nkbmusic/admin/lib/screens/singers_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

189 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
class SingersScreen extends StatefulWidget {
const SingersScreen({super.key});
@override
State<SingersScreen> createState() => _SingersScreenState();
}
class _SingersScreenState extends State<SingersScreen> {
String _query = '';
late Future<List<AdminArtist>> _data = AdminApi.artists();
void _reload() => setState(() => _data = AdminApi.artists(query: _query));
Future<void> _run(Future<void> job, String done) async {
try {
await job;
if (mounted) showMessage(context, done);
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _edit([AdminArtist? a]) async {
final name = TextEditingController(text: a?.name ?? '');
final tamil = TextEditingController(text: a?.nameTamil ?? '');
final hatti = TextEditingController(text: a?.hattiName ?? '');
final bio = TextEditingController(text: a?.bio ?? '');
var verified = a?.verified ?? false;
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text(a == null ? 'New singer' : 'Edit singer'),
content: DialogBody(children: [
TextField(controller: name, autofocus: true, decoration: const InputDecoration(labelText: 'Name (English letters)')),
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Name in Tamil script')),
TextField(controller: hatti, decoration: const InputDecoration(labelText: 'Village / hatti')),
TextField(controller: bio, maxLines: 4, decoration: const InputDecoration(labelText: 'About')),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Verified'),
subtitle: const Text('We have confirmed who this singer is.'),
value: verified,
onChanged: (v) => setLocal(() => verified = v),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Save')),
],
),
),
);
if (ok != true || name.text.trim().isEmpty) return;
if (a == null) {
await _run(
AdminApi.createArtist(
name: name.text.trim(),
nameTamil: trimmedOrNull(tamil.text),
hatti: hatti.text,
bio: trimmedOrNull(bio.text),
verified: verified),
'Singer added',
);
} else {
await _run(
AdminApi.updateArtist(a.id,
name: name.text.trim(),
nameTamil: trimmedOrNull(tamil.text),
hatti: hatti.text,
bio: trimmedOrNull(bio.text),
verified: verified),
'Singer saved',
);
}
}
Future<void> _merge(AdminArtist from, List<AdminArtist> all) async {
var into = <String>[];
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text('Merge "${from.name}"'),
content: DialogBody(children: [
Text('Their ${from.trackCount} songs are credited to the singer you choose, then "${from.name}" is '
'deleted. Use this when the same person was entered twice.'),
SingerPicker(
artists: [for (final a in all) if (a.id != from.id) a],
selected: into,
onChanged: (v) => setLocal(() => into = v.isEmpty ? v : [v.last]),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: into.isEmpty ? null : () => Navigator.pop(context, true), child: const Text('Merge')),
],
),
),
);
if (ok == true && into.isNotEmpty) await _run(AdminApi.mergeArtists(from.id, into.single), 'Singers merged');
}
Future<void> _delete(AdminArtist a) async {
final body = a.trackCount == 0
? 'No songs credit this singer.'
: 'Their name is removed from ${a.trackCount} songs. The songs stay.';
if (await confirm(context, 'Delete "${a.name}"?', body, action: 'Delete', danger: true)) {
await _run(AdminApi.deleteArtist(a.id), 'Singer deleted');
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader('Singers', subtitle: 'Names, villages, verification and duplicates', actions: [
SearchField(
hint: 'Search singers',
onChanged: (q) {
_query = q;
_reload();
},
),
FilledButton.icon(onPressed: _edit, icon: const Icon(Icons.add), label: const Text('New singer')),
]),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, artists) => artists.isEmpty
? const EmptyState('No singers found.', icon: Icons.mic_off_outlined)
: ListView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
children: [
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, a) in artists.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: NkColors.raised,
child: Text(a.name.characters.first.toUpperCase(),
style: const TextStyle(color: NkColors.sun, fontWeight: FontWeight.w800)),
),
title: Text(a.name, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text([?a.nameTamil, ?a.hattiName, '${a.trackCount} songs'].join(' · ')),
onTap: () => context.go(Uri(path: '/songs',
queryParameters: {'singer': a.id, 'name': a.name}).toString()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (a.verified) const Tag('Verified', color: NkColors.teaLeaf),
if (a.claimed) ...[const SizedBox(width: 6), const Tag('Has account', color: NkColors.info)],
IconButton(tooltip: 'Edit', onPressed: () => _edit(a), icon: const Icon(Icons.edit_outlined)),
PopupMenuButton<String>(
tooltip: 'More',
onSelected: (v) => v == 'merge' ? _merge(a, artists) : _delete(a),
itemBuilder: (context) => const [
PopupMenuItem(value: 'merge', child: Text('Merge into another singer…')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
],
],
),
),
],
),
),
),
],
);
}