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>
This commit is contained in:
metatroncubeswdev 2026-10-30 11:35:44 -04:00
parent d368880601
commit cf645071aa
35 changed files with 4332 additions and 9 deletions

48
admin/.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/

30
admin/.metadata Normal file
View File

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
- platform: web
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

64
admin/README.md Normal file
View File

@ -0,0 +1,64 @@
# NK Admin
The website at **https://admin.nkbmusic.com** for managing the Nanga Kathai catalog. Only admin accounts can sign in.
## Signing in
1. Open https://admin.nkbmusic.com and enter your email.
2. Type the 6-digit code from the email ("Your Nanga Kathai sign-in code").
3. Only accounts with the admin role get in. Anyone else is signed straight out.
**To make someone an admin**, they first sign up in the app. Then open Studio (https://app.nkbmusic.com) → SQL editor and run:
```sql
update profiles set role = 'admin'
where id = (select id from auth.users where email = 'person@example.com');
```
## Pages
| Page | What you do there |
|------|-------------------|
| **Overview** | Counts, plus a **Needs attention** list (songs to review, creator applications, songs without a singer or album, empty albums). Click a line to open the matching list. |
| **Songs** | Search by title, singer or album and filter with the chips (Waiting for review, Live, Hidden, No singer, No album…). Use ▶ to listen. Click a row to edit its title, Tamil title, meaning, year, album, occasion, singers and status. |
| **Albums** | Rename albums or add new ones. Click an album to see its songs. Use **⋮ → Merge** for duplicates and **Delete** for empty albums. |
| **Singers** | Edit names, village/hatti and bio, and mark singers *Verified*. Click a singer to see their songs. Use **⋮ → Merge** when someone was entered twice. |
| **Creators** | Applications from people who say they are singers or creators. Check their links and **View ID**, then **Approve**, **Ask for more** or **Reject**. |
### Publishing songs
- A song only appears in the app once its status is **Live**. Use **✓** on the row or **Approve** in the bulk bar.
- **Hide** (the crossed-out eye) takes a song out of the app at once and keeps it in the catalog.
- A song without an audio file can't be approved.
### Editing many songs at once
1. Tick the songs. The header checkbox selects the whole page.
2. In the bar that appears, use **Approve**, **Hide** or **Edit…**.
3. In **Edit…**, tick only the fields to change: album, occasion, singers or status.
- **Singers → Add to existing** keeps the current singers and adds yours.
- **Singers → Replace all** removes the current singers and sets yours.
In the album and singer boxes, type to search. If the name isn't there, pick **New album "…"** or **New singer "…"** to create it on the spot. Drag singers to change their order (the first is the lead).
### Approving a creator
When you approve someone, choose their existing singer profile if they are already in the catalog. Their songs are then linked to their account. If you choose nobody, a new verified singer is created. Approval also gives their account the *artist* role. The ID document is private: **View ID** opens a link that works for 10 minutes.
## Publishing a new version (developers)
Run from your own computer in the repo root (needs Flutter, ssh and tar; Git Bash on Windows):
```sh
sh admin/deploy-admin.sh
```
It builds the site with the app's `env.json`, uploads it into `~/APPS/nk-backend/supabase-project/nk/admin` on the server and reloads the `nk-admin` container (127.0.0.1:18080). HestiaCP proxies admin.nkbmusic.com to that container. The script warns if the database migration is missing.
Database changes (`supabase/migrations/*_admin.sql`) go out the usual way: push to git, then on the server run
```sh
sh ~/APPS/nanga_kathai/server/update.sh
```
To run it locally: `cd admin && flutter run -d chrome --dart-define-from-file=../env.json`.

View File

@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- web/**

58
admin/deploy-admin.sh Normal file
View File

@ -0,0 +1,58 @@
#!/bin/sh
# Builds NK Admin and publishes it to the nk-admin container (admin.nkbmusic.com).
# Run on your own computer (needs Flutter, ssh and tar; Git Bash works on Windows):
#
# sh admin/deploy-admin.sh
#
# Optional environment:
# NK_SSH ssh target (default dev@72.62.169.43)
# NK_PROJECT Supabase project dir (default APPS/nk-backend/supabase-project, under the ssh home)
# NK_ENV env.json to build with (default ../env.json, the app's file)
# NK_SKIP_BUILD=1 publish the existing build/web without building again
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
SSH_TARGET="${NK_SSH:-dev@72.62.169.43}"
PROJECT="${NK_PROJECT:-APPS/nk-backend/supabase-project}"
ENV_FILE="${NK_ENV:-$HERE/../env.json}"
say() { printf '\n==> %s\n' "$*"; }
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
cd "$HERE"
if [ "${NK_SKIP_BUILD:-}" != 1 ]; then
[ -f "$ENV_FILE" ] || die "missing $ENV_FILE (copy env.example.json and fill it in)"
say "Building NK Admin"
flutter pub get >/dev/null
flutter build web --release --dart-define-from-file="$ENV_FILE"
fi
[ -f build/web/index.html ] || die "no build found in admin/build/web"
say "Uploading to $SSH_TARGET:$PROJECT/nk/admin"
# The folder is bind-mounted into the container, so its contents are replaced in place
# (unpacked next to it first, then swapped in) rather than the folder itself.
tar -C build/web -czf - . | ssh "$SSH_TARGET" "set -e
cd '$PROJECT'
rm -rf nk/admin.new && mkdir -p nk/admin.new nk/admin nk/nginx
tar -xzf - -C nk/admin.new
find nk/admin -mindepth 1 -delete
cp -a nk/admin.new/. nk/admin/
rm -rf nk/admin.new"
say "Updating the web server config"
ssh "$SSH_TARGET" "cat > '$PROJECT/nk/nginx/admin.conf'" < "$HERE/../server/nginx/admin.conf"
ssh "$SSH_TARGET" "set -e
cd '$PROJECT'
docker compose up -d nk-admin >/dev/null
docker compose exec -T nk-admin nginx -s reload >/dev/null 2>&1 || docker compose restart nk-admin >/dev/null
if curl -fsS http://127.0.0.1:\${NK_ADMIN_PORT:-18080}/ | grep -q flutter_bootstrap; then
echo 'NK Admin is being served on 127.0.0.1:18080.'
else
echo 'Warning: the admin container did not return the new page (docker compose logs nk-admin).'
fi
PGPASSWORD=\$(grep -E '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)
applied=\$(docker compose exec -T -e PGPASSWORD=\"\$PGPASSWORD\" db psql -h localhost -U postgres -d postgres -tA \
-c \"select 1 from public.nk_schema_migrations where name = '20261031000000_admin.sql'\" 2>/dev/null || true)
[ \"\$applied\" = 1 ] || echo 'Warning: the admin database migration is not applied yet. Push to git, then run: sh ~/APPS/nanga_kathai/server/update.sh'"
say "Done: https://admin.nkbmusic.com"

383
admin/lib/api.dart Normal file
View File

@ -0,0 +1,383 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import 'env.dart';
SupabaseClient get _db => Supabase.instance.client;
const occasions = {
'huttu': 'Huttu · birth',
'habba': 'Habba · festival',
'madhuvae': 'Madhuvae · wedding',
'devotional': 'Devotional',
'folk': 'Folk',
'saavu': 'Saavu · remembrance',
'instrumental': 'Instrumental',
};
/// Song status as stored in tracks.status, with the words admins see.
enum SongStatus {
pending('pending', 'Waiting for review'),
approved('approved', 'Live'),
needsChanges('needs_changes', 'Needs changes'),
draft('draft', 'Draft'),
rejected('rejected', 'Rejected'),
takenDown('taken_down', 'Hidden');
const SongStatus(this.id, this.label);
final String id;
final String label;
static SongStatus fromId(String? id) =>
values.firstWhere((s) => s.id == id, orElse: () => SongStatus.pending);
}
/// Filters on the Songs page; each maps to a query (and to a ?show= value in the URL).
enum SongFilter {
all('all', 'All'),
pending('pending', 'Waiting for review'),
live('live', 'Live'),
hidden('hidden', 'Hidden'),
needsChanges('needs_changes', 'Needs changes'),
noSinger('no_singer', 'No singer'),
noAlbum('no_album', 'No album');
const SongFilter(this.id, this.label);
final String id;
final String label;
static SongFilter fromId(String? id) => values.firstWhere((f) => f.id == id, orElse: () => SongFilter.all);
}
class AdminTrack {
AdminTrack.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
title = r['title_latin'] as String,
titleTamil = r['title_tamil'] as String?,
meaning = r['title_en_meaning'] as String?,
albumId = r['album_id'] as String?,
albumTitle = r['album_title'] as String?,
occasion = r['occasion'] as String? ?? 'folk',
year = (r['year'] as num?)?.toInt(),
duration = Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
audioKey = r['audio_key_128'] as String?,
status = SongStatus.fromId(r['status'] as String?),
sourceNote = r['source_note'] as String?,
reviewNote = r['review_note'] as String?,
createdAt = DateTime.parse(r['created_at'] as String),
singerIds = [for (final id in (r['singer_ids'] as List? ?? const [])) id as String],
singerNames = r['singer_names'] as String? ?? '';
final String id;
final String title;
final String? titleTamil;
final String? meaning;
final String? albumId;
final String? albumTitle;
final String occasion;
final int? year;
final Duration duration;
final String? audioKey;
final SongStatus status;
final String? sourceNote;
final String? reviewNote;
final DateTime createdAt;
final List<String> singerIds;
final String singerNames;
String? get audioUrl => audioKey == null ? null : '${Env.mediaBaseUrl}/$audioKey';
}
class AdminAlbum {
AdminAlbum.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
title = r['title_latin'] as String,
titleTamil = r['title_tamil'] as String?,
year = (r['year'] as num?)?.toInt(),
coverUrl = r['cover_url'] as String?,
trackCount = (r['track_count'] as num?)?.toInt() ?? 0,
approvedCount = (r['approved_count'] as num?)?.toInt() ?? 0;
final String id;
final String title;
final String? titleTamil;
final int? year;
final String? coverUrl;
final int trackCount;
final int approvedCount;
}
class AdminArtist {
AdminArtist.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
name = r['name_latin'] as String,
nameTamil = r['name_tamil'] as String?,
hattiName = r['hatti_name'] as String?,
bio = r['bio'] as String?,
photoUrl = r['photo_url'] as String?,
verified = r['verified'] as bool? ?? false,
claimed = r['claimed_by'] != null,
trackCount = (r['track_count'] as num?)?.toInt() ?? 0;
final String id;
final String name;
final String? nameTamil;
final String? hattiName;
final String? bio;
final String? photoUrl;
final bool verified;
final bool claimed;
final int trackCount;
}
class CreatorApplication {
CreatorApplication.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
email = r['email'] as String?,
displayName = r['display_name'] as String?,
phone = r['phone'] as String?,
stageName = r['stage_name'] as String,
realName = r['real_name'] as String,
hattiName = r['hatti_name'] as String?,
instagram = r['instagram'] as String?,
whatsapp = r['whatsapp'] as String?,
workLinks = [for (final l in (r['work_links'] as List? ?? const [])) l as String],
idDocumentPath = r['id_document_path'] as String?,
claimedSongs = r['claimed_songs'] as String?,
message = r['message'] as String?,
status = r['status'] as String,
artistId = r['artist_id'] as String?,
reviewNote = r['review_note'] as String?,
createdAt = DateTime.parse(r['created_at'] as String);
final String id;
final String? email;
final String? displayName;
final String? phone;
final String stageName;
final String realName;
final String? hattiName;
final String? instagram;
final String? whatsapp;
final List<String> workLinks;
final String? idDocumentPath;
final String? claimedSongs;
final String? message;
final String status;
final String? artistId;
final String? reviewNote;
final DateTime createdAt;
}
class ResultPage<T> {
const ResultPage(this.items, this.total);
final List<T> items;
final int total;
}
/// Changes for many songs at once. Null fields are left as they are.
class SongChanges {
SongChanges({this.albumId, this.clearAlbum = false, this.occasion, this.status, this.singerIds,
this.replaceSingers = true});
final String? albumId;
final bool clearAlbum;
final String? occasion;
final SongStatus? status;
final List<String>? singerIds;
final bool replaceSingers;
}
String _like(String word) => '%${word.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_')}%';
abstract final class AdminApi {
// ------------------------------------------------------------ session
static Future<void> sendCode(String email) => _db.auth.signInWithOtp(email: email, shouldCreateUser: false);
static Future<void> verifyCode(String email, String code) =>
_db.auth.verifyOTP(type: OtpType.email, email: email, token: code);
static Future<bool> isAdmin() async => await _db.rpc('is_admin') == true;
static Future<void> signOut() => _db.auth.signOut();
// ------------------------------------------------------------ overview
static Future<Map<String, int>> overview() async {
final json = await _db.rpc('admin_overview') as Map<String, dynamic>;
return json.map((k, v) => MapEntry(k, (v as num).toInt()));
}
static Future<List<AdminTrack>> recentPending({int limit = 8}) async {
final rows = await _db
.from('admin_tracks')
.select()
.eq('status', 'pending')
.order('created_at', ascending: false)
.limit(limit);
return rows.map(AdminTrack.fromRow).toList();
}
// ------------------------------------------------------------ songs
static Future<ResultPage<AdminTrack>> tracks({
String query = '',
SongFilter filter = SongFilter.all,
String? albumId,
String? artistId,
bool newestFirst = true,
int page = 0,
int pageSize = 50,
}) async {
var q = _db.from('admin_tracks').select();
for (final word in query.toLowerCase().split(RegExp(r'\s+')).where((w) => w.isNotEmpty)) {
q = q.ilike('search_text', _like(word));
}
q = switch (filter) {
SongFilter.all => q,
SongFilter.pending => q.eq('status', 'pending'),
SongFilter.live => q.eq('status', 'approved'),
SongFilter.hidden => q.inFilter('status', ['taken_down', 'rejected']),
SongFilter.needsChanges => q.eq('status', 'needs_changes'),
SongFilter.noSinger => q.eq('singer_names', ''),
SongFilter.noAlbum => q.isFilter('album_id', null),
};
if (albumId != null) q = q.eq('album_id', albumId);
if (artistId != null) q = q.contains('singer_ids', [artistId]);
final ordered = newestFirst
? q.order('created_at', ascending: false).order('title_latin', ascending: true)
: q.order('title_latin', ascending: true);
final res = await ordered.range(page * pageSize, (page + 1) * pageSize - 1).count(CountOption.exact);
return ResultPage(res.data.map(AdminTrack.fromRow).toList(), res.count);
}
static Future<void> updateSongs(List<String> ids, SongChanges c) async {
if (ids.isEmpty) return;
final row = <String, dynamic>{
'occasion': ?c.occasion,
if (c.albumId != null || c.clearAlbum) 'album_id': c.albumId,
if (c.status != null) ..._reviewed(c.status!),
};
if (row.isNotEmpty) await _db.from('tracks').update(row).inFilter('id', ids);
if (c.singerIds != null) await _setSingers(ids, c.singerIds!, replace: c.replaceSingers);
}
/// Saves every field of one song as given (empty text clears a field).
static Future<void> saveSong(AdminTrack t,
{required String title,
String? titleTamil,
String? meaning,
int? year,
String? albumId,
required String occasion,
required SongStatus status,
required List<String> singerIds}) async {
await _db.from('tracks').update({
'title_latin': title,
'title_tamil': titleTamil,
'title_en_meaning': meaning,
'year': year,
'album_id': albumId,
'occasion': occasion,
if (status != t.status) ..._reviewed(status),
}).eq('id', t.id);
if (singerIds.join(',') != t.singerIds.join(',')) await _setSingers([t.id], singerIds, replace: true);
}
static Map<String, dynamic> _reviewed(SongStatus s) => {
'status': s.id,
'reviewed_by': _db.auth.currentUser?.id,
'reviewed_at': DateTime.now().toUtc().toIso8601String(),
};
static Future<void> _setSingers(List<String> trackIds, List<String> artistIds, {required bool replace}) =>
_db.rpc('admin_set_singers', params: {'p_track_ids': trackIds, 'p_artist_ids': artistIds, 'p_replace': replace});
static Future<void> setStatus(List<String> ids, SongStatus status) => updateSongs(ids, SongChanges(status: status));
// ------------------------------------------------------------ albums
static Future<List<AdminAlbum>> albums({String query = ''}) async {
var q = _db.from('admin_albums').select();
if (query.trim().isNotEmpty) q = q.ilike('title_latin', _like(query.trim()));
final rows = await q.order('title_latin');
return rows.map(AdminAlbum.fromRow).toList();
}
static Future<String> createAlbum({required String title, String? titleTamil, int? year}) async {
final row = await _db
.from('albums')
.insert({'title_latin': title, 'title_tamil': titleTamil, 'year': year})
.select('id')
.single();
return row['id'] as String;
}
static Future<void> updateAlbum(String id, {required String title, String? titleTamil, int? year}) =>
_db.from('albums').update({'title_latin': title, 'title_tamil': titleTamil, 'year': year}).eq('id', id);
static Future<void> deleteAlbum(String id) async {
await _db.from('tracks').update({'album_id': null}).eq('album_id', id);
await _db.from('albums').delete().eq('id', id);
}
static Future<void> mergeAlbums(String from, String into) =>
_db.rpc('admin_merge_albums', params: {'p_from': from, 'p_into': into});
// ------------------------------------------------------------ singers
static Future<List<AdminArtist>> artists({String query = ''}) async {
var q = _db.from('admin_artists').select();
if (query.trim().isNotEmpty) q = q.ilike('name_latin', _like(query.trim()));
final rows = await q.order('name_latin');
return rows.map(AdminArtist.fromRow).toList();
}
static Future<String> createArtist({required String name, String? nameTamil, String? hatti, String? bio,
bool verified = false}) async {
final row = await _db
.from('artists')
.insert({
'name_latin': name,
'name_tamil': nameTamil,
'hatti_id': await _hattiId(hatti),
'bio': bio,
'verified': verified,
})
.select('id')
.single();
return row['id'] as String;
}
static Future<void> updateArtist(String id,
{required String name, String? nameTamil, String? hatti, String? bio, required bool verified}) async =>
_db.from('artists').update({
'name_latin': name,
'name_tamil': nameTamil,
'hatti_id': await _hattiId(hatti),
'bio': bio,
'verified': verified,
}).eq('id', id);
static Future<void> deleteArtist(String id) => _db.from('artists').delete().eq('id', id);
static Future<void> mergeArtists(String from, String into) =>
_db.rpc('admin_merge_artists', params: {'p_from': from, 'p_into': into});
static Future<String?> _hattiId(String? name) async =>
await _db.rpc('admin_hatti_id', params: {'p_name': name ?? ''}) as String?;
// ------------------------------------------------------------ creator applications
static Future<List<CreatorApplication>> applications(String status) async {
final rows = await _db
.from('admin_applications')
.select()
.eq('status', status)
.order('created_at', ascending: status != 'pending');
return rows.map(CreatorApplication.fromRow).toList();
}
static Future<void> reviewApplication(String id, String decision, {String? note, String? artistId}) =>
_db.rpc('admin_review_application',
params: {'p_id': id, 'p_decision': decision, 'p_note': note, 'p_artist_id': artistId});
/// A link to the applicant's ID document that works for 10 minutes.
static Future<String> idDocumentUrl(String path) =>
_db.storage.from('creator-documents').createSignedUrl(path, 600);
}

10
admin/lib/env.dart Normal file
View File

@ -0,0 +1,10 @@
/// Build-time configuration: `--dart-define-from-file=../env.json` (same file as the app).
abstract final class Env {
static const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
static const supabasePublishableKey = String.fromEnvironment('SUPABASE_PUBLISHABLE_KEY');
/// Public base URL of the audio bucket, e.g. https://app.nkbmusic.com/storage/v1/object/public/audio
static const mediaBaseUrl = String.fromEnvironment('MEDIA_BASE_URL');
static bool get isConfigured => supabaseUrl.isNotEmpty && supabasePublishableKey.isNotEmpty;
}

44
admin/lib/main.dart Normal file
View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'env.dart';
import 'router.dart';
import 'session.dart';
import 'theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy(); // admin.nkbmusic.com/songs, not /#/songs (nginx serves index.html for any path)
if (!Env.isConfigured) {
runApp(const _MissingConfig());
return;
}
await Supabase.initialize(url: Env.supabaseUrl, publishableKey: Env.supabasePublishableKey);
AdminSession.instance.start();
runApp(const NkAdminApp());
}
class NkAdminApp extends StatelessWidget {
const NkAdminApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp.router(
title: 'NK Admin',
debugShowCheckedModeBanner: false,
theme: buildNkTheme(),
routerConfig: adminRouter,
);
}
class _MissingConfig extends StatelessWidget {
const _MissingConfig();
@override
Widget build(BuildContext context) => MaterialApp(
theme: buildNkTheme(),
home: const Scaffold(
body: Center(child: Text('Built without env.json: run deploy-admin.sh (see admin/README.md).')),
),
);
}

206
admin/lib/pickers.dart Normal file
View File

@ -0,0 +1,206 @@
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),
),
],
),
),
),
),
),
);
}

137
admin/lib/player.dart Normal file
View File

@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'api.dart';
import 'theme.dart';
import 'widgets.dart';
/// One player for the whole dashboard, so a song keeps playing while you edit or move pages.
class AdminPlayer {
AdminPlayer._();
static final instance = AdminPlayer._();
final audio = AudioPlayer();
final current = ValueNotifier<AdminTrack?>(null);
Future<void> toggle(AdminTrack track) async {
if (current.value?.id == track.id) {
audio.playing ? await audio.pause() : await audio.play();
return;
}
final url = track.audioUrl;
if (url == null) return;
current.value = track;
await audio.setUrl(url);
await audio.play();
}
Future<void> close() async {
await audio.stop();
current.value = null;
}
}
/// Play/pause button for a song row.
class PlayButton extends StatelessWidget {
const PlayButton(this.track, {super.key});
final AdminTrack track;
@override
Widget build(BuildContext context) {
final player = AdminPlayer.instance;
return ValueListenableBuilder(
valueListenable: player.current,
builder: (context, current, _) => StreamBuilder<PlayerState>(
stream: player.audio.playerStateStream,
builder: (context, snap) {
final isThis = current?.id == track.id;
final playing = isThis && (snap.data?.playing ?? false);
final loading = isThis &&
const [ProcessingState.loading, ProcessingState.buffering].contains(snap.data?.processingState);
return IconButton(
tooltip: track.audioKey == null ? 'No audio file' : (playing ? 'Pause' : 'Play'),
onPressed: track.audioKey == null ? null : () => player.toggle(track),
icon: loading && playing
? const SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(playing ? Icons.pause_circle_filled : Icons.play_circle_fill,
color: isThis ? NkColors.sun : NkColors.cream, size: 30),
);
},
),
);
}
}
/// Bar at the bottom of the dashboard while a song is loaded.
class PlayerBar extends StatelessWidget {
const PlayerBar({super.key});
@override
Widget build(BuildContext context) {
final player = AdminPlayer.instance;
return ValueListenableBuilder(
valueListenable: player.current,
builder: (context, track, _) {
if (track == null) return const SizedBox.shrink();
return Material(
color: NkColors.raised,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
PlayButton(track),
const SizedBox(width: 8),
SizedBox(
width: 240,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(track.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(creditLine(track), maxLines: 1, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall),
],
),
),
Expanded(child: _Progress(player.audio)),
IconButton(tooltip: 'Close player', onPressed: player.close, icon: const Icon(Icons.close)),
],
),
),
);
},
);
}
}
class _Progress extends StatelessWidget {
const _Progress(this.audio);
final AudioPlayer audio;
@override
Widget build(BuildContext context) => StreamBuilder<Duration>(
stream: audio.positionStream,
builder: (context, snap) {
final total = audio.duration ?? Duration.zero;
final pos = snap.data ?? Duration.zero;
final max = total.inMilliseconds.toDouble();
return Row(
children: [
Text(formatDuration(pos), style: Theme.of(context).textTheme.bodySmall),
Expanded(
child: Slider(
value: max == 0 ? 0 : pos.inMilliseconds.clamp(0, max).toDouble(),
max: max == 0 ? 1 : max,
activeColor: NkColors.sun,
onChanged: max == 0 ? null : (v) => audio.seek(Duration(milliseconds: v.round())),
),
),
Text(formatDuration(total), style: Theme.of(context).textTheme.bodySmall),
],
);
},
);
}

54
admin/lib/router.dart Normal file
View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'screens/albums_screen.dart';
import 'screens/applications_screen.dart';
import 'screens/overview_screen.dart';
import 'screens/sign_in_screen.dart';
import 'screens/singers_screen.dart';
import 'screens/songs_screen.dart';
import 'session.dart';
import 'shell.dart';
final adminRouter = GoRouter(
refreshListenable: AdminSession.instance,
redirect: (context, state) {
final session = AdminSession.instance.state;
final atSignIn = state.matchedLocation == '/sign-in';
if (session == SessionState.checking) return null;
if (session == SessionState.signedOut) {
return atSignIn ? null : '/sign-in?from=${Uri.encodeComponent(state.uri.toString())}';
}
if (atSignIn) return state.uri.queryParameters['from'] ?? '/';
return null;
},
routes: [
GoRoute(path: '/sign-in', builder: (context, state) => const SignInScreen()),
ShellRoute(
builder: (context, state, child) => AdminShell(location: state.matchedLocation, child: child),
routes: [
GoRoute(path: '/', pageBuilder: (context, state) => _page(const OverviewScreen())),
GoRoute(
path: '/songs',
pageBuilder: (context, state) {
final p = state.uri.queryParameters;
return _page(SongsScreen(
key: ValueKey(state.uri.toString()),
filter: SongFilter.fromId(p['show']),
query: p['q'] ?? '',
albumId: p['album'],
artistId: p['singer'],
filterName: p['name'],
));
},
),
GoRoute(path: '/albums', pageBuilder: (context, state) => _page(const AlbumsScreen())),
GoRoute(path: '/singers', pageBuilder: (context, state) => _page(const SingersScreen())),
GoRoute(path: '/applications', pageBuilder: (context, state) => _page(const ApplicationsScreen())),
],
),
],
);
Page<void> _page(Widget child) => NoTransitionPage(child: child);

View File

@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
class AlbumsScreen extends StatefulWidget {
const AlbumsScreen({super.key});
@override
State<AlbumsScreen> createState() => _AlbumsScreenState();
}
class _AlbumsScreenState extends State<AlbumsScreen> {
String _query = '';
late Future<List<AdminAlbum>> _data = AdminApi.albums();
void _reload() => setState(() => _data = AdminApi.albums(query: _query));
Future<void> _run(Future<void> job, String done) async {
try {
await job;
if (mounted) showMessage(context, done);
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _edit([AdminAlbum? album]) async {
final title = TextEditingController(text: album?.title ?? '');
final tamil = TextEditingController(text: album?.titleTamil ?? '');
final year = TextEditingController(text: album?.year?.toString() ?? '');
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(album == null ? 'New album' : 'Edit album'),
content: DialogBody(children: [
TextField(controller: title, autofocus: true, decoration: const InputDecoration(labelText: 'Title (English letters)')),
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Title in Tamil script')),
TextField(controller: year, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Year')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Save')),
],
),
);
if (ok != true || title.text.trim().isEmpty) return;
final fields = (title.text.trim(), trimmedOrNull(tamil.text), int.tryParse(year.text.trim()));
if (album == null) {
await _run(AdminApi.createAlbum(title: fields.$1, titleTamil: fields.$2, year: fields.$3), 'Album added');
} else {
await _run(AdminApi.updateAlbum(album.id, title: fields.$1, titleTamil: fields.$2, year: fields.$3),
'Album saved');
}
}
Future<void> _merge(AdminAlbum from, List<AdminAlbum> all) async {
String? into;
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text('Merge "${from.title}"'),
content: DialogBody(children: [
Text('Its ${from.trackCount} songs move into the album you choose, then "${from.title}" is deleted. '
'Use this for duplicates.'),
AlbumPicker(
albums: [for (final a in all) if (a.id != from.id) a],
albumId: into,
onChanged: (v) => setLocal(() => into = v),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: into == null ? null : () => Navigator.pop(context, true), child: const Text('Merge')),
],
),
),
);
if (ok == true && into != null) await _run(AdminApi.mergeAlbums(from.id, into!), 'Albums merged');
}
Future<void> _delete(AdminAlbum a) async {
final body = a.trackCount == 0
? 'This album has no songs.'
: 'Its ${a.trackCount} songs stay in the catalog without an album.';
if (await confirm(context, 'Delete "${a.title}"?', body, action: 'Delete', danger: true)) {
await _run(AdminApi.deleteAlbum(a.id), 'Album deleted');
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader('Albums', subtitle: 'Rename, merge duplicates and open their songs', actions: [
SearchField(
hint: 'Search albums',
onChanged: (q) {
_query = q;
_reload();
},
),
FilledButton.icon(onPressed: _edit, icon: const Icon(Icons.add), label: const Text('New album')),
]),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, albums) => albums.isEmpty
? const EmptyState('No albums found.', icon: Icons.album_outlined)
: ListView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
children: [
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, a) in albums.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: const Icon(Icons.album, color: NkColors.muted),
title: Text(a.title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text([
?a.titleTamil,
if (a.year != null) '${a.year}',
'${a.trackCount} songs, ${a.approvedCount} live',
].join(' · ')),
onTap: () => context.go(Uri(path: '/songs',
queryParameters: {'album': a.id, 'name': a.title}).toString()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (a.trackCount == 0) const Tag('Empty'),
IconButton(tooltip: 'Edit', onPressed: () => _edit(a), icon: const Icon(Icons.edit_outlined)),
PopupMenuButton<String>(
tooltip: 'More',
onSelected: (v) => v == 'merge' ? _merge(a, albums) : _delete(a),
itemBuilder: (context) => const [
PopupMenuItem(value: 'merge', child: Text('Merge into another album…')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
],
],
),
),
],
),
),
),
],
);
}

View File

@ -0,0 +1,227 @@
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')),
],
),
],
],
),
),
);
}
}

View File

@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../player.dart';
import '../theme.dart';
import '../widgets.dart';
class OverviewScreen extends StatefulWidget {
const OverviewScreen({super.key});
@override
State<OverviewScreen> createState() => _OverviewScreenState();
}
class _OverviewScreenState extends State<OverviewScreen> {
late Future<(Map<String, int>, List<AdminTrack>)> _data = _load();
Future<(Map<String, int>, List<AdminTrack>)> _load() async =>
(await AdminApi.overview(), await AdminApi.recentPending());
void _reload() => setState(() => _data = _load());
@override
Widget build(BuildContext context) => Loader(
future: _data,
onRetry: _reload,
builder: (context, data) {
final (n, pending) = data;
final attention = [
if (n['pending']! > 0)
_Todo(Icons.hourglass_top, NkColors.sun, '${n['pending']} songs waiting for review',
'Listen, fix the details and approve them.', '/songs?show=pending'),
if (n['applications']! > 0)
_Todo(Icons.how_to_reg, NkColors.sun, '${n['applications']} creator applications',
'Check their links and ID, then approve or ask for more.', '/applications'),
if (n['takedowns']! > 0)
_Todo(Icons.report_outlined, NkColors.roofRed, '${n['takedowns']} open takedown requests',
'Handle these in Studio (takedown_requests) for now.', null),
if (n['no_singer']! > 0)
_Todo(Icons.mic_off_outlined, NkColors.info, '${n['no_singer']} songs without a singer',
'Credit the singers so listeners can find them.', '/songs?show=no_singer'),
if (n['no_album']! > 0)
_Todo(Icons.album_outlined, NkColors.info, '${n['no_album']} songs without an album',
'Group them into albums.', '/songs?show=no_album'),
if (n['needs_changes']! > 0)
_Todo(Icons.edit_note, NkColors.info, '${n['needs_changes']} songs marked "needs changes"',
'Follow up once they are fixed.', '/songs?show=needs_changes'),
if (n['empty_albums']! > 0)
_Todo(Icons.folder_off_outlined, NkColors.muted, '${n['empty_albums']} empty albums',
'Delete them or move songs in.', '/albums'),
];
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
PageHeader('Overview', subtitle: 'The Nanga Kathai catalog at a glance', actions: [
IconButton(tooltip: 'Refresh', onPressed: _reload, icon: const Icon(Icons.refresh)),
]),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
_Stat('Live songs', n['approved']!, '/songs?show=live'),
_Stat('Waiting for review', n['pending']!, '/songs?show=pending', highlight: n['pending']! > 0),
_Stat('Hidden', n['hidden']!, '/songs?show=hidden'),
_Stat('All songs', n['songs']!, '/songs'),
_Stat('Albums', n['albums']!, '/albums'),
_Stat('Singers', n['singers']!, '/singers'),
_Stat('Listener accounts', n['listeners']!, null),
],
),
),
const SizedBox(height: 28),
_sectionTitle(context, 'Needs attention'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: attention.isEmpty
? const EmptyState('All clear. Nothing needs attention right now.',
icon: Icons.check_circle_outline)
: Column(
children: [
for (final (i, t) in attention.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: Icon(t.icon, color: t.color),
title: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text(t.hint),
trailing: t.path == null ? null : const Icon(Icons.chevron_right),
onTap: t.path == null ? null : () => context.go(t.path!),
),
],
],
),
),
),
if (pending.isNotEmpty) ...[
const SizedBox(height: 28),
_sectionTitle(context, 'Latest waiting for review'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: Column(
children: [
for (final t in pending)
ListTile(
leading: PlayButton(t),
title: Text(t.title),
subtitle: Text('${creditLine(t)} · ${t.albumTitle ?? 'No album'}'),
trailing: Text(formatDate(t.createdAt), style: Theme.of(context).textTheme.bodySmall),
),
const Divider(),
TextButton(
onPressed: () => context.go('/songs?show=pending'),
child: const Text('Review all'),
),
],
),
),
),
],
],
),
);
},
);
Widget _sectionTitle(BuildContext context, String text) => Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 10),
child: Text(text, style: Theme.of(context).textTheme.titleLarge),
);
}
class _Todo {
const _Todo(this.icon, this.color, this.title, this.hint, this.path);
final IconData icon;
final Color color;
final String title;
final String hint;
final String? path;
}
class _Stat extends StatelessWidget {
const _Stat(this.label, this.value, this.path, {this.highlight = false});
final String label;
final int value;
final String? path;
final bool highlight;
@override
Widget build(BuildContext context) => SizedBox(
width: 180,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: highlight ? NkColors.sun : NkColors.outline),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: path == null ? null : () => context.go(path!),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$value',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: highlight ? NkColors.sun : NkColors.cream)),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
),
),
),
);
}

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../session.dart';
import '../theme.dart';
import '../widgets.dart';
/// Email-code sign-in. Codes are only sent to existing accounts, and only admins get in.
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key});
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
final _email = TextEditingController();
final _code = TextEditingController();
bool _codeSent = false;
bool _busy = false;
String? _error;
@override
void dispose() {
_email.dispose();
_code.dispose();
super.dispose();
}
String get _emailText => _email.text.trim().toLowerCase();
Future<void> _run(Future<void> Function() job) async {
setState(() {
_busy = true;
_error = null;
});
try {
await job();
} catch (e) {
_error = errorText(e);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _sendCode() {
if (!_emailText.contains('@')) {
setState(() => _error = 'Enter your admin email address.');
return;
}
_run(() async {
await AdminApi.sendCode(_emailText);
_codeSent = true;
_code.clear();
});
}
void _verify() {
if (_code.text.trim().length < 6) {
setState(() => _error = 'Enter the 6-digit code from the email.');
return;
}
_run(() => AdminApi.verifyCode(_emailText, _code.text.trim()));
}
@override
Widget build(BuildContext context) {
final session = AdminSession.instance;
return Scaffold(
body: ListenableBuilder(
listenable: session,
builder: (context, _) {
if (session.state == SessionState.checking) return const Center(child: CircularProgressIndicator());
final message = _error ?? session.refusal;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: const BoxDecoration(color: NkColors.sun, shape: BoxShape.circle),
child: const Text('NK',
style: TextStyle(color: NkColors.ground, fontWeight: FontWeight.w900, fontSize: 20)),
),
const SizedBox(height: 20),
Text('Nanga Kathai Admin', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 6),
Text(
_codeSent
? 'We emailed a 6-digit code to $_emailText.'
: 'Sign in with your admin email. We\'ll send you a code.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 24),
if (!_codeSent)
TextField(
controller: _email,
autofocus: true,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: 'Email'),
onSubmitted: (_) => _sendCode(),
)
else
TextField(
controller: _code,
autofocus: true,
keyboardType: TextInputType.number,
autofillHints: const [AutofillHints.oneTimeCode],
maxLength: 6,
style: const TextStyle(fontSize: 22, letterSpacing: 8, fontWeight: FontWeight.w700),
decoration: const InputDecoration(labelText: 'Code', counterText: ''),
onSubmitted: (_) => _verify(),
),
if (message != null) ...[
const SizedBox(height: 12),
Text(message, style: const TextStyle(color: NkColors.roofRed)),
],
const SizedBox(height: 20),
FilledButton(
onPressed: _busy ? null : (_codeSent ? _verify : _sendCode),
child: _busy
? const SizedBox.square(
dimension: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(_codeSent ? 'Sign in' : 'Email me a code'),
),
if (_codeSent) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _busy ? null : () => setState(() => _codeSent = false),
child: const Text('Use another email'),
),
TextButton(onPressed: _busy ? null : _sendCode, child: const Text('Send again')),
],
),
],
],
),
),
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../pickers.dart';
import '../theme.dart';
import '../widgets.dart';
class SingersScreen extends StatefulWidget {
const SingersScreen({super.key});
@override
State<SingersScreen> createState() => _SingersScreenState();
}
class _SingersScreenState extends State<SingersScreen> {
String _query = '';
late Future<List<AdminArtist>> _data = AdminApi.artists();
void _reload() => setState(() => _data = AdminApi.artists(query: _query));
Future<void> _run(Future<void> job, String done) async {
try {
await job;
if (mounted) showMessage(context, done);
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _edit([AdminArtist? a]) async {
final name = TextEditingController(text: a?.name ?? '');
final tamil = TextEditingController(text: a?.nameTamil ?? '');
final hatti = TextEditingController(text: a?.hattiName ?? '');
final bio = TextEditingController(text: a?.bio ?? '');
var verified = a?.verified ?? false;
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text(a == null ? 'New singer' : 'Edit singer'),
content: DialogBody(children: [
TextField(controller: name, autofocus: true, decoration: const InputDecoration(labelText: 'Name (English letters)')),
TextField(controller: tamil, decoration: const InputDecoration(labelText: 'Name in Tamil script')),
TextField(controller: hatti, decoration: const InputDecoration(labelText: 'Village / hatti')),
TextField(controller: bio, maxLines: 4, decoration: const InputDecoration(labelText: 'About')),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Verified'),
subtitle: const Text('We have confirmed who this singer is.'),
value: verified,
onChanged: (v) => setLocal(() => verified = v),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Save')),
],
),
),
);
if (ok != true || name.text.trim().isEmpty) return;
if (a == null) {
await _run(
AdminApi.createArtist(
name: name.text.trim(),
nameTamil: trimmedOrNull(tamil.text),
hatti: hatti.text,
bio: trimmedOrNull(bio.text),
verified: verified),
'Singer added',
);
} else {
await _run(
AdminApi.updateArtist(a.id,
name: name.text.trim(),
nameTamil: trimmedOrNull(tamil.text),
hatti: hatti.text,
bio: trimmedOrNull(bio.text),
verified: verified),
'Singer saved',
);
}
}
Future<void> _merge(AdminArtist from, List<AdminArtist> all) async {
var into = <String>[];
final ok = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setLocal) => AlertDialog(
title: Text('Merge "${from.name}"'),
content: DialogBody(children: [
Text('Their ${from.trackCount} songs are credited to the singer you choose, then "${from.name}" is '
'deleted. Use this when the same person was entered twice.'),
SingerPicker(
artists: [for (final a in all) if (a.id != from.id) a],
selected: into,
onChanged: (v) => setLocal(() => into = v.isEmpty ? v : [v.last]),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: into.isEmpty ? null : () => Navigator.pop(context, true), child: const Text('Merge')),
],
),
),
);
if (ok == true && into.isNotEmpty) await _run(AdminApi.mergeArtists(from.id, into.single), 'Singers merged');
}
Future<void> _delete(AdminArtist a) async {
final body = a.trackCount == 0
? 'No songs credit this singer.'
: 'Their name is removed from ${a.trackCount} songs. The songs stay.';
if (await confirm(context, 'Delete "${a.name}"?', body, action: 'Delete', danger: true)) {
await _run(AdminApi.deleteArtist(a.id), 'Singer deleted');
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader('Singers', subtitle: 'Names, villages, verification and duplicates', actions: [
SearchField(
hint: 'Search singers',
onChanged: (q) {
_query = q;
_reload();
},
),
FilledButton.icon(onPressed: _edit, icon: const Icon(Icons.add), label: const Text('New singer')),
]),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, artists) => artists.isEmpty
? const EmptyState('No singers found.', icon: Icons.mic_off_outlined)
: ListView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
children: [
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, a) in artists.indexed) ...[
if (i > 0) const Divider(),
ListTile(
leading: CircleAvatar(
backgroundColor: NkColors.raised,
child: Text(a.name.characters.first.toUpperCase(),
style: const TextStyle(color: NkColors.sun, fontWeight: FontWeight.w800)),
),
title: Text(a.name, style: const TextStyle(fontWeight: FontWeight.w700)),
subtitle: Text([?a.nameTamil, ?a.hattiName, '${a.trackCount} songs'].join(' · ')),
onTap: () => context.go(Uri(path: '/songs',
queryParameters: {'singer': a.id, 'name': a.name}).toString()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (a.verified) const Tag('Verified', color: NkColors.teaLeaf),
if (a.claimed) ...[const SizedBox(width: 6), const Tag('Has account', color: NkColors.info)],
IconButton(tooltip: 'Edit', onPressed: () => _edit(a), icon: const Icon(Icons.edit_outlined)),
PopupMenuButton<String>(
tooltip: 'More',
onSelected: (v) => v == 'merge' ? _merge(a, artists) : _delete(a),
itemBuilder: (context) => const [
PopupMenuItem(value: 'merge', child: Text('Merge into another singer…')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
],
],
),
),
],
),
),
),
],
);
}

View File

@ -0,0 +1,314 @@
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'),
),
],
);
}
}

View File

@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../api.dart';
import '../player.dart';
import '../theme.dart';
import '../widgets.dart';
import 'song_editor.dart';
class SongsScreen extends StatefulWidget {
const SongsScreen({
super.key,
this.filter = SongFilter.all,
this.query = '',
this.albumId,
this.artistId,
this.filterName,
});
final SongFilter filter;
final String query;
final String? albumId;
final String? artistId;
/// Album or singer name, shown when the list is limited to one of them.
final String? filterName;
@override
State<SongsScreen> createState() => _SongsScreenState();
}
class _SongsScreenState extends State<SongsScreen> {
static const _pageSize = 50;
late SongFilter _filter = widget.filter;
late String _query = widget.query;
bool _newestFirst = true;
int _page = 0;
final _selected = <String>{};
late Future<ResultPage<AdminTrack>> _data = _load();
Future<ResultPage<AdminTrack>> _load() async {
final page = await AdminApi.tracks(
query: _query,
filter: _filter,
albumId: widget.albumId,
artistId: widget.artistId,
newestFirst: _newestFirst,
page: _page,
pageSize: _pageSize,
);
return page;
}
void _reload({bool resetPage = false, bool keepSelection = true}) => setState(() {
if (resetPage) _page = 0;
if (!keepSelection) _selected.clear();
_data = _load();
});
Future<void> _setStatus(List<String> ids, SongStatus status) async {
try {
await AdminApi.setStatus(ids, status);
if (!mounted) return;
showMessage(context, '${ids.length == 1 ? 'Song' : '${ids.length} songs'}: ${status.label.toLowerCase()}');
_selected.removeAll(ids);
_reload();
} catch (e) {
if (mounted) showError(context, e);
}
}
Future<void> _edit(AdminTrack t) async {
if (await editSong(context, t)) _reload();
}
Future<void> _bulkEdit() async {
if (await bulkEditSongs(context, _selected.toList())) {
if (!mounted) return;
showMessage(context, 'Updated ${_selected.length} songs');
_reload(keepSelection: false);
}
}
@override
Widget build(BuildContext context) {
final scoped = widget.albumId != null || widget.artistId != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PageHeader(
'Songs',
subtitle: scoped
? '${widget.albumId != null ? 'Album' : 'Singer'}: ${widget.filterName ?? ''}'
: 'Search, listen, fix details and publish',
actions: [
if (scoped)
OutlinedButton.icon(
onPressed: () => context.go('/songs'),
icon: const Icon(Icons.close, size: 18),
label: const Text('Show all songs'),
),
SearchField(
hint: 'Title, singer, album…',
initial: _query,
onChanged: (q) {
_query = q;
_reload(resetPage: true, keepSelection: false);
},
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final f in SongFilter.values)
ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
_filter = f;
_reload(resetPage: true, keepSelection: false);
},
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
_newestFirst = !_newestFirst;
_reload(resetPage: true);
},
icon: const Icon(Icons.sort, size: 18),
label: Text(_newestFirst ? 'Newest first' : 'A to Z'),
),
],
),
),
const SizedBox(height: 8),
if (_selected.isNotEmpty) _bulkBar(),
Expanded(
child: Loader(
future: _data,
onRetry: _reload,
builder: (context, page) => page.items.isEmpty
? const EmptyState('No songs match.', icon: Icons.music_off_outlined)
: _list(page),
),
),
],
);
}
Widget _bulkBar() => Container(
margin: const EdgeInsets.fromLTRB(24, 4, 24, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: NkColors.raised, borderRadius: BorderRadius.circular(14)),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text('${_selected.length} selected', style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () => _setStatus(_selected.toList(), SongStatus.approved),
icon: const Icon(Icons.check, size: 18),
label: const Text('Approve'),
),
OutlinedButton.icon(
onPressed: () => _setStatus(_selected.toList(), SongStatus.takenDown),
icon: const Icon(Icons.visibility_off_outlined, size: 18),
label: const Text('Hide'),
),
OutlinedButton.icon(
onPressed: _bulkEdit,
icon: const Icon(Icons.edit_outlined, size: 18),
label: const Text('Edit…'),
),
TextButton(onPressed: () => setState(_selected.clear), child: const Text('Clear selection')),
],
),
);
Widget _list(ResultPage<AdminTrack> page) {
final pageIds = page.items.map((t) => t.id).toSet();
final allOnPage = pageIds.every(_selected.contains);
final wide = MediaQuery.sizeOf(context).width >= 1100;
final first = _page * _pageSize + 1;
final last = first + page.items.length - 1;
return ListView(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
children: [
Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
ListTile(
dense: true,
leading: Checkbox(
value: allOnPage,
onChanged: (v) => setState(() => v == true ? _selected.addAll(pageIds) : _selected.removeAll(pageIds)),
),
title: Text('$first–$last of ${page.total}', style: Theme.of(context).textTheme.bodySmall),
),
for (final t in page.items) ...[const Divider(), _row(t, wide)],
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _page == 0
? null
: () {
_page--;
_reload();
},
child: const Text('Previous'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: last >= page.total
? null
: () {
_page++;
_reload();
},
child: const Text('Next'),
),
],
),
],
);
}
Widget _row(AdminTrack t, bool wide) {
final small = Theme.of(context).textTheme.bodySmall;
final quick = t.status == SongStatus.approved
? IconButton(
tooltip: 'Hide from the app',
onPressed: () => _setStatus([t.id], SongStatus.takenDown),
icon: const Icon(Icons.visibility_off_outlined),
)
: IconButton(
tooltip: 'Approve (publish in the app)',
onPressed: t.audioKey == null ? null : () => _setStatus([t.id], SongStatus.approved),
icon: const Icon(Icons.check_circle_outline, color: NkColors.teaLeaf),
);
return InkWell(
onTap: () => _edit(t),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
Checkbox(
value: _selected.contains(t.id),
onChanged: (v) => setState(() => v == true ? _selected.add(t.id) : _selected.remove(t.id)),
),
PlayButton(t),
const SizedBox(width: 8),
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(
wide ? creditLine(t) : '${creditLine(t)} · ${t.albumTitle ?? 'No album'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: t.singerNames.isEmpty ? NkColors.info : null),
),
],
),
),
if (wide) ...[
Expanded(
flex: 3,
child: Text(t.albumTitle ?? 'No album', maxLines: 1, overflow: TextOverflow.ellipsis,
style: small?.copyWith(color: t.albumId == null ? NkColors.info : null)),
),
SizedBox(width: 150, child: Text(occasions[t.occasion] ?? t.occasion, style: small)),
SizedBox(width: 50, child: Text(formatDuration(t.duration), style: small)),
],
SizedBox(width: 150, child: Align(alignment: Alignment.centerLeft, child: StatusChip(t.status))),
quick,
IconButton(tooltip: 'Edit', onPressed: () => _edit(t), icon: const Icon(Icons.edit_outlined)),
],
),
),
);
}
}

55
admin/lib/session.dart Normal file
View File

@ -0,0 +1,55 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'api.dart';
enum SessionState { checking, signedOut, admin }
/// Who is signed in. Only admin accounts get past the sign-in page: anyone else is
/// signed straight out again (the database refuses their changes either way).
class AdminSession extends ChangeNotifier {
AdminSession._();
static final instance = AdminSession._();
SessionState state = SessionState.checking;
String? email;
/// Shown on the sign-in page after a non-admin account tried to sign in.
String? refusal;
StreamSubscription<AuthState>? _sub;
void start() {
_sub ??= Supabase.instance.client.auth.onAuthStateChange.listen((e) => _check(e.session));
_check(Supabase.instance.client.auth.currentSession);
}
Future<void> _check(Session? session) async {
if (session == null) {
_set(SessionState.signedOut, null);
return;
}
if (state == SessionState.admin && email == session.user.email) return;
try {
if (await AdminApi.isAdmin()) {
refusal = null;
_set(SessionState.admin, session.user.email);
return;
}
refusal = '${session.user.email} is not an admin account.';
} catch (e) {
refusal = 'Could not check your account: $e';
}
await AdminApi.signOut();
_set(SessionState.signedOut, null);
}
void _set(SessionState s, String? e) {
if (s == state && e == email) return;
state = s;
email = e;
notifyListeners();
}
}

153
admin/lib/shell.dart Normal file
View File

@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api.dart';
import 'player.dart';
import 'session.dart';
import 'theme.dart';
import 'widgets.dart';
class _Section {
const _Section(this.path, this.label, this.icon);
final String path;
final String label;
final IconData icon;
}
const _sections = [
_Section('/', 'Overview', Icons.dashboard_outlined),
_Section('/songs', 'Songs', Icons.music_note_outlined),
_Section('/albums', 'Albums', Icons.album_outlined),
_Section('/singers', 'Singers', Icons.mic_none_outlined),
_Section('/applications', 'Creators', Icons.how_to_reg_outlined),
];
/// Sidebar (wide screens) or drawer (phones), the page, and the player bar.
class AdminShell extends StatelessWidget {
const AdminShell({super.key, required this.location, required this.child});
final String location;
final Widget child;
int get _selected {
final i = _sections.lastIndexWhere((s) => s.path == '/' ? location == '/' : location.startsWith(s.path));
return i < 0 ? 0 : i;
}
@override
Widget build(BuildContext context) {
final session = AdminSession.instance;
return ListenableBuilder(
listenable: session,
builder: (context, _) {
if (session.state != SessionState.admin) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final wide = MediaQuery.sizeOf(context).width >= 900;
final page = Column(children: [Expanded(child: child), const PlayerBar()]);
if (!wide) {
return Scaffold(
appBar: AppBar(
backgroundColor: NkColors.surface,
title: Text(_sections[_selected].label),
),
drawer: Drawer(
backgroundColor: NkColors.surface,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(padding: EdgeInsets.all(20), child: _Brand()),
for (final (i, s) in _sections.indexed)
ListTile(
leading: Icon(s.icon, color: i == _selected ? NkColors.sun : NkColors.muted),
title: Text(s.label),
selected: i == _selected,
selectedColor: NkColors.sun,
onTap: () {
Navigator.of(context).pop();
context.go(s.path);
},
),
const Spacer(),
_Account(email: session.email),
],
),
),
),
body: page,
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: MediaQuery.sizeOf(context).width >= 1200,
minExtendedWidth: 220,
selectedIndex: _selected,
onDestinationSelected: (i) => context.go(_sections[i].path),
leading: const Padding(padding: EdgeInsets.fromLTRB(12, 20, 12, 24), child: _Brand()),
trailing: Expanded(
child: Align(alignment: Alignment.bottomLeft, child: _Account(email: session.email)),
),
destinations: [
for (final s in _sections)
NavigationRailDestination(icon: Icon(s.icon), label: Text(s.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: page),
],
),
);
},
);
}
}
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: const BoxDecoration(color: NkColors.sun, shape: BoxShape.circle),
child: const Text('NK', style: TextStyle(color: NkColors.ground, fontWeight: FontWeight.w900)),
),
if (MediaQuery.sizeOf(context).width >= 1200 || MediaQuery.sizeOf(context).width < 900) ...[
const SizedBox(width: 10),
Text('NK Admin', style: Theme.of(context).textTheme.titleLarge),
],
],
);
}
class _Account extends StatelessWidget {
const _Account({required this.email});
final String? email;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(12),
child: Tooltip(
message: email ?? '',
child: TextButton.icon(
onPressed: () async {
if (await confirm(context, 'Sign out?', 'You will need a new email code to sign in again.',
action: 'Sign out')) {
await AdminPlayer.instance.close();
await AdminApi.signOut();
}
},
icon: const Icon(Icons.logout, size: 18),
label: const Text('Sign out'),
),
),
);
}

132
admin/lib/theme.dart Normal file
View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Nanga Kathai palette (same as the app, lib/app/theme.dart).
abstract final class NkColors {
static const ground = Color(0xFF0B1A14);
static const surface = Color(0xFF14281F);
static const raised = Color(0xFF1F3A2B);
static const outline = Color(0xFF2A4537);
static const teaLeaf = Color(0xFF7FA857);
static const sun = Color(0xFFF5B335);
static const roofRed = Color(0xFFC9502F);
static const cream = Color(0xFFFBF7EE);
static const muted = Color(0xFF9FB3A6);
static const info = Color(0xFF6FA8C9);
}
ThemeData buildNkTheme() {
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: NkColors.ground,
colorScheme: const ColorScheme.dark(
primary: NkColors.sun,
onPrimary: NkColors.ground,
secondary: NkColors.teaLeaf,
surface: NkColors.ground,
onSurface: NkColors.cream,
surfaceContainer: NkColors.surface,
surfaceContainerHigh: NkColors.raised,
surfaceContainerHighest: NkColors.raised,
outline: NkColors.outline,
outlineVariant: NkColors.outline,
error: NkColors.roofRed,
),
);
final body = base.textTheme.apply(
fontFamily: GoogleFonts.manrope().fontFamily,
bodyColor: NkColors.cream,
displayColor: NkColors.cream,
);
TextStyle display(double size, [FontWeight w = FontWeight.w800]) =>
GoogleFonts.sora(fontSize: size, fontWeight: w, color: NkColors.cream, height: 1.15);
OutlineInputBorder border(Color c) =>
OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: c, width: 1.5));
return base.copyWith(
textTheme: body.copyWith(
headlineMedium: display(28),
headlineSmall: display(22),
titleLarge: display(18, FontWeight.w700),
titleMedium: body.titleMedium?.copyWith(fontWeight: FontWeight.w700),
bodySmall: body.bodySmall?.copyWith(color: NkColors.muted),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: NkColors.sun,
foregroundColor: NkColors.ground,
minimumSize: const Size(64, 44),
shape: const StadiumBorder(),
textStyle: GoogleFonts.manrope(fontSize: 15, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: NkColors.cream,
minimumSize: const Size(64, 42),
side: const BorderSide(color: NkColors.outline, width: 1.5),
shape: const StadiumBorder(),
textStyle: GoogleFonts.manrope(fontSize: 14, fontWeight: FontWeight.w700),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: NkColors.sun,
textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: NkColors.surface,
isDense: true,
hintStyle: const TextStyle(color: NkColors.muted),
labelStyle: const TextStyle(color: NkColors.muted),
border: border(NkColors.outline),
enabledBorder: border(NkColors.outline),
focusedBorder: border(NkColors.sun),
),
chipTheme: base.chipTheme.copyWith(
backgroundColor: NkColors.raised,
selectedColor: NkColors.sun,
side: BorderSide.none,
shape: const StadiumBorder(),
labelStyle: GoogleFonts.manrope(fontWeight: FontWeight.w600, color: NkColors.cream),
secondaryLabelStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, color: NkColors.ground),
showCheckmark: false,
),
cardTheme: CardThemeData(
color: NkColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: NkColors.outline),
),
),
dialogTheme: DialogThemeData(
backgroundColor: NkColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
dividerTheme: const DividerThemeData(color: NkColors.outline, space: 1),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: NkColors.surface,
indicatorColor: NkColors.raised,
selectedIconTheme: const IconThemeData(color: NkColors.sun),
unselectedIconTheme: const IconThemeData(color: NkColors.muted),
selectedLabelTextStyle: GoogleFonts.manrope(color: NkColors.sun, fontWeight: FontWeight.w700),
unselectedLabelTextStyle: GoogleFonts.manrope(color: NkColors.muted, fontWeight: FontWeight.w600),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith((s) => s.contains(WidgetState.selected) ? NkColors.sun : null),
checkColor: const WidgetStatePropertyAll(NkColors.ground),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: NkColors.raised,
contentTextStyle: TextStyle(color: NkColors.cream),
behavior: SnackBarBehavior.floating,
width: 420,
),
);
}

257
admin/lib/widgets.dart Normal file
View File

@ -0,0 +1,257 @@
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();

831
admin/pubspec.lock Normal file
View File

@ -0,0 +1,831 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
app_links:
dependency: transitive
description:
name: app_links
sha256: "08ba88bf19d9edea33130cb71c8ced2cb982b4e00b82efef4a434d1357bc69c3"
url: "https://pub.dev"
source: hosted
version: "7.2.2"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: "9ccb5802085a1e57bb07b6256fd1d61a78c7ec104944d4965e62938b4fefd9cb"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: "382d32c6f486f15b1d7f5d6a37a70a481a4fc08382eef419d373169921a3e765"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: f9e7711a0e24ca8b40f5d7ac374c3c6e55016f3157912badd18199cdbbca5c4d
url: "https://pub.dev"
source: hosted
version: "0.2.4"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
url: "https://pub.dev"
source: hosted
version: "3.0.8"
cupertino_ui:
dependency: transitive
description:
name: cupertino_ui
sha256: a47c4e7c66fd7444a063a602597fceb238f705c3f6b1322bc0053d0a2638ee6e
url: "https://pub.dev"
source: hosted
version: "1.1.4"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
url: "https://pub.dev"
source: hosted
version: "3.4.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
functions_client:
dependency: transitive
description:
name: functions_client
sha256: "931e822688d2e97867b2300e2fdbb08c9db73f8653fc83efc7a259d2e0cca769"
url: "https://pub.dev"
source: hosted
version: "2.7.1"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: "07089be6dc124eb9596a51b5e359c657f30a59929287f11b126c2cd13eaaece0"
url: "https://pub.dev"
source: hosted
version: "18.1.1"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: fdbbfbbb1a62f376ab29fa9627561d389751fb984da0b630ce3326e272911c8a
url: "https://pub.dev"
source: hosted
version: "9.0.0"
gotrue:
dependency: transitive
description:
name: gotrue
sha256: cca816226824160e40d149816fd89740cc64dda1eaa3d9161e334330b18bba89
url: "https://pub.dev"
source: hosted
version: "2.27.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
jni:
dependency: transitive
description:
name: jni
sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f
url: "https://pub.dev"
source: hosted
version: "1.1.1"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
url: "https://pub.dev"
source: hosted
version: "1.0.4+1"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: e60aa97b233ddea025e13dfac59195207f528fa5e9e4a4a49a8c0766701e5fe6
url: "https://pub.dev"
source: hosted
version: "0.10.6"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_ui:
dependency: transitive
description:
name: material_ui
sha256: e4031746f96c8316864d9b92bce375f1320a36ed56e166307e1e1bda9d90b4ab
url: "https://pub.dev"
source: hosted
version: "1.8.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
passkeys_platform_interface:
dependency: transitive
description:
name: passkeys_platform_interface
sha256: "5fca044a6f1a3c5abff645ca5a5417b613c130d1c4e08513ba9182c7f4834619"
url: "https://pub.dev"
source: hosted
version: "2.10.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
postgrest:
dependency: transitive
description:
name: postgrest
sha256: b7f957273ba6a1c26cefa987ac524f4138e9ab7542fbb9e6abdfddacb9895a21
url: "https://pub.dev"
source: hosted
version: "2.9.1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
realtime_client:
dependency: transitive
description:
name: realtime_client
sha256: "2ef680d83ba2c0f76019d16194ca7067fa2458b2d4091b52323a9ca4a964fd40"
url: "https://pub.dev"
source: hosted
version: "2.13.2"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
storage_client:
dependency: transitive
description:
name: storage_client
sha256: b951281b8e2b293460f0e6cc4e866f2546681390f0e37d24fbbd7b4a0deb27a3
url: "https://pub.dev"
source: hosted
version: "2.8.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
supabase:
dependency: transitive
description:
name: supabase
sha256: "123fe08792b24e5e7c4b9a01145de8e95ebaa9183cd5f3c9d48dd2ff890f28a6"
url: "https://pub.dev"
source: hosted
version: "2.16.5"
supabase_common:
dependency: transitive
description:
name: supabase_common
sha256: a7961a44953cf8d9da049381fc3377516348255042fbb12b9d4f8540816f2fe9
url: "https://pub.dev"
source: hosted
version: "0.1.2"
supabase_flutter:
dependency: "direct main"
description:
name: supabase_flutter
sha256: "48bbde226e68bdb293020859a1bfa42d0f3bd2239992fba0ec4f9194cbb842e6"
url: "https://pub.dev"
source: hosted
version: "2.18.3"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5
url: "https://pub.dev"
source: hosted
version: "3.5.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
web:
dependency: transitive
description:
name: web
sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
yet_another_json_isolate:
dependency: transitive
description:
name: yet_another_json_isolate
sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9
url: "https://pub.dev"
source: hosted
version: "2.1.1"
sdks:
dart: ">=3.13.4 <4.0.0"
flutter: ">=3.47.0"

26
admin/pubspec.yaml Normal file
View File

@ -0,0 +1,26 @@
name: nk_admin
description: "NK Admin - manage the Nanga Kathai catalog (admin.nkbmusic.com)"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.13.4
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
supabase_flutter: ^2.18.3
go_router: ^18.1.1
google_fonts: ^9.0.0
just_audio: ^0.10.6
url_launcher: ^6.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true

BIN
admin/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

18
admin/web/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="Nanga Kathai catalog administration">
<meta name="theme-color" content="#0B1A14">
<link rel="icon" type="image/png" href="favicon.png"/>
<title>NK Admin</title>
<link rel="manifest" href="manifest.json">
<style>body { margin: 0; background: #0B1A14; }</style>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

35
admin/web/manifest.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "NK Admin",
"short_name": "NK Admin",
"start_url": ".",
"display": "standalone",
"background_color": "#0B1A14",
"theme_color": "#0B1A14",
"description": "Nanga Kathai catalog administration",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@ -18,7 +18,7 @@ Everything runs in **one** Docker Compose project (`~/APPS/nk-backend/supabase-p
| Domain | Hestia reverse proxy to | What it is | | Domain | Hestia reverse proxy to | What it is |
|---|---|---| |---|---|---|
| `app.nkbmusic.com` | `http://127.0.0.1:18000` | API for the app + Supabase Studio | | `app.nkbmusic.com` | `http://127.0.0.1:18000` | API for the app + Supabase Studio |
| `admin.nkbmusic.com` | `http://127.0.0.1:18080` | NK Admin website (container `nk-admin`) | | `admin.nkbmusic.com` | `http://127.0.0.1:18080` | NK Admin website (container `nk-admin`; publish with `sh admin/deploy-admin.sh`, see [admin/README.md](../admin/README.md)) |
Internal only (never proxied): Postgres `127.0.0.1:15432`, pooler `127.0.0.1:16543`. Change ports with `API_GW_HTTP_PORT`, `NK_ADMIN_PORT`, `NK_DB_PORT`, `NK_POOLER_PORT` in the project's `.env`. Internal only (never proxied): Postgres `127.0.0.1:15432`, pooler `127.0.0.1:16543`. Change ports with `API_GW_HTTP_PORT`, `NK_ADMIN_PORT`, `NK_DB_PORT`, `NK_POOLER_PORT` in the project's `.env`.

View File

@ -93,7 +93,7 @@ else
fi fi
cd "$PROJECT" cd "$PROJECT"
# Admin website: the placeholder until a real build has been published (deploy-admin.sh). # Admin website: the placeholder until a real build has been published (admin/deploy-admin.sh).
if [ ! -f nk/admin/index.html ]; then if [ ! -f nk/admin/index.html ]; then
mkdir -p nk/admin mkdir -p nk/admin
cp "$HERE/admin-placeholder/index.html" nk/admin/ cp "$HERE/admin-placeholder/index.html" nk/admin/

View File

@ -1,19 +1,22 @@
# NK Admin: a single-page web app. Unknown paths load index.html so deep links work. # NK Admin: a single-page web app. Unknown paths load index.html so deep links work.
# Flutter's main.dart.js and flutter_bootstrap.js have no content hash, so code is always
# revalidated (cheap: ETag) and only images and fonts are cached.
# nginx drops server-level add_header lines inside a location that sets its own, so the
# security headers are repeated in each location.
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache" always;
}
# Built files have content hashes or change rarely; cache them.
location ~* \.(js|wasm|css|png|jpg|svg|ttf|otf|woff2?)$ {
expires 7d;
}
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always; add_header Referrer-Policy same-origin always;
}
location ~* \.(png|jpg|svg|ico|ttf|otf|woff2?)$ {
expires 7d;
add_header X-Content-Type-Options nosniff always;
}
} }

View File

@ -0,0 +1,240 @@
-- NK Admin (admin.nkbmusic.com): list views, admin functions and the creator applications queue.
-- Every function checks is_admin(); views use the caller's row-level security unless noted.
create or replace function nk_require_admin() returns void
language plpgsql stable security definer set search_path = public as $$
begin
if not is_admin() then
raise exception 'admins only' using errcode = '42501';
end if;
end $$;
-- ---------------------------------------------------------------- list views
-- One row per song with its album and singers, so the admin can search and filter in one query.
create view admin_tracks with (security_invoker = true) as
select t.id, t.title_latin, t.title_tamil, t.title_en_meaning, t.album_id,
al.title_latin as album_title, t.occasion, t.year, t.duration_ms, t.audio_key_128,
t.cover_url, t.tier, t.status, t.rights_status, t.source_note, t.review_note,
t.reviewed_at, t.created_at,
coalesce(s.ids, '{}') as singer_ids,
coalesce(s.names, '') as singer_names,
lower(concat_ws(' ', t.title_latin, t.title_tamil, t.title_en_meaning, al.title_latin,
s.names, t.source_note)) as search_text
from tracks t
left join albums al on al.id = t.album_id
left join lateral (
select array_agg(ar.id order by ta.position) as ids,
string_agg(ar.name_latin, ', ' order by ta.position) as names
from track_artists ta
join artists ar on ar.id = ta.artist_id
where ta.track_id = t.id and ta.role = 'singer'
) s on true;
create view admin_albums with (security_invoker = true) as
select al.id, al.title_latin, al.title_tamil, al.year, al.cover_url,
(select count(*) from tracks t where t.album_id = al.id)::int as track_count,
(select count(*) from tracks t where t.album_id = al.id and t.status = 'approved')::int as approved_count
from albums al;
create view admin_artists with (security_invoker = true) as
select ar.id, ar.name_latin, ar.name_tamil, ar.hatti_id, h.name_latin as hatti_name, ar.bio,
ar.photo_url, ar.photo_permission, ar.verified, ar.claimed_by, ar.created_at,
(select count(distinct ta.track_id) from track_artists ta
where ta.artist_id = ar.id and ta.role = 'singer')::int as track_count
from artists ar
left join hattis h on h.id = ar.hatti_id;
revoke all on admin_tracks, admin_albums, admin_artists from anon;
grant select on admin_tracks, admin_albums, admin_artists to authenticated;
-- ---------------------------------------------------------------- creator applications
create type application_status as enum ('pending', 'needs_info', 'approved', 'rejected');
create table creator_applications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references profiles on delete cascade,
stage_name text not null,
real_name text not null,
hatti_name text,
instagram text,
whatsapp text,
work_links text[] not null default '{}',
id_document_path text, -- object in the private creator-documents bucket
claimed_songs text, -- the applicant's own words; admins link real songs later
message text,
status application_status not null default 'pending',
artist_id uuid references artists on delete set null, -- set on approval
review_note text,
reviewed_by uuid references profiles,
reviewed_at timestamptz,
created_at timestamptz not null default now()
);
create index creator_applications_status_idx on creator_applications (status);
create unique index creator_applications_one_open on creator_applications (user_id)
where status in ('pending', 'needs_info');
alter table creator_applications enable row level security;
create policy own_application_read on creator_applications for select
using (user_id = auth.uid() or is_admin());
create policy own_application_insert on creator_applications for insert
with check (user_id = auth.uid() and status = 'pending' and artist_id is null and reviewed_by is null);
create policy admin_applications on creator_applications for all
using (is_admin()) with check (is_admin());
-- Applications with the applicant's email and profile. Runs as the owner (to read auth.users),
-- so it filters to admins itself.
create view admin_applications as
select ca.*, u.email, p.display_name, p.phone, p.role as user_role
from creator_applications ca
join auth.users u on u.id = ca.user_id
join profiles p on p.id = ca.user_id
where is_admin();
revoke all on admin_applications from anon;
grant select on admin_applications to authenticated;
-- ID documents: creator-documents/<user id>/<file>. Applicants upload their own; only admins read.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('creator-documents', 'creator-documents', false, 10485760,
array['image/jpeg','image/png','image/webp','application/pdf'])
on conflict (id) do nothing;
create policy nk_own_creator_documents on storage.objects for insert to authenticated
with check (bucket_id = 'creator-documents' and (storage.foldername(name))[1] = auth.uid()::text);
create policy nk_admin_creator_documents on storage.objects for select to authenticated
using (bucket_id = 'creator-documents' and public.is_admin());
-- ---------------------------------------------------------------- admin functions
create function admin_overview() returns json
language plpgsql stable security definer set search_path = public as $$
begin
perform nk_require_admin();
return json_build_object(
'songs', (select count(*) from tracks),
'approved', (select count(*) from tracks where status = 'approved'),
'pending', (select count(*) from tracks where status = 'pending'),
'needs_changes', (select count(*) from tracks where status = 'needs_changes'),
'hidden', (select count(*) from tracks where status in ('taken_down', 'rejected')),
'no_singer', (select count(*) from tracks t where not exists
(select 1 from track_artists ta where ta.track_id = t.id and ta.role = 'singer')),
'no_album', (select count(*) from tracks where album_id is null),
'albums', (select count(*) from albums),
'empty_albums', (select count(*) from albums al where not exists
(select 1 from tracks t where t.album_id = al.id)),
'singers', (select count(*) from artists),
'listeners', (select count(*) from profiles),
'applications', (select count(*) from creator_applications where status = 'pending'),
'takedowns', (select count(*) from takedown_requests where status = 'open'));
end $$;
-- Sets the singers of one or more songs, in the given order. p_replace = false adds them
-- after the existing singers instead.
create function admin_set_singers(p_track_ids uuid[], p_artist_ids uuid[], p_replace boolean default true)
returns void
language plpgsql security definer set search_path = public as $$
begin
perform nk_require_admin();
if p_replace then
delete from track_artists where track_id = any (p_track_ids) and role = 'singer';
end if;
insert into track_artists (track_id, artist_id, role, position)
select t.id, a.id, 'singer',
coalesce((select max(x.position) + 1 from track_artists x
where x.track_id = t.id and x.role = 'singer'), 0) + a.ord::int - 1
from unnest(p_track_ids) as t(id)
cross join unnest(p_artist_ids) with ordinality as a(id, ord)
on conflict do nothing;
end $$;
-- Moves every song of one album into another and deletes the first (for duplicates).
create function admin_merge_albums(p_from uuid, p_into uuid) returns void
language plpgsql security definer set search_path = public as $$
begin
perform nk_require_admin();
if p_from = p_into then return; end if;
update tracks set album_id = p_into where album_id = p_from;
update albums a set title_tamil = coalesce(a.title_tamil, f.title_tamil),
year = coalesce(a.year, f.year),
cover_url = coalesce(a.cover_url, f.cover_url)
from albums f where a.id = p_into and f.id = p_from;
delete from albums where id = p_from;
end $$;
-- Moves every credit of one singer onto another and deletes the first (for duplicates).
create function admin_merge_artists(p_from uuid, p_into uuid) returns void
language plpgsql security definer set search_path = public as $$
begin
perform nk_require_admin();
if p_from = p_into then return; end if;
insert into track_artists (track_id, artist_id, role, position)
select track_id, p_into, role, position from track_artists where artist_id = p_from
on conflict do nothing;
delete from track_artists where artist_id = p_from;
update consents set artist_id = p_into where artist_id = p_from;
update creator_applications set artist_id = p_into where artist_id = p_from;
update artists a set name_tamil = coalesce(a.name_tamil, f.name_tamil),
hatti_id = coalesce(a.hatti_id, f.hatti_id),
bio = coalesce(a.bio, f.bio),
verified = a.verified or f.verified,
claimed_by = coalesce(a.claimed_by, f.claimed_by)
from artists f where a.id = p_into and f.id = p_from;
delete from artists where id = p_from;
end $$;
-- Finds or creates a hatti by its Latin name; null for an empty name.
create function admin_hatti_id(p_name text) returns uuid
language plpgsql security definer set search_path = public as $$
declare
v_id uuid;
begin
perform nk_require_admin();
if nullif(trim(p_name), '') is null then return null; end if;
select id into v_id from hattis where lower(name_latin) = lower(trim(p_name));
if v_id is null then
insert into hattis (name_latin) values (trim(p_name)) returning id into v_id;
end if;
return v_id;
end $$;
-- Records the decision on a creator application. Approving makes the user an artist and
-- links (or creates) their verified singer profile. Returns the singer id when approved.
create function admin_review_application(p_id uuid, p_decision application_status,
p_note text default null, p_artist_id uuid default null)
returns uuid
language plpgsql security definer set search_path = public as $$
declare
app creator_applications;
v_artist uuid;
begin
perform nk_require_admin();
select * into app from creator_applications where id = p_id for update;
if not found then
raise exception 'application not found';
end if;
if p_decision = 'approved' then
v_artist := coalesce(p_artist_id, app.artist_id);
if v_artist is null then
insert into artists (name_latin, hatti_id, verified, claimed_by)
values (app.stage_name, admin_hatti_id(app.hatti_name), true, app.user_id)
returning id into v_artist;
else
update artists set verified = true, claimed_by = app.user_id where id = v_artist;
end if;
update profiles set role = 'artist' where id = app.user_id and role = 'listener';
end if;
update creator_applications
set status = p_decision, review_note = p_note, reviewed_by = auth.uid(), reviewed_at = now(),
artist_id = coalesce(v_artist, artist_id)
where id = p_id;
return v_artist;
end $$;
revoke all on function nk_require_admin(), admin_overview(), admin_set_singers(uuid[], uuid[], boolean),
admin_merge_albums(uuid, uuid), admin_merge_artists(uuid, uuid), admin_hatti_id(text),
admin_review_application(uuid, application_status, text, uuid) from public, anon;
grant execute on function nk_require_admin(), admin_overview(), admin_set_singers(uuid[], uuid[], boolean),
admin_merge_albums(uuid, uuid), admin_merge_artists(uuid, uuid), admin_hatti_id(text),
admin_review_application(uuid, application_status, text, uuid) to authenticated;