nkbmusic/admin/lib/shell.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

154 lines
5.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'player.dart';
import 'session.dart';
import 'theme.dart';
import 'widgets.dart';
class _Section {
const _Section(this.path, this.label, this.icon);
final String path;
final String label;
final IconData icon;
}
const _sections = [
_Section('/', 'Overview', Icons.dashboard_outlined),
_Section('/songs', 'Songs', Icons.music_note_outlined),
_Section('/albums', 'Albums', Icons.album_outlined),
_Section('/singers', 'Singers', Icons.mic_none_outlined),
_Section('/applications', 'Creators', Icons.how_to_reg_outlined),
];
/// Sidebar (wide screens) or drawer (phones), the page, and the player bar.
class AdminShell extends StatelessWidget {
const AdminShell({super.key, required this.location, required this.child});
final String location;
final Widget child;
int get _selected {
final i = _sections.lastIndexWhere((s) => s.path == '/' ? location == '/' : location.startsWith(s.path));
return i < 0 ? 0 : i;
}
@override
Widget build(BuildContext context) {
final session = AdminSession.instance;
return ListenableBuilder(
listenable: session,
builder: (context, _) {
if (session.state != SessionState.admin) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final wide = MediaQuery.sizeOf(context).width >= 900;
final page = Column(children: [Expanded(child: child), const PlayerBar()]);
if (!wide) {
return Scaffold(
appBar: AppBar(
backgroundColor: NkColors.surface,
title: Text(_sections[_selected].label),
),
drawer: Drawer(
backgroundColor: NkColors.surface,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(padding: EdgeInsets.all(20), child: _Brand()),
for (final (i, s) in _sections.indexed)
ListTile(
leading: Icon(s.icon, color: i == _selected ? NkColors.sun : NkColors.muted),
title: Text(s.label),
selected: i == _selected,
selectedColor: NkColors.sun,
onTap: () {
Navigator.of(context).pop();
context.go(s.path);
},
),
const Spacer(),
_Account(email: session.email),
],
),
),
),
body: page,
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: MediaQuery.sizeOf(context).width >= 1200,
minExtendedWidth: 220,
selectedIndex: _selected,
onDestinationSelected: (i) => context.go(_sections[i].path),
leading: const Padding(padding: EdgeInsets.fromLTRB(12, 20, 12, 24), child: _Brand()),
trailing: Expanded(
child: Align(alignment: Alignment.bottomLeft, child: _Account(email: session.email)),
),
destinations: [
for (final s in _sections)
NavigationRailDestination(icon: Icon(s.icon), label: Text(s.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: page),
],
),
);
},
);
}
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: const BoxDecoration(color: NkColors.sun, shape: BoxShape.circle),
child: const Text('NK', style: TextStyle(color: NkColors.ground, fontWeight: FontWeight.w900)),
),
if (MediaQuery.sizeOf(context).width >= 1200 || MediaQuery.sizeOf(context).width < 900) ...[
const SizedBox(width: 10),
Text('NK Admin', style: Theme.of(context).textTheme.titleLarge),
],
],
);
}
class _Account extends StatelessWidget {
const _Account({required this.email});
final String? email;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(12),
child: Tooltip(
message: email ?? '',
child: TextButton.icon(
onPressed: () async {
if (await confirm(context, 'Sign out?', 'You will need a new email code to sign in again.',
action: 'Sign out')) {
await AdminPlayer.instance.close();
await AdminApi.signOut();
}
},
icon: const Icon(Icons.logout, size: 18),
label: const Text('Sign out'),
),
),
);
}