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>
315 lines
11 KiB
Dart
315 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api.dart';
|
|
import '../pickers.dart';
|
|
import '../theme.dart';
|
|
import '../widgets.dart';
|
|
|
|
typedef _Lookups = (List<AdminArtist>, List<AdminAlbum>);
|
|
|
|
Future<_Lookups> _lookups() async => (await AdminApi.artists(), await AdminApi.albums());
|
|
|
|
/// Edits one song. Returns true when something was saved.
|
|
Future<bool> editSong(BuildContext context, AdminTrack track) async =>
|
|
await showDialog<bool>(context: context, builder: (_) => _LookupsDialog(builder: (l) => _SongForm(track, l))) ??
|
|
false;
|
|
|
|
/// Changes chosen fields on many songs at once. Returns true when something was saved.
|
|
Future<bool> bulkEditSongs(BuildContext context, List<String> ids) async =>
|
|
await showDialog<bool>(context: context, builder: (_) => _LookupsDialog(builder: (l) => _BulkForm(ids, l))) ??
|
|
false;
|
|
|
|
class _LookupsDialog extends StatefulWidget {
|
|
const _LookupsDialog({required this.builder});
|
|
|
|
final Widget Function(_Lookups lookups) builder;
|
|
|
|
@override
|
|
State<_LookupsDialog> createState() => _LookupsDialogState();
|
|
}
|
|
|
|
class _LookupsDialogState extends State<_LookupsDialog> {
|
|
late Future<_Lookups> _data = _lookups();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => FutureBuilder(
|
|
future: _data,
|
|
builder: (context, snap) {
|
|
if (snap.hasData) return widget.builder(snap.data!);
|
|
return AlertDialog(
|
|
content: SizedBox(
|
|
width: 320,
|
|
height: 120,
|
|
child: snap.hasError
|
|
? Center(child: Text(errorText(snap.error!)))
|
|
: const Center(child: CircularProgressIndicator()),
|
|
),
|
|
actions: [
|
|
if (snap.hasError) TextButton(onPressed: () => setState(() => _data = _lookups()), child: const Text('Retry')),
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _occasionField(String? value, ValueChanged<String?> onChanged) => DropdownButtonFormField<String>(
|
|
initialValue: value,
|
|
decoration: const InputDecoration(labelText: 'Occasion'),
|
|
items: [for (final e in occasions.entries) DropdownMenuItem(value: e.key, child: Text(e.value))],
|
|
onChanged: onChanged,
|
|
);
|
|
|
|
Widget _statusField(SongStatus? value, ValueChanged<SongStatus?> onChanged) => DropdownButtonFormField<SongStatus>(
|
|
initialValue: value,
|
|
decoration: const InputDecoration(labelText: 'Status'),
|
|
items: [for (final s in SongStatus.values) DropdownMenuItem(value: s, child: Text(s.label))],
|
|
onChanged: onChanged,
|
|
);
|
|
|
|
class _SongForm extends StatefulWidget {
|
|
const _SongForm(this.track, this.lookups);
|
|
|
|
final AdminTrack track;
|
|
final _Lookups lookups;
|
|
|
|
@override
|
|
State<_SongForm> createState() => _SongFormState();
|
|
}
|
|
|
|
class _SongFormState extends State<_SongForm> {
|
|
late final t = widget.track;
|
|
late final _title = TextEditingController(text: t.title);
|
|
late final _tamil = TextEditingController(text: t.titleTamil ?? '');
|
|
late final _meaning = TextEditingController(text: t.meaning ?? '');
|
|
late final _year = TextEditingController(text: t.year?.toString() ?? '');
|
|
late String? _albumId = t.albumId;
|
|
late String _occasion = t.occasion;
|
|
late List<String> _singers = [...t.singerIds];
|
|
late SongStatus _status = t.status;
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in [_title, _tamil, _meaning, _year]) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (_title.text.trim().isEmpty) {
|
|
showMessage(context, 'The song needs a title.');
|
|
return;
|
|
}
|
|
final year = int.tryParse(_year.text.trim());
|
|
if (_year.text.trim().isNotEmpty && year == null) {
|
|
showMessage(context, 'The year should be a number, like 1998.');
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
try {
|
|
await AdminApi.saveSong(
|
|
t,
|
|
title: _title.text.trim(),
|
|
titleTamil: trimmedOrNull(_tamil.text),
|
|
meaning: trimmedOrNull(_meaning.text),
|
|
year: year,
|
|
albumId: _albumId,
|
|
occasion: _occasion,
|
|
status: _status,
|
|
singerIds: _singers,
|
|
);
|
|
if (mounted) Navigator.pop(context, true);
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (artists, albums) = widget.lookups;
|
|
return AlertDialog(
|
|
title: const Text('Edit song'),
|
|
content: DialogBody(
|
|
width: 560,
|
|
children: [
|
|
TextField(controller: _title, decoration: const InputDecoration(labelText: 'Title (English letters)')),
|
|
TextField(controller: _tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
|
|
TextField(controller: _meaning, decoration: const InputDecoration(labelText: 'Meaning in English')),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _occasionField(_occasion, (v) => setState(() => _occasion = v ?? _occasion))),
|
|
const SizedBox(width: 12),
|
|
SizedBox(
|
|
width: 120,
|
|
child: TextField(
|
|
controller: _year,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Year'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
AlbumPicker(albums: albums, albumId: _albumId, onChanged: (v) => setState(() => _albumId = v)),
|
|
Text('Singers', style: Theme.of(context).textTheme.titleMedium),
|
|
SingerPicker(artists: artists, selected: _singers, onChanged: (v) => setState(() => _singers = v)),
|
|
_statusField(_status, (v) => setState(() => _status = v ?? _status)),
|
|
if (t.status == SongStatus.pending && _status == SongStatus.pending)
|
|
const Text('Tip: set the status to "Live" to publish this song in the app.',
|
|
style: TextStyle(color: NkColors.muted)),
|
|
_Facts(t),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(onPressed: _saving ? null : _save, child: Text(_saving ? 'Saving…' : 'Save')),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Facts extends StatelessWidget {
|
|
const _Facts(this.t);
|
|
|
|
final AdminTrack t;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final style = Theme.of(context).textTheme.bodySmall;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Text('Added ${formatDate(t.createdAt)} · ${formatDuration(t.duration)}', style: style),
|
|
if (t.sourceNote != null) Text('Source: ${t.sourceNote}', style: style),
|
|
if (t.audioKey != null) SelectableText('File: ${t.audioKey}', style: style),
|
|
if (t.reviewNote != null) Text('Review note: ${t.reviewNote}', style: style),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BulkForm extends StatefulWidget {
|
|
const _BulkForm(this.ids, this.lookups);
|
|
|
|
final List<String> ids;
|
|
final _Lookups lookups;
|
|
|
|
@override
|
|
State<_BulkForm> createState() => _BulkFormState();
|
|
}
|
|
|
|
class _BulkFormState extends State<_BulkForm> {
|
|
bool _setAlbum = false, _setOccasion = false, _setSingers = false, _setStatus = false;
|
|
String? _albumId;
|
|
String? _occasion;
|
|
List<String> _singers = [];
|
|
bool _replaceSingers = false;
|
|
SongStatus? _status;
|
|
bool _saving = false;
|
|
|
|
Future<void> _save() async {
|
|
if (_setOccasion && _occasion == null || _setStatus && _status == null) {
|
|
showMessage(context, 'Choose a value for each ticked field.');
|
|
return;
|
|
}
|
|
if (_setSingers && _singers.isEmpty && !_replaceSingers) {
|
|
showMessage(context, 'Choose at least one singer to add.');
|
|
return;
|
|
}
|
|
if (!(_setAlbum || _setOccasion || _setSingers || _setStatus)) {
|
|
showMessage(context, 'Tick at least one field to change.');
|
|
return;
|
|
}
|
|
setState(() => _saving = true);
|
|
try {
|
|
await AdminApi.updateSongs(
|
|
widget.ids,
|
|
SongChanges(
|
|
albumId: _setAlbum ? _albumId : null,
|
|
clearAlbum: _setAlbum && _albumId == null,
|
|
occasion: _setOccasion ? _occasion : null,
|
|
status: _setStatus ? _status : null,
|
|
singerIds: _setSingers ? _singers : null,
|
|
replaceSingers: _replaceSingers,
|
|
),
|
|
);
|
|
if (mounted) Navigator.pop(context, true);
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
Widget _toggle(String label, bool value, ValueChanged<bool> onChanged, Widget child) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
title: Text(label, style: const TextStyle(fontWeight: FontWeight.w700)),
|
|
value: value,
|
|
onChanged: (v) => setState(() => onChanged(v ?? false)),
|
|
),
|
|
if (value) Padding(padding: const EdgeInsets.only(left: 40), child: child),
|
|
],
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (artists, albums) = widget.lookups;
|
|
return AlertDialog(
|
|
title: Text('Edit ${widget.ids.length} songs'),
|
|
content: DialogBody(
|
|
width: 560,
|
|
children: [
|
|
const Text('Tick what to change. Everything else stays as it is.', style: TextStyle(color: NkColors.muted)),
|
|
_toggle('Album', _setAlbum, (v) => _setAlbum = v, Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
AlbumPicker(albums: albums, albumId: _albumId, onChanged: (v) => setState(() => _albumId = v)),
|
|
if (_albumId == null)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 6),
|
|
child: Text('No album chosen: the songs will be removed from their album.',
|
|
style: TextStyle(color: NkColors.muted)),
|
|
),
|
|
],
|
|
)),
|
|
_toggle('Occasion', _setOccasion, (v) => _setOccasion = v,
|
|
_occasionField(_occasion, (v) => setState(() => _occasion = v))),
|
|
_toggle('Singers', _setSingers, (v) => _setSingers = v, Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SegmentedButton<bool>(
|
|
segments: const [
|
|
ButtonSegment(value: false, label: Text('Add to existing')),
|
|
ButtonSegment(value: true, label: Text('Replace all')),
|
|
],
|
|
selected: {_replaceSingers},
|
|
onSelectionChanged: (s) => setState(() => _replaceSingers = s.first),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SingerPicker(artists: artists, selected: _singers, onChanged: (v) => setState(() => _singers = v)),
|
|
],
|
|
)),
|
|
_toggle('Status', _setStatus, (v) => _setStatus = v,
|
|
_statusField(_status, (v) => setState(() => _status = v))),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: _saving ? null : () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: Text(_saving ? 'Saving…' : 'Apply to ${widget.ids.length} songs'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|