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>
207 lines
7.1 KiB
Dart
207 lines
7.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'api.dart';
|
|
import 'theme.dart';
|
|
import 'widgets.dart';
|
|
|
|
/// Chooses singers in order: type to search, pick a match, or add a new singer by name.
|
|
class SingerPicker extends StatefulWidget {
|
|
const SingerPicker({super.key, required this.artists, required this.selected, required this.onChanged});
|
|
|
|
final List<AdminArtist> artists;
|
|
final List<String> selected;
|
|
final ValueChanged<List<String>> onChanged;
|
|
|
|
@override
|
|
State<SingerPicker> createState() => _SingerPickerState();
|
|
}
|
|
|
|
class _SingerPickerState extends State<SingerPicker> {
|
|
late final _names = {for (final a in widget.artists) a.id: a.name};
|
|
bool _adding = false;
|
|
|
|
void _add(String id) {
|
|
if (widget.selected.contains(id)) return;
|
|
widget.onChanged([...widget.selected, id]);
|
|
}
|
|
|
|
Future<void> _createSinger(String name) async {
|
|
setState(() => _adding = true);
|
|
try {
|
|
final id = await AdminApi.createArtist(name: name);
|
|
_names[id] = name;
|
|
_add(id);
|
|
if (mounted) showMessage(context, 'Added singer "$name"');
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
} finally {
|
|
if (mounted) setState(() => _adding = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (widget.selected.isNotEmpty) ...[
|
|
ReorderableListView(
|
|
shrinkWrap: true,
|
|
buildDefaultDragHandles: false,
|
|
onReorderItem: (from, to) {
|
|
final list = [...widget.selected];
|
|
list.insert(to, list.removeAt(from));
|
|
widget.onChanged(list);
|
|
},
|
|
children: [
|
|
for (final (i, id) in widget.selected.indexed)
|
|
ListTile(
|
|
key: ValueKey(id),
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: ReorderableDragStartListener(
|
|
index: i,
|
|
child: const Icon(Icons.drag_indicator, color: NkColors.muted),
|
|
),
|
|
title: Text(_names[id] ?? 'Unknown singer'),
|
|
trailing: IconButton(
|
|
tooltip: 'Remove',
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: () => widget.onChanged([...widget.selected]..remove(id)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
_SearchAdd(
|
|
label: _adding ? 'Adding…' : 'Add a singer',
|
|
options: [
|
|
for (final a in widget.artists)
|
|
if (!widget.selected.contains(a.id)) (a.id, a.name, a.hattiName),
|
|
],
|
|
onPick: _add,
|
|
createLabel: (q) => 'New singer "$q"',
|
|
onCreate: _createSinger,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Chooses one album, or none; can create a new album.
|
|
class AlbumPicker extends StatefulWidget {
|
|
const AlbumPicker({super.key, required this.albums, required this.albumId, required this.onChanged});
|
|
|
|
final List<AdminAlbum> albums;
|
|
final String? albumId;
|
|
final ValueChanged<String?> onChanged;
|
|
|
|
@override
|
|
State<AlbumPicker> createState() => _AlbumPickerState();
|
|
}
|
|
|
|
class _AlbumPickerState extends State<AlbumPicker> {
|
|
late final _titles = {for (final a in widget.albums) a.id: a.title};
|
|
|
|
Future<void> _create(String title) async {
|
|
try {
|
|
final id = await AdminApi.createAlbum(title: title);
|
|
_titles[id] = title;
|
|
widget.onChanged(id);
|
|
if (mounted) showMessage(context, 'Added album "$title"');
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final current = widget.albumId;
|
|
if (current != null) {
|
|
return InputDecorator(
|
|
decoration: const InputDecoration(labelText: 'Album'),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(_titles[current] ?? 'Unknown album')),
|
|
TextButton(onPressed: () => widget.onChanged(null), child: const Text('Change')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return _SearchAdd(
|
|
label: 'Album (type to search)',
|
|
options: [for (final a in widget.albums) (a.id, a.title, a.year?.toString())],
|
|
onPick: widget.onChanged,
|
|
createLabel: (q) => 'New album "$q"',
|
|
onCreate: _create,
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef _Option = (String id, String name, String? detail);
|
|
|
|
class _SearchAdd extends StatelessWidget {
|
|
const _SearchAdd({
|
|
required this.label,
|
|
required this.options,
|
|
required this.onPick,
|
|
required this.createLabel,
|
|
required this.onCreate,
|
|
});
|
|
|
|
final String label;
|
|
final List<_Option> options;
|
|
final ValueChanged<String> onPick;
|
|
final String Function(String query) createLabel;
|
|
final ValueChanged<String> onCreate;
|
|
|
|
static const _createId = '\u0000create';
|
|
|
|
@override
|
|
Widget build(BuildContext context) => LayoutBuilder(
|
|
builder: (context, box) => Autocomplete<_Option>(
|
|
displayStringForOption: (o) => '',
|
|
optionsBuilder: (value) {
|
|
final q = value.text.trim().toLowerCase();
|
|
if (q.isEmpty) return const [];
|
|
final matches = options.where((o) => o.$2.toLowerCase().contains(q)).take(12).toList()
|
|
..sort((a, b) => (a.$2.toLowerCase().startsWith(q) ? 0 : 1)
|
|
.compareTo(b.$2.toLowerCase().startsWith(q) ? 0 : 1));
|
|
final exact = options.any((o) => o.$2.toLowerCase() == q);
|
|
return [...matches, if (!exact) (_createId, value.text.trim(), null)];
|
|
},
|
|
onSelected: (o) => o.$1 == _createId ? onCreate(o.$2) : onPick(o.$1),
|
|
fieldViewBuilder: (context, text, focus, submit) => TextField(
|
|
controller: text,
|
|
focusNode: focus,
|
|
decoration: InputDecoration(labelText: label, prefixIcon: const Icon(Icons.search, color: NkColors.muted)),
|
|
),
|
|
optionsViewBuilder: (context, select, items) => Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Material(
|
|
color: NkColors.raised,
|
|
elevation: 6,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: 280, maxWidth: box.maxWidth),
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
children: [
|
|
for (final o in items)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(o.$1 == _createId ? Icons.add : Icons.check_circle_outline,
|
|
color: o.$1 == _createId ? NkColors.sun : NkColors.muted, size: 20),
|
|
title: Text(o.$1 == _createId ? createLabel(o.$2) : o.$2),
|
|
subtitle: o.$3 == null ? null : Text(o.$3!),
|
|
onTap: () => select(o),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|