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>
154 lines
5.1 KiB
Dart
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'),
|
|
),
|
|
),
|
|
);
|
|
}
|