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>
228 lines
8.7 KiB
Dart
228 lines
8.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../api.dart';
|
|
import '../pickers.dart';
|
|
import '../theme.dart';
|
|
import '../widgets.dart';
|
|
|
|
const _tabs = {
|
|
'pending': 'Waiting',
|
|
'needs_info': 'Asked for more',
|
|
'approved': 'Approved',
|
|
'rejected': 'Rejected',
|
|
};
|
|
|
|
/// Singers and creators asking to be verified (the "I'm a singer" form in the app).
|
|
class ApplicationsScreen extends StatefulWidget {
|
|
const ApplicationsScreen({super.key});
|
|
|
|
@override
|
|
State<ApplicationsScreen> createState() => _ApplicationsScreenState();
|
|
}
|
|
|
|
class _ApplicationsScreenState extends State<ApplicationsScreen> {
|
|
String _status = 'pending';
|
|
late Future<List<CreatorApplication>> _data = AdminApi.applications(_status);
|
|
|
|
void _reload() => setState(() => _data = AdminApi.applications(_status));
|
|
|
|
Future<void> _approve(CreatorApplication app) async {
|
|
final artists = await AdminApi.artists();
|
|
if (!mounted) return;
|
|
var link = <String>[if (app.artistId != null) app.artistId!];
|
|
final note = TextEditingController();
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setLocal) => AlertDialog(
|
|
title: Text('Approve ${app.stageName}'),
|
|
content: DialogBody(children: [
|
|
const Text('If this singer is already in the catalog, choose them so their songs are linked to this '
|
|
'account. Otherwise a new verified singer is created.'),
|
|
SingerPicker(
|
|
artists: artists,
|
|
selected: link,
|
|
onChanged: (v) => setLocal(() => link = v.isEmpty ? v : [v.last]),
|
|
),
|
|
TextField(controller: note, decoration: const InputDecoration(labelText: 'Note (optional)')),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Approve')),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (ok == true) {
|
|
await _review(app, 'approved', trimmedOrNull(note.text), link.isEmpty ? null : link.single);
|
|
}
|
|
}
|
|
|
|
Future<void> _decline(CreatorApplication app, String decision) async {
|
|
final note = TextEditingController();
|
|
final ask = decision == 'needs_info';
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(ask ? 'Ask ${app.stageName} for more' : 'Reject ${app.stageName}'),
|
|
content: DialogBody(children: [
|
|
TextField(
|
|
controller: note,
|
|
autofocus: true,
|
|
maxLines: 3,
|
|
decoration: InputDecoration(labelText: ask ? 'What do you need from them?' : 'Reason'),
|
|
),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
FilledButton(
|
|
style: ask ? null : FilledButton.styleFrom(backgroundColor: NkColors.roofRed, foregroundColor: NkColors.cream),
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(ask ? 'Send back' : 'Reject'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) await _review(app, decision, trimmedOrNull(note.text), null);
|
|
}
|
|
|
|
Future<void> _review(CreatorApplication app, String decision, String? note, String? artistId) async {
|
|
try {
|
|
await AdminApi.reviewApplication(app.id, decision, note: note, artistId: artistId);
|
|
if (mounted) showMessage(context, '${app.stageName}: ${_tabs[decision]!.toLowerCase()}');
|
|
_reload();
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
}
|
|
}
|
|
|
|
Future<void> _openDocument(String path) async {
|
|
try {
|
|
await launchUrl(Uri.parse(await AdminApi.idDocumentUrl(path)));
|
|
} catch (e) {
|
|
if (mounted) showError(context, e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
PageHeader('Creator applications', subtitle: 'Verify singers and creators before they can upload', actions: [
|
|
SegmentedButton<String>(
|
|
segments: [for (final e in _tabs.entries) ButtonSegment(value: e.key, label: Text(e.value))],
|
|
selected: {_status},
|
|
onSelectionChanged: (s) {
|
|
_status = s.first;
|
|
_reload();
|
|
},
|
|
),
|
|
]),
|
|
Expanded(
|
|
child: Loader(
|
|
future: _data,
|
|
onRetry: _reload,
|
|
builder: (context, apps) => apps.isEmpty
|
|
? const EmptyState('No applications here.', icon: Icons.how_to_reg_outlined)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
|
itemCount: apps.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
|
itemBuilder: (context, i) => _card(apps[i]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _card(CreatorApplication a) {
|
|
final small = Theme.of(context).textTheme.bodySmall;
|
|
final open = a.status == 'pending' || a.status == 'needs_info';
|
|
Widget fact(String label, String? value, {String? link}) => value == null || value.isEmpty
|
|
? const SizedBox.shrink()
|
|
: Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(width: 120, child: Text(label, style: small)),
|
|
Expanded(
|
|
child: link == null
|
|
? SelectableText(value)
|
|
: InkWell(
|
|
onTap: () => launchUrl(Uri.parse(link)),
|
|
child: Text(value, style: const TextStyle(color: NkColors.sun, decoration: TextDecoration.underline)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
String? web(String? s) => s == null || s.isEmpty ? null : (s.startsWith('http') ? s : 'https://$s');
|
|
String? instagram(String? s) =>
|
|
s == null || s.isEmpty ? null : (s.startsWith('http') ? s : 'https://instagram.com/${s.replaceAll('@', '')}');
|
|
String? whatsapp(String? s) =>
|
|
s == null || s.isEmpty ? null : 'https://wa.me/${s.replaceAll(RegExp(r'[^0-9]'), '')}';
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(a.stageName, style: Theme.of(context).textTheme.titleLarge),
|
|
Text('Applied ${formatDate(a.createdAt)}', style: small),
|
|
],
|
|
),
|
|
),
|
|
if (a.idDocumentPath != null)
|
|
OutlinedButton.icon(
|
|
onPressed: () => _openDocument(a.idDocumentPath!),
|
|
icon: const Icon(Icons.badge_outlined, size: 18),
|
|
label: const Text('View ID'),
|
|
)
|
|
else
|
|
const Tag('No ID uploaded', color: NkColors.roofRed),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
fact('Real name', a.realName),
|
|
fact('Account', [?a.displayName, ?a.email].join(' · ')),
|
|
fact('Phone', a.phone),
|
|
fact('Village / hatti', a.hattiName),
|
|
fact('Instagram', a.instagram, link: instagram(a.instagram)),
|
|
fact('WhatsApp', a.whatsapp, link: whatsapp(a.whatsapp)),
|
|
for (final l in a.workLinks) fact('Work', l, link: web(l)),
|
|
fact('Their songs', a.claimedSongs),
|
|
fact('Message', a.message),
|
|
fact('Review note', a.reviewNote),
|
|
if (open) ...[
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: () => _approve(a),
|
|
icon: const Icon(Icons.verified_outlined, size: 18),
|
|
label: const Text('Approve'),
|
|
),
|
|
if (a.status == 'pending')
|
|
OutlinedButton(onPressed: () => _decline(a, 'needs_info'), child: const Text('Ask for more')),
|
|
OutlinedButton(onPressed: () => _decline(a, 'rejected'), child: const Text('Reject')),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|