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

258 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'api.dart';
import 'theme.dart';
String creditLine(AdminTrack t) => t.singerNames.isEmpty ? 'No singer yet' : t.singerNames;
String formatDuration(Duration d) {
final s = d.inSeconds;
return '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}';
}
String formatDate(DateTime d) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final l = d.toLocal();
return '${l.day} ${months[l.month - 1]} ${l.year}';
}
String errorText(Object e) => switch (e) {
PostgrestException(:final message) => message,
AuthException(:final message) => message,
StorageException(:final message) => message,
_ => '$e',
};
void showMessage(BuildContext context, String text) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
void showError(BuildContext context, Object e) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(errorText(e)), backgroundColor: NkColors.roofRed));
Future<bool> confirm(BuildContext context, String title, String body,
{String action = 'OK', bool danger = false}) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 420), child: Text(body)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
style: danger ? FilledButton.styleFrom(backgroundColor: NkColors.roofRed, foregroundColor: NkColors.cream) : null,
onPressed: () => Navigator.pop(context, true),
child: Text(action),
),
],
),
);
return ok ?? false;
}
/// Title row at the top of each page.
class PageHeader extends StatelessWidget {
const PageHeader(this.title, {super.key, this.subtitle, this.actions = const []});
final String title;
final String? subtitle;
final List<Widget> actions;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
child: Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
runSpacing: 12,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: Theme.of(context).textTheme.headlineSmall),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
],
],
),
Wrap(spacing: 8, runSpacing: 8, children: actions),
],
),
);
}
class StatusChip extends StatelessWidget {
const StatusChip(this.status, {super.key});
final SongStatus status;
@override
Widget build(BuildContext context) {
final color = switch (status) {
SongStatus.approved => NkColors.teaLeaf,
SongStatus.pending => NkColors.sun,
SongStatus.needsChanges => NkColors.info,
SongStatus.draft => NkColors.muted,
SongStatus.rejected || SongStatus.takenDown => NkColors.roofRed,
};
return Tag(status.label, color: color);
}
}
class Tag extends StatelessWidget {
const Tag(this.text, {super.key, this.color = NkColors.muted});
final String text;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(99),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Text(text, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w700)),
);
}
/// Shows a spinner, an error with a retry button, or the loaded data.
class Loader<T> extends StatelessWidget {
const Loader({super.key, required this.future, required this.onRetry, required this.builder});
final Future<T>? future;
final VoidCallback onRetry;
final Widget Function(BuildContext context, T data) builder;
@override
Widget build(BuildContext context) => FutureBuilder<T>(
future: future,
builder: (context, snap) {
if (snap.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, color: NkColors.muted, size: 40),
const SizedBox(height: 12),
Text(errorText(snap.error!), textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Try again')),
],
),
);
}
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
return builder(context, snap.data as T);
},
);
}
class EmptyState extends StatelessWidget {
const EmptyState(this.text, {super.key, this.icon = Icons.inbox_outlined});
final String text;
final IconData icon;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: NkColors.muted, size: 40),
const SizedBox(height: 12),
Text(text, textAlign: TextAlign.center, style: const TextStyle(color: NkColors.muted)),
],
),
),
);
}
/// Search box that reports its text after a short pause in typing.
class SearchField extends StatefulWidget {
const SearchField({super.key, required this.hint, required this.onChanged, this.initial = '', this.width = 320});
final String hint;
final String initial;
final double width;
final ValueChanged<String> onChanged;
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> {
late final _text = TextEditingController(text: widget.initial);
int _typing = 0;
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _changed(String v) {
final n = ++_typing;
Future.delayed(const Duration(milliseconds: 350), () {
if (mounted && n == _typing) widget.onChanged(v.trim());
});
}
@override
Widget build(BuildContext context) => SizedBox(
width: widget.width,
child: TextField(
controller: _text,
onChanged: _changed,
onSubmitted: (v) => widget.onChanged(v.trim()),
decoration: InputDecoration(
hintText: widget.hint,
prefixIcon: const Icon(Icons.search, color: NkColors.muted),
suffixIcon: ListenableBuilder(
listenable: _text,
builder: (context, _) => _text.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.close, size: 18),
onPressed: () {
_text.clear();
widget.onChanged('');
},
),
),
),
),
);
}
/// A dialog body with a fixed width that scrolls when tall.
class DialogBody extends StatelessWidget {
const DialogBody({super.key, required this.children, this.width = 520});
final List<Widget> children;
final double width;
@override
Widget build(BuildContext context) => SizedBox(
width: width,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in children.indexed) ...[if (i > 0) const SizedBox(height: 14), c],
],
),
),
);
}
String? trimmedOrNull(String s) => s.trim().isEmpty ? null : s.trim();