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>
258 lines
8.2 KiB
Dart
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();
|