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

185 lines
7.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../player.dart';
import '../theme.dart';
import '../widgets.dart';
class OverviewScreen extends StatefulWidget {
const OverviewScreen({super.key});
@override
State<OverviewScreen> createState() => _OverviewScreenState();
}
class _OverviewScreenState extends State<OverviewScreen> {
late Future<(Map<String, int>, List<AdminTrack>)> _data = _load();
Future<(Map<String, int>, List<AdminTrack>)> _load() async =>
(await AdminApi.overview(), await AdminApi.recentPending());
void _reload() => setState(() => _data = _load());
@override
Widget build(BuildContext context) => Loader(
future: _data,
onRetry: _reload,
builder: (context, data) {
final (n, pending) = data;
final attention = [
if (n['pending']! > 0)
_Todo(Icons.hourglass_top, NkColors.sun, '${n['pending']} songs waiting for review',
'Listen, fix the details and approve them.', '/songs?show=pending'),
if (n['applications']! > 0)
_Todo(Icons.how_to_reg, NkColors.sun, '${n['applications']} creator applications',
'Check their links and ID, then approve or ask for more.', '/applications'),
if (n['takedowns']! > 0)
_Todo(Icons.report_outlined, NkColors.roofRed, '${n['takedowns']} open takedown requests',
'Handle these in Studio (takedown_requests) for now.', null),
if (n['no_singer']! > 0)
_Todo(Icons.mic_off_outlined, NkColors.info, '${n['no_singer']} songs without a singer',
'Credit the singers so listeners can find them.', '/songs?show=no_singer'),
if (n['no_album']! > 0)
_Todo(Icons.album_outlined, NkColors.info, '${n['no_album']} songs without an album',
'Group them into albums.', '/songs?show=no_album'),
if (n['needs_changes']! > 0)
_Todo(Icons.edit_note, NkColors.info, '${n['needs_changes']} songs marked "needs changes"',
'Follow up once they are fixed.', '/songs?show=needs_changes'),
if (n['empty_albums']! > 0)
_Todo(Icons.folder_off_outlined, NkColors.muted, '${n['empty_albums']} empty albums',
'Delete them or move songs in.', '/albums'),
];
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
PageHeader('Overview', subtitle: 'The Nanga Kathai catalog at a glance', actions: [
IconButton(tooltip: 'Refresh', onPressed: _reload, icon: const Icon(Icons.refresh)),
]),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
_Stat('Live songs', n['approved']!, '/songs?show=live'),
_Stat('Waiting for review', n['pending']!, '/songs?show=pending', highlight: n['pending']! > 0),
_Stat('Hidden', n['hidden']!, '/songs?show=hidden'),
_Stat('All songs', n['songs']!, '/songs'),
_Stat('Albums', n['albums']!, '/albums'),
_Stat('Singers', n['singers']!, '/singers'),
_Stat('Listener accounts', n['listeners']!, null),
],
),
),
const SizedBox(height: 28),
_sectionTitle(context, 'Needs attention'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: attention.isEmpty
? const EmptyState('All clear. Nothing needs attention right now.',
icon: Icons.check_circle_outline)
: Column(
children: [
for (final (i, t) in attention.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: Icon(t.icon, color: t.color),
title: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text(t.hint),
trailing: t.path == null ? null : const Icon(Icons.chevron_right),
onTap: t.path == null ? null : () => context.go(t.path!),
),
],
],
),
),
),
if (pending.isNotEmpty) ...[
const SizedBox(height: 28),
_sectionTitle(context, 'Latest waiting for review'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: Column(
children: [
for (final t in pending)
ListTile(
leading: PlayButton(t),
title: Text(t.title),
subtitle: Text('${creditLine(t)} · ${t.albumTitle ?? 'No album'}'),
trailing: Text(formatDate(t.createdAt), style: Theme.of(context).textTheme.bodySmall),
),
const Divider(),
TextButton(
onPressed: () => context.go('/songs?show=pending'),
child: const Text('Review all'),
),
],
),
),
),
],
],
),
);
},
);
Widget _sectionTitle(BuildContext context, String text) => Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 10),
child: Text(text, style: Theme.of(context).textTheme.titleLarge),
);
}
class _Todo {
const _Todo(this.icon, this.color, this.title, this.hint, this.path);
final IconData icon;
final Color color;
final String title;
final String hint;
final String? path;
}
class _Stat extends StatelessWidget {
const _Stat(this.label, this.value, this.path, {this.highlight = false});
final String label;
final int value;
final String? path;
final bool highlight;
@override
Widget build(BuildContext context) => SizedBox(
width: 180,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: highlight ? NkColors.sun : NkColors.outline),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: path == null ? null : () => context.go(path!),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$value',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: highlight ? NkColors.sun : NkColors.cream)),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
),
);
}