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:
parent
d368880601
commit
cf645071aa
48
admin/.gitignore
vendored
Normal file
48
admin/.gitignore
vendored
Normal 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
30
admin/.metadata
Normal 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
64
admin/README.md
Normal 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`.
|
||||
6
admin/analysis_options.yaml
Normal file
6
admin/analysis_options.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- web/**
|
||||
58
admin/deploy-admin.sh
Normal file
58
admin/deploy-admin.sh
Normal 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
383
admin/lib/api.dart
Normal 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
10
admin/lib/env.dart
Normal 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
44
admin/lib/main.dart
Normal 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
206
admin/lib/pickers.dart
Normal 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
137
admin/lib/player.dart
Normal 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
54
admin/lib/router.dart
Normal 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);
|
||||
161
admin/lib/screens/albums_screen.dart
Normal file
161
admin/lib/screens/albums_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
227
admin/lib/screens/applications_screen.dart
Normal file
227
admin/lib/screens/applications_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
admin/lib/screens/overview_screen.dart
Normal file
184
admin/lib/screens/overview_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
160
admin/lib/screens/sign_in_screen.dart
Normal file
160
admin/lib/screens/sign_in_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
188
admin/lib/screens/singers_screen.dart
Normal file
188
admin/lib/screens/singers_screen.dart
Normal 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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
314
admin/lib/screens/song_editor.dart
Normal file
314
admin/lib/screens/song_editor.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
299
admin/lib/screens/songs_screen.dart
Normal file
299
admin/lib/screens/songs_screen.dart
Normal 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
55
admin/lib/session.dart
Normal 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
153
admin/lib/shell.dart
Normal 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
132
admin/lib/theme.dart
Normal 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
257
admin/lib/widgets.dart
Normal 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
831
admin/pubspec.lock
Normal 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
26
admin/pubspec.yaml
Normal 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
BIN
admin/web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
admin/web/icons/Icon-192.png
Normal file
BIN
admin/web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
admin/web/icons/Icon-512.png
Normal file
BIN
admin/web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
admin/web/icons/Icon-maskable-192.png
Normal file
BIN
admin/web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
admin/web/icons/Icon-maskable-512.png
Normal file
BIN
admin/web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
18
admin/web/index.html
Normal file
18
admin/web/index.html
Normal 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
35
admin/web/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -18,7 +18,7 @@ Everything runs in **one** Docker Compose project (`~/APPS/nk-backend/supabase-p
|
||||
| Domain | Hestia reverse proxy to | What it is |
|
||||
|---|---|---|
|
||||
| `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`.
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ else
|
||||
fi
|
||||
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
|
||||
mkdir -p nk/admin
|
||||
cp "$HERE/admin-placeholder/index.html" nk/admin/
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
# 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 {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Built files have content hashes or change rarely; cache them.
|
||||
location ~* \.(js|wasm|css|png|jpg|svg|ttf|otf|woff2?)$ {
|
||||
expires 7d;
|
||||
}
|
||||
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header X-Content-Type-Options nosniff 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;
|
||||
}
|
||||
}
|
||||
|
||||
240
supabase/migrations/20261031000000_admin.sql
Normal file
240
supabase/migrations/20261031000000_admin.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user