nkbmusic/admin/lib/screens/songs_screen.dart
metatroncubeswdev cf645071aa Add NK Admin dashboard (Flutter web) for admin.nkbmusic.com
Email-code sign-in for admins only; Overview with a needs-attention list;
Songs with search, playback, editing, approve/hide and bulk edit; Albums
and Singers with merge; creator applications queue. Adds the admin views,
functions and creator_applications table, a deploy-admin script and a
how-to guide. The admin nginx config no longer caches unhashed JS and
sends its security headers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:35:44 -04:00

300 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../player.dart';
import '../theme.dart';
import '../widgets.dart';
import 'song_editor.dart';
class SongsScreen extends StatefulWidget {
const SongsScreen({
super.key,
this.filter = SongFilter.all,
this.query = '',
this.albumId,
this.artistId,
this.filterName,
});
final SongFilter filter;
final String query;
final String? albumId;
final String? artistId;
/// Album or singer name, shown when the list is limited to one of them.
final String? filterName;
@override
State<SongsScreen> createState() => _SongsScreenState();
}
class _SongsScreenState extends State<SongsScreen> {
static const _pageSize = 50;
late SongFilter _filter = widget.filter;
late String _query = widget.query;
bool _newestFirst = true;
int _page = 0;
final _selected = <String>{};
late Future<ResultPage<AdminTrack>> _data = _load();
Future<ResultPage<AdminTrack>> _load() async {
final page = await AdminApi.tracks(
query: _query,
filter: _filter,
albumId: widget.albumId,
artistId: widget.artistId,
newestFirst: _newestFirst,
page: _page,
pageSize: _pageSize,
);
return page;
}
void _reload({bool resetPage = false, bool keepSelection = true}) => setState(() {
if (resetPage) _page = 0;
if (!keepSelection) _selected.clear();
_data = _load();
});
Future<void> _setStatus(List<String> ids, SongStatus status) async {
try {
await AdminApi.setStatus(ids, status);
if (!mounted) return;
showMessage(context, '${ids.length == 1 ? 'Song' : '${ids.length} songs'}: ${status.label.toLowerCase()}');
_selected.removeAll(ids);
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _edit(AdminTrack t) async {
if (await editSong(context, t)) _reload();
}
Future<void> _bulkEdit() async {
if (await bulkEditSongs(context, _selected.toList())) {
if (!mounted) return;
showMessage(context, 'Updated ${_selected.length} songs');
_reload(keepSelection: false);
}
}
@override
Widget build(BuildContext context) {
final scoped = widget.albumId != null || widget.artistId != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
'Songs',
subtitle: scoped
? '${widget.albumId != null ? 'Album' : 'Singer'}: ${widget.filterName ?? ''}'
: 'Search, listen, fix details and publish',
actions: [
if (scoped)
OutlinedButton.icon(
onPressed: () => context.go('/songs'),
icon: const Icon(Icons.close, size: 18),
label: const Text('Show all songs'),
),
SearchField(
hint: 'Title, singer, album…',
initial: _query,
onChanged: (q) {
_query = q;
_reload(resetPage: true, keepSelection: false);
},
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final f in SongFilter.values)
ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
_filter = f;
_reload(resetPage: true, keepSelection: false);
},
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
_newestFirst = !_newestFirst;
_reload(resetPage: true);
},
icon: const Icon(Icons.sort, size: 18),
label: Text(_newestFirst ? 'Newest first' : 'A to Z'),
),
],
),
),
const SizedBox(height: 8),
if (_selected.isNotEmpty) _bulkBar(),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, page) => page.items.isEmpty
? const EmptyState('No songs match.', icon: Icons.music_off_outlined)
: _list(page),
),
),
],
);
}
Widget _bulkBar() => Container(
margin: const EdgeInsets.fromLTRB(24, 4, 24, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: NkColors.raised, borderRadius: BorderRadius.circular(14)),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('${_selected.length} selected', style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () => _setStatus(_selected.toList(), SongStatus.approved),
icon: const Icon(Icons.check, size: 18),
label: const Text('Approve'),
),
OutlinedButton.icon(
onPressed: () => _setStatus(_selected.toList(), SongStatus.takenDown),
icon: const Icon(Icons.visibility_off_outlined, size: 18),
label: const Text('Hide'),
),
OutlinedButton.icon(
onPressed: _bulkEdit,
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit…'),
),
TextButton(onPressed: () => setState(_selected.clear), child: const Text('Clear selection')),
],
),
);
Widget _list(ResultPage<AdminTrack> page) {
final pageIds = page.items.map((t) => t.id).toSet();
final allOnPage = pageIds.every(_selected.contains);
final wide = MediaQuery.sizeOf(context).width >= 1100;
final first = _page * _pageSize + 1;
final last = first + page.items.length - 1;
return ListView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
children: [
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
dense: true,
leading: Checkbox(
value: allOnPage,
onChanged: (v) => setState(() => v == true ? _selected.addAll(pageIds) : _selected.removeAll(pageIds)),
),
title: Text('$first–$last of ${page.total}', style: Theme.of(context).textTheme.bodySmall),
),
for (final t in page.items) ...[const Divider(), _row(t, wide)],
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _page == 0
? null
: () {
_page--;
_reload();
},
child: const Text('Previous'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: last >= page.total
? null
: () {
_page++;
_reload();
},
child: const Text('Next'),
),
],
),
],
);
}
Widget _row(AdminTrack t, bool wide) {
final small = Theme.of(context).textTheme.bodySmall;
final quick = t.status == SongStatus.approved
? IconButton(
tooltip: 'Hide from the app',
onPressed: () => _setStatus([t.id], SongStatus.takenDown),
icon: const Icon(Icons.visibility_off_outlined),
)
: IconButton(
tooltip: 'Approve (publish in the app)',
onPressed: t.audioKey == null ? null : () => _setStatus([t.id], SongStatus.approved),
icon: const Icon(Icons.check_circle_outline, color: NkColors.teaLeaf),
);
return InkWell(
onTap: () => _edit(t),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Checkbox(
value: _selected.contains(t.id),
onChanged: (v) => setState(() => v == true ? _selected.add(t.id) : _selected.remove(t.id)),
),
PlayButton(t),
const SizedBox(width: 8),
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(
wide ? creditLine(t) : '${creditLine(t)} · ${t.albumTitle ?? 'No album'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: t.singerNames.isEmpty ? NkColors.info : null),
),
],
),
),
if (wide) ...[
Expanded(
flex: 3,
child: Text(t.albumTitle ?? 'No album', maxLines: 1, overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: t.albumId == null ? NkColors.info : null)),
),
SizedBox(width: 150, child: Text(occasions[t.occasion] ?? t.occasion, style: small)),
SizedBox(width: 50, child: Text(formatDuration(t.duration), style: small)),
],
SizedBox(width: 150, child: Align(alignment: Alignment.centerLeft, child: StatusChip(t.status))),
quick,
IconButton(tooltip: 'Edit', onPressed: () => _edit(t), icon: const Icon(Icons.edit_outlined)),
],
),
),
);
}
}