Compare commits
No files matched your search
@@ -0,0 +1,109 @@
|
|||||||
|
# Интеграционные проверки SQL на настоящем PostgreSQL + pgvector: миграции вверх / вниз / вверх,
|
||||||
|
# запросы сканера, отпечатков, шаблонов и очереди, сквозной сценарий пользователя через HTTP (Go),
|
||||||
|
# загрузка каталога, профиля и обучение ALS (Python).
|
||||||
|
name: ci / db
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- 'deploy/backup.sh'
|
||||||
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'services/api/**'
|
||||||
|
- 'services/recommender/**'
|
||||||
|
- 'deploy/backup.sh'
|
||||||
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
db:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:0.8.7-pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: pipu
|
||||||
|
POSTGRES_PASSWORD: pipu
|
||||||
|
POSTGRES_DB: pipu
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U pipu"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.11
|
||||||
|
env:
|
||||||
|
MEILI_MASTER_KEY: ci-master-key-0123456789
|
||||||
|
MEILI_ENV: development
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
||||||
|
TEST_REDIS_URL: redis://redis:6379/0
|
||||||
|
TEST_MEILI_URL: http://meilisearch:7700
|
||||||
|
TEST_MEILI_KEY: ci-master-key-0123456789
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||||
|
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||||
|
run: |
|
||||||
|
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||||
|
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||||
|
echo "свободно на раннере: ${free} МБ"
|
||||||
|
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||||
|
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||||
|
host=${svc%:*}; port=${svc#*:}
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||||
|
echo "$svc готов"; break
|
||||||
|
fi
|
||||||
|
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
done
|
||||||
|
- name: Go — миграции и запросы store
|
||||||
|
working-directory: services/api
|
||||||
|
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||||
|
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||||
|
working-directory: services/api
|
||||||
|
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
||||||
|
- name: Python — каталог, профиль, обучение ALS
|
||||||
|
working-directory: services/recommender
|
||||||
|
run: |
|
||||||
|
uv sync --frozen
|
||||||
|
uv run pytest -q tests/test_db_integration.py
|
||||||
|
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
|
||||||
|
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
|
||||||
|
env:
|
||||||
|
PGHOST: postgres
|
||||||
|
PGUSER: pipu
|
||||||
|
PGPASSWORD: pipu
|
||||||
|
PGDATABASE: pipu
|
||||||
|
BACKUP_DIR: /tmp/backups
|
||||||
|
VERIFY_EXACT: "1"
|
||||||
|
run: |
|
||||||
|
SUDO=$(command -v sudo || true)
|
||||||
|
$SUDO install -d /usr/share/postgresql-common/pgdg
|
||||||
|
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||||
|
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
|
||||||
|
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
|
||||||
|
export PATH=/usr/lib/postgresql/17/bin:$PATH
|
||||||
|
before=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||||
|
file=$(bash deploy/backup.sh once)
|
||||||
|
bash deploy/backup.sh verify "$file"
|
||||||
|
bash deploy/backup.sh restore "$file"
|
||||||
|
after=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||||
|
echo "tracks: $before → $after"
|
||||||
|
test "$before" = "$after"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
name: ci / mobile
|
name: ci / mobile
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -48,12 +48,21 @@ jobs:
|
|||||||
android:
|
android:
|
||||||
name: android (APK в релиз)
|
name: android (APK в релиз)
|
||||||
needs: release
|
needs: release
|
||||||
|
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
|
||||||
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||||||
|
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
container:
|
container:
|
||||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||||||
|
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||||||
|
options: --cpus=3 --memory=7g --memory-swap=7g
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/ci-cache/gradle:/root/.gradle
|
- /opt/ci-cache/gradle:/root/.gradle
|
||||||
- /opt/ci-cache/android:/root/.android
|
- /opt/ci-cache/android:/root/.android
|
||||||
@@ -96,6 +105,11 @@ jobs:
|
|||||||
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
||||||
CI: "1"
|
CI: "1"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||||||
|
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||||||
|
NINJAFLAGS: "-j2"
|
||||||
|
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=1536"
|
||||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
@@ -105,14 +119,35 @@ jobs:
|
|||||||
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
|
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
|
||||||
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
|
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
|
||||||
run: |
|
run: |
|
||||||
|
set -o pipefail
|
||||||
npx expo prebuild --platform android --clean --no-install
|
npx expo prebuild --platform android --clean --no-install
|
||||||
cd android
|
cd android
|
||||||
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
|
||||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||||||
|
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||||||
|
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||||
|
fi
|
||||||
|
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||||
|
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||||
|
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||||
|
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
||||||
|
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||||||
|
./gradlew --stop || true
|
||||||
|
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||||
|
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
./gradlew --stop
|
./gradlew --stop
|
||||||
|
|
||||||
|
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||||
|
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||||
|
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||||
|
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
- name: Диагностика сбоя (диск, память, OOM)
|
- name: Диагностика сбоя (диск, память, OOM)
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
.cache/
|
||||||
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
|
|||||||
API (из `services/api`):
|
API (из `services/api`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
|
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
|
||||||
```
|
```
|
||||||
|
|
||||||
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
|
||||||
|
|||||||
+3120
-1
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
version,
|
version,
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
userInterfaceStyle: 'automatic',
|
userInterfaceStyle: 'automatic',
|
||||||
newArchEnabled: true,
|
// react-native-track-player 4 не поддерживает New Architecture (React Native Directory) — фоновое
|
||||||
|
// воспроизведение и уведомление важнее; вернём, когда плеер её поддержит (docs/mobile.md).
|
||||||
|
newArchEnabled: false,
|
||||||
backgroundColor: '#05070D',
|
backgroundColor: '#05070D',
|
||||||
android: {
|
android: {
|
||||||
package: 'app.pipu.music',
|
package: 'app.pipu.music',
|
||||||
@@ -23,9 +25,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
},
|
},
|
||||||
platforms: ['android'],
|
platforms: ['android'],
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Маршруты — файлы в src/app (docs/mobile.md).
|
||||||
|
['expo-router', { root: './src/app' }],
|
||||||
|
'expo-secure-store',
|
||||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||||
|
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||||
|
'./plugins/withArm64Only',
|
||||||
],
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
|
experiments: { typedRoutes: false },
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
// Фоновый сервис плеера регистрируется до запуска UI: кнопки уведомления и гарнитуры
|
||||||
|
// работают, даже когда интерфейс выгружен (docs/mobile.md#воспроизведение).
|
||||||
|
import TrackPlayer from 'react-native-track-player';
|
||||||
|
|
||||||
import { App } from './src/App';
|
import { PlaybackService } from './src/player/service';
|
||||||
|
|
||||||
registerRootComponent(App);
|
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
||||||
|
|
||||||
|
import 'expo-router/entry';
|
||||||
@@ -10,20 +10,48 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@pipu/api-client": "workspace:*",
|
"@pipu/api-client": "workspace:*",
|
||||||
"@pipu/core": "workspace:*",
|
"@pipu/core": "workspace:*",
|
||||||
"@pipu/design-tokens": "workspace:*",
|
"@pipu/design-tokens": "workspace:*",
|
||||||
"@pipu/i18n": "workspace:*",
|
"@pipu/i18n": "workspace:*",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
|
"@shopify/react-native-skia": "2.0.0-next.4",
|
||||||
|
"@tanstack/react-query": "^5.104.4",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-build-properties": "~0.14.8",
|
"expo-build-properties": "~0.14.8",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
|
"expo-file-system": "~18.1.11",
|
||||||
|
"expo-font": "~13.3.2",
|
||||||
|
"expo-intent-launcher": "~12.1.5",
|
||||||
|
"expo-linking": "~7.1.7",
|
||||||
|
"expo-router": "~5.1.11",
|
||||||
|
"expo-secure-store": "~14.2.4",
|
||||||
|
"expo-sqlite": "~15.2.14",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
|
"js-sha256": "^1.0.0",
|
||||||
|
"query-string": "^7.1.3",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-i18next": "^15.5.3",
|
"react-i18next": "^15.5.3",
|
||||||
"react-native": "0.79.6"
|
"react-native": "0.79.6",
|
||||||
|
"react-native-reanimated": "~3.17.5",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.11.1",
|
||||||
|
"react-native-track-player": "^4.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.5",
|
||||||
"@types/react": "~19.0.10"
|
"@types/react": "~19.0.10"
|
||||||
|
},
|
||||||
|
"expo": {
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native-track-player"
|
||||||
|
],
|
||||||
|
"listUnknownPackages": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||||
|
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||||
|
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||||
|
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||||
|
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
const MARKER = '// pipu: arm64-v8a only';
|
||||||
|
|
||||||
|
module.exports = function withArm64Only(config) {
|
||||||
|
return withAppBuildGradle(config, (cfg) => {
|
||||||
|
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||||
|
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||||
|
/defaultConfig\s*\{/,
|
||||||
|
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||||
|
);
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import type { i18n as I18n } from 'i18next';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { I18nextProvider } from 'react-i18next';
|
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
|
||||||
|
|
||||||
import { ConnectScreen } from './ConnectScreen';
|
|
||||||
import { colors } from './theme';
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const [i18n, setI18n] = useState<I18n | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
||||||
void createI18n(pickLocale([deviceLocale])).then(setI18n);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!i18n) {
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
|
||||||
<ActivityIndicator color={colors.accent} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<I18nextProvider i18n={i18n}>
|
|
||||||
<StatusBar style="light" />
|
|
||||||
<ConnectScreen />
|
|
||||||
</I18nextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { createApiClient } from '@pipu/api-client';
|
|
||||||
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
|
||||||
import Constants from 'expo-constants';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
||||||
|
|
||||||
import { colors, radius } from './theme';
|
|
||||||
|
|
||||||
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
|
||||||
|
|
||||||
type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck);
|
|
||||||
|
|
||||||
/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */
|
|
||||||
export function ConnectScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [address, setAddress] = useState('');
|
|
||||||
const [state, setState] = useState<State>({ kind: 'idle' });
|
|
||||||
|
|
||||||
const checking = state.kind === 'checking';
|
|
||||||
|
|
||||||
async function onCheck() {
|
|
||||||
if (checking) return;
|
|
||||||
const baseUrl = normalizeServerUrl(address);
|
|
||||||
if (!baseUrl) {
|
|
||||||
setState({ kind: 'invalid' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState({ kind: 'checking' });
|
|
||||||
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
|
||||||
setState({ kind: 'done', ...res });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.screen}>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.title}>{t('app.name')}</Text>
|
|
||||||
<Text style={styles.muted}>{t('app.tagline')}</Text>
|
|
||||||
|
|
||||||
<Text style={styles.label}>{t('server.address')}</Text>
|
|
||||||
<TextInput
|
|
||||||
value={address}
|
|
||||||
onChangeText={setAddress}
|
|
||||||
onSubmitEditing={onCheck}
|
|
||||||
editable={!checking}
|
|
||||||
placeholder="https://music.home.lan"
|
|
||||||
placeholderTextColor={colors.text3}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{state.kind === 'checking' && (
|
|
||||||
<View style={[styles.status, { backgroundColor: colors.surface2 }]}>
|
|
||||||
<ActivityIndicator color={colors.text2} />
|
|
||||||
<Text style={{ color: colors.text2 }}>{t('server.checking')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{state.kind === 'invalid' && <Status ok={false} text={t('server.invalid_address')} />}
|
|
||||||
{state.kind === 'done' && state.status === 'ok' && (
|
|
||||||
<Status ok text={t('server.online', { version: state.info.version })} />
|
|
||||||
)}
|
|
||||||
{state.kind === 'done' && state.status === 'unsupported_app' && (
|
|
||||||
<Status ok={false} text={t('server.unsupported_app')} />
|
|
||||||
)}
|
|
||||||
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={onCheck}
|
|
||||||
disabled={checking}
|
|
||||||
accessibilityState={{ disabled: checking, busy: checking }}
|
|
||||||
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>{t('server.check')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Status({ ok, text }: { ok: boolean; text: string }) {
|
|
||||||
return (
|
|
||||||
<View style={[styles.status, { backgroundColor: ok ? colors['ok-soft'] : colors['danger-soft'] }]}>
|
|
||||||
<Text style={{ color: ok ? colors.ok : colors.danger, fontWeight: '500' }}>{text}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 },
|
|
||||||
card: {
|
|
||||||
gap: 12,
|
|
||||||
padding: 24,
|
|
||||||
borderRadius: radius.xl,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.line2,
|
|
||||||
},
|
|
||||||
title: { color: colors.text, fontSize: 26, fontWeight: '600' },
|
|
||||||
muted: { color: colors.text2, lineHeight: 21 },
|
|
||||||
label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 },
|
|
||||||
input: {
|
|
||||||
height: 48,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.surface2,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.line2,
|
|
||||||
color: colors.text,
|
|
||||||
fontSize: 15,
|
|
||||||
},
|
|
||||||
status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 },
|
|
||||||
button: {
|
|
||||||
height: 48,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
backgroundColor: colors.accent,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useNetInfo } from '@react-native-community/netinfo';
|
||||||
|
import { Tabs, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||||
|
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
|
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
index: t('nav.home'),
|
||||||
|
search: t('mobile.search'),
|
||||||
|
collection: t('nav.collection'),
|
||||||
|
profile: t('mobile.profile'),
|
||||||
|
};
|
||||||
|
const net = useNetInfo();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||||
|
tabBar={({ state, navigation }) => (
|
||||||
|
<View>
|
||||||
|
<MiniPlayer />
|
||||||
|
<TabBar
|
||||||
|
routes={state.routes.map((r) => r.name)}
|
||||||
|
active={state.index}
|
||||||
|
labels={labels}
|
||||||
|
onPress={(name) => navigation.navigate(name)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tabs.Screen name="index" />
|
||||||
|
<Tabs.Screen name="search" />
|
||||||
|
<Tabs.Screen name="collection" />
|
||||||
|
<Tabs.Screen name="profile" />
|
||||||
|
</Tabs>
|
||||||
|
<UpdateDialog />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBar({
|
||||||
|
routes,
|
||||||
|
active,
|
||||||
|
labels,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
routes: string[];
|
||||||
|
active: number;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
onPress: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
accessibilityRole="tablist"
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
backgroundColor: colors.bg2,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{routes.map((name, i) => {
|
||||||
|
const on = i === active;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={name}
|
||||||
|
onPress={() => onPress(name)}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
accessibilityLabel={labels[name]}
|
||||||
|
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||||
|
>
|
||||||
|
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||||
|
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||||
|
{labels[name]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
|
||||||
|
function MiniPlayer() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const track = st.track;
|
||||||
|
if (!track) return null;
|
||||||
|
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/player')}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('mobile.open_player')}
|
||||||
|
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
|
||||||
|
>
|
||||||
|
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
|
||||||
|
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
|
||||||
|
<Cover album={track.album} size={42} />
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
|
||||||
|
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||||
|
<Icon name="skip-next" size={28} color={colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
|
||||||
|
function OfflineBanner({ text }: { text: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Icon name="cloud-off" size={16} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { unwrap, type Track } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { DownloadButton, useDownloads } from '../../ui/downloads';
|
||||||
|
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
|
||||||
|
|
||||||
|
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
|
||||||
|
|
||||||
|
export default function CollectionScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const [tab, setTab] = useState<Tab>('likes');
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['m-collection', tab],
|
||||||
|
enabled: tab !== 'downloads',
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const query = { query: { cursor: pageParam, limit: 100 } };
|
||||||
|
if (tab === 'history') {
|
||||||
|
const page = unwrap(await session.client.GET('/api/v1/me/history', { params: query }));
|
||||||
|
return { items: page.items.map((i) => ({ track: i.track, at: i.played_at })), next: page.next_cursor };
|
||||||
|
}
|
||||||
|
const path = tab === 'likes' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
|
||||||
|
const page = unwrap(await session.client.GET(path, { params: query }));
|
||||||
|
return { items: page.items.map((i) => ({ track: i.track, at: i.created_at })), next: page.next_cursor };
|
||||||
|
},
|
||||||
|
getNextPageParam: (p) => p.next ?? undefined,
|
||||||
|
});
|
||||||
|
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
/** Все лайки целиком, а не только загруженные страницы списка. */
|
||||||
|
const downloadAllLikes = async () => {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } }));
|
||||||
|
await downloads.enqueue(page.items.map((i) => i.track));
|
||||||
|
cursor = page.next_cursor ?? undefined;
|
||||||
|
} while (cursor);
|
||||||
|
};
|
||||||
|
const tracks: Track[] = items.map((i) => i.track);
|
||||||
|
const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const);
|
||||||
|
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||||
|
|
||||||
|
const tabs = (
|
||||||
|
<Segmented<Tab>
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
options={[
|
||||||
|
{ value: 'likes', label: t('collection.likes') },
|
||||||
|
{ value: 'history', label: t('collection.history') },
|
||||||
|
{ value: 'downloads', label: t('mobile.dl_title') },
|
||||||
|
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(it, i) => `${it.track.id}-${i}`}
|
||||||
|
onEndReached={() => list.hasNextPage && void list.fetchNextPage()}
|
||||||
|
onRefresh={() => void list.refetch()}
|
||||||
|
refreshing={list.isRefetching}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 14 }}>
|
||||||
|
<H1>{t('collection.title')}</H1>
|
||||||
|
{tabs}
|
||||||
|
{tab === 'likes' && tracks.length > 0 && (
|
||||||
|
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
|
||||||
|
)}
|
||||||
|
{tab === 'likes' && tracks.length > 0 && (
|
||||||
|
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item.track}
|
||||||
|
current={current?.id === item.track.id}
|
||||||
|
meta={tab === 'history' ? time.format(new Date(item.at)) : undefined}
|
||||||
|
onPress={() => void player.playList(tracks, index, context)}
|
||||||
|
onLike={tab === 'dislikes' ? undefined : () => void player.react(item.track, item.track.reaction === 'like' ? null : 'like').then(() => list.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
|
||||||
|
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dl = useDownloads();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
|
||||||
|
const items = [...dl.items.values()];
|
||||||
|
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
|
||||||
|
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
|
||||||
|
const failed = items.some((i) => i.status === 'error');
|
||||||
|
const context = { type: 'collection', id: 'downloads' } as const;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void downloads.usage().then(setUsage);
|
||||||
|
}, [done.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(it) => it.track.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 14 }}>
|
||||||
|
<H1>{t('collection.title')}</H1>
|
||||||
|
{tabs}
|
||||||
|
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
|
||||||
|
{pending > 0 && (
|
||||||
|
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
|
||||||
|
)}
|
||||||
|
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
|
||||||
|
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item.track}
|
||||||
|
current={current?.id === item.track.id}
|
||||||
|
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
|
||||||
|
onPress={() => {
|
||||||
|
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
|
||||||
|
}}
|
||||||
|
trailing={<DownloadButton track={item.track} size={20} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||||
|
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||||
|
|
||||||
|
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||||
|
function iconFor(name: string): IconName {
|
||||||
|
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
|
||||||
|
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главная: кнопка «Моя волна» (Skia, вид orb / liquid из профиля) и выбор занятия / настроения.
|
||||||
|
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||||
|
*/
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
||||||
|
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
|
||||||
|
const options = useQuery({
|
||||||
|
queryKey: ['wave-options'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const waveOn = st.wave !== null;
|
||||||
|
const playing = waveOn && st.playing;
|
||||||
|
const run = (fn: () => Promise<void>) => {
|
||||||
|
setError(null);
|
||||||
|
fn().catch(setError);
|
||||||
|
};
|
||||||
|
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
|
||||||
|
const choose = (kind: 'activity' | 'mood', key: string) => {
|
||||||
|
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
|
||||||
|
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
|
||||||
|
setActivity(a);
|
||||||
|
setMood(m);
|
||||||
|
if (waveOn) run(() => player.setWaveContext(a, m));
|
||||||
|
};
|
||||||
|
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
|
||||||
|
<WaveOrb
|
||||||
|
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||||
|
size={260}
|
||||||
|
playing={playing}
|
||||||
|
bpm={st.track?.features?.bpm ?? 100}
|
||||||
|
energy={st.track?.features?.energy ?? 0.6}
|
||||||
|
colors={waveOn ? st.track?.album.cover_colors : undefined}
|
||||||
|
onPress={onWave}
|
||||||
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||||
|
>
|
||||||
|
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
|
||||||
|
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
|
||||||
|
</WaveOrb>
|
||||||
|
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
|
||||||
|
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
|
||||||
|
{options.data && (
|
||||||
|
<>
|
||||||
|
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
|
||||||
|
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chips({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
options: { key: string; icon: string }[];
|
||||||
|
value: string | null;
|
||||||
|
label: (k: string) => string;
|
||||||
|
onPick: (k: string) => void;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const on = value === o.key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={o.key}
|
||||||
|
onPress={() => onPick(o.key)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: on }}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
height: 38,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: on ? colors.accent : colors.line,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
|
||||||
|
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Switch, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { playerCache } from '../../lib/prefs';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { updater } from '../../lib/update';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { radius, useColors } from '../../theme';
|
||||||
|
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||||
|
import { useDownloads } from '../../ui/downloads';
|
||||||
|
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||||
|
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
|
type UiPrefs = components['schemas']['UiPrefs'];
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const { server, disconnect } = useSessionCtx();
|
||||||
|
const staff = user.role !== 'user';
|
||||||
|
const update = useUpdateState();
|
||||||
|
const dl = useDownloads();
|
||||||
|
const quality = useQualityPrefs();
|
||||||
|
const [sheet, setSheet] = useState<'quality' | 'password' | null>(null);
|
||||||
|
const [cache, setCache] = useState<{ bytes: number; pending: boolean } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([playerCache.size(), playerCache.clearPending()]).then(([bytes, pending]) => setCache({ bytes, pending }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Оптимистично, как в вебе: настройка применяется сразу, сервер подтверждает.
|
||||||
|
const setPrefs = async (prefs: UiPrefs) => {
|
||||||
|
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
|
||||||
|
} catch {
|
||||||
|
session.setUser(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const clearCache = async () => {
|
||||||
|
await playerCache.requestClear();
|
||||||
|
setCache((c) => (c ? { ...c, pending: true } : c));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLocale = async (l: Locale) => {
|
||||||
|
await i18n.changeLanguage(l);
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||||
|
} catch {
|
||||||
|
// язык уже сменился локально
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await player.reset();
|
||||||
|
await session.logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
|
||||||
|
<H1>{user.display_name ?? user.username}</H1>
|
||||||
|
<Muted>
|
||||||
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
|
</Muted>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('language.label')}>
|
||||||
|
<Segmented<Locale>
|
||||||
|
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
|
||||||
|
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
||||||
|
onChange={(l) => void setLocale(l)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('settings.theme')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['theme']>>
|
||||||
|
value={user.ui_prefs.theme ?? 'system'}
|
||||||
|
options={[
|
||||||
|
{ value: 'dark', label: t('settings.theme_dark') },
|
||||||
|
{ value: 'light', label: t('settings.theme_light') },
|
||||||
|
{ value: 'system', label: t('settings.theme_system') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ theme: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('settings.wave_variant')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['wave_variant']>>
|
||||||
|
value={user.ui_prefs.wave_variant ?? 'orb'}
|
||||||
|
options={[
|
||||||
|
{ value: 'orb', label: t('settings.wave_orb') },
|
||||||
|
{ value: 'liquid', label: t('settings.wave_liquid') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ wave_variant: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.player_view')}>
|
||||||
|
<Segmented<NonNullable<UiPrefs['mobile_player']>>
|
||||||
|
value={user.ui_prefs.mobile_player ?? 'cover'}
|
||||||
|
options={[
|
||||||
|
{ value: 'cover', label: t('mobile.player_cover') },
|
||||||
|
{ value: 'wave', label: t('mobile.player_wave') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Pressable onPress={() => setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}>
|
||||||
|
<Row label={t('mobile.quality')}>
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Pressable>
|
||||||
|
<Row label={t('mobile.cache')}>
|
||||||
|
{cache?.pending ? (
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.cache_pending')}</Text>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={() => void clearCache()} accessibilityRole="button" hitSlop={8}>
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>
|
||||||
|
{t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.dl_wifi_only')}>
|
||||||
|
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
|
||||||
|
</Row>
|
||||||
|
<Row label={t('mobile.dl_auto_likes')}>
|
||||||
|
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Row label={t('mobile.server')}>
|
||||||
|
<Text style={{ color: colors.text2 }} numberOfLines={1}>
|
||||||
|
{server?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
{staff && server && (
|
||||||
|
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
|
||||||
|
<Icon name="open-in-new" size={20} color={colors.accent2} />
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Button title={t('settings.change_password')} variant="secondary" icon="lock" onPress={() => setSheet('password')} />
|
||||||
|
<Button
|
||||||
|
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
|
||||||
|
variant="secondary"
|
||||||
|
icon="system-update"
|
||||||
|
busy={update.status === 'checking'}
|
||||||
|
onPress={() => void updater.check(session, true)}
|
||||||
|
/>
|
||||||
|
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
|
||||||
|
|
||||||
|
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
|
||||||
|
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
|
||||||
|
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
|
||||||
|
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
<QualitySheet visible={sheet === 'quality'} onClose={() => setSheet(null)} />
|
||||||
|
<PasswordSheet visible={sheet === 'password'} onClose={() => setSheet(null)} />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ children }: { children: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<Text style={{ color: colors.text }}>{label}</Text>
|
||||||
|
<View style={{ flexShrink: 1 }}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { radius, useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
const RECENT_KEY = 'pipu.recent_searches';
|
||||||
|
|
||||||
|
/** Поиск: подсказки по мере ввода (debounce 250 мс), недавние запросы — локально. */
|
||||||
|
export default function SearchScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [debounced, setDebounced] = useState('');
|
||||||
|
const [recent, setRecent] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void SecureStore.getItemAsync(RECENT_KEY).then((v) => setRecent(v ? (JSON.parse(v) as string[]) : []));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(q.trim()), 250);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const remember = (query: string) => {
|
||||||
|
const next = [query, ...recent.filter((r) => r !== query)].slice(0, 10);
|
||||||
|
setRecent(next);
|
||||||
|
void SecureStore.setItemAsync(RECENT_KEY, JSON.stringify(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = useQuery({
|
||||||
|
queryKey: ['m-search', debounced],
|
||||||
|
enabled: debounced !== '',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async ({ signal }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/search', { params: { query: { q: debounced } }, signal })),
|
||||||
|
});
|
||||||
|
const data = debounced ? res.data : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View style={{ padding: 16, gap: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, height: 46, paddingHorizontal: 12, borderRadius: radius.md, backgroundColor: colors.surface2 }}>
|
||||||
|
<Icon name="search" color={colors.text3} />
|
||||||
|
<TextInput
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
onSubmitEditing={() => q.trim() && remember(q.trim())}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
placeholderTextColor={colors.text3}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
accessibilityLabel={t('search.label')}
|
||||||
|
style={{ flex: 1, color: colors.text, fontSize: 16 }}
|
||||||
|
returnKeyType="search"
|
||||||
|
/>
|
||||||
|
{!!q && (
|
||||||
|
<Pressable onPress={() => setQ('')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="close" color={colors.text3} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{data?.corrected_query && <Text style={{ color: colors.warn, fontSize: 13 }}>{t('search.corrected', { query: data.corrected_query })}</Text>}
|
||||||
|
{res.error && <Text style={{ color: colors.danger }}>{errorText(res.error)}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!debounced ? (
|
||||||
|
<View style={{ paddingHorizontal: 16, gap: 4 }}>
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>{t('mobile.recent_searches')}</Text>
|
||||||
|
<Pressable onPress={() => { setRecent([]); void SecureStore.deleteItemAsync(RECENT_KEY); }} accessibilityRole="button">
|
||||||
|
<Text style={{ color: colors.accent2 }}>{t('mobile.clear_recent')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{recent.map((r) => (
|
||||||
|
<Pressable key={r} onPress={() => setQ(r)} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }}>
|
||||||
|
<Icon name="history" size={20} color={colors.text3} />
|
||||||
|
<Text style={{ color: colors.text }}>{r}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={data?.tracks ?? []}
|
||||||
|
keyExtractor={(tr) => tr.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
{data?.artists.map((a) => (
|
||||||
|
<Pressable key={a.id} onPress={() => { remember(debounced); router.push(`/artist/${a.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||||
|
<Cover album={{ id: a.id }} size={44} round />
|
||||||
|
<View>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{a.name}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('search.artist_kind')}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{data?.albums.map((al) => (
|
||||||
|
<Pressable key={al.id} onPress={() => { remember(debounced); router.push(`/album/${al.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||||
|
<Cover album={al} size={44} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{al.title}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{[al.artist.name, al.year].filter(Boolean).join(' · ')}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
res.isSuccess && !data?.artists.length && !data?.albums.length ? (
|
||||||
|
<Text style={{ color: colors.text3, textAlign: 'center', marginTop: 24 }}>{t('search.nothing')}</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
current={current?.id === item.id}
|
||||||
|
onPress={() => {
|
||||||
|
remember(debounced);
|
||||||
|
void player.playList(data?.tracks ?? [], index, { type: 'search', id: debounced });
|
||||||
|
}}
|
||||||
|
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => res.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import type { i18n as I18n } from 'i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, AppState, useColorScheme, View } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
|
||||||
|
import { downloads } from '../offline/downloads';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { ThemeContext, useColors } from '../theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [i18n, setI18n] = useState<I18n | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||||
|
void createI18n(pickLocale([deviceLocale])).then(setI18n);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!i18n) return <Splash />;
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>
|
||||||
|
<Gate />
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Splash() {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
|
||||||
|
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
|
||||||
|
*/
|
||||||
|
function Gate() {
|
||||||
|
const { server, session, ready } = useSessionCtx();
|
||||||
|
const auth = useAuthState();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const user = auth.status === 'signed_in' ? auth.user : null;
|
||||||
|
const system = useColorScheme();
|
||||||
|
// Тема из профиля; «system» и экраны до входа — по настройке телефона.
|
||||||
|
const pref = user?.ui_prefs.theme ?? 'system';
|
||||||
|
const theme = pref === 'system' ? (system === 'light' ? 'light' : 'dark') : pref;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
player.attach(user ? session : null, server ?? '', user?.id ?? null);
|
||||||
|
void downloads.attach(user ? session : null, server ?? '', user?.id ?? null);
|
||||||
|
if (user) void player.restore();
|
||||||
|
}, [user?.id, session, server]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
|
||||||
|
}, [user?.locale, i18n]);
|
||||||
|
|
||||||
|
// Свёрнутое приложение досылает события и позицию.
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const first = segments[0] as string | undefined;
|
||||||
|
let target: string | null = null;
|
||||||
|
if (!server) target = first === 'connect' ? null : '/connect';
|
||||||
|
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
|
||||||
|
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
|
||||||
|
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
|
||||||
|
if (target) router.replace(target as never);
|
||||||
|
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
|
||||||
|
|
||||||
|
if (!ready) return <Splash />;
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={theme}>
|
||||||
|
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
|
||||||
|
<Screens />
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Screens() {
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
||||||
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { downloads } from '../../offline/downloads';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, Cover, Icon, Screen, TrackRow, formatDuration, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
export default function AlbumScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
const current = usePlayerState().track;
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const album = useQuery({
|
||||||
|
queryKey: ['album', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const a = album.data;
|
||||||
|
const ctx = { type: 'album' as const, id };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={a?.tracks ?? []}
|
||||||
|
keyExtractor={(tr) => tr.id}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 12, alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="arrow-back" color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
{album.error ? <Text style={{ color: colors.danger }}>{errorText(album.error)}</Text> : null}
|
||||||
|
{a && (
|
||||||
|
<>
|
||||||
|
<Cover album={a} size={220} />
|
||||||
|
<Text style={{ color: colors.text, fontSize: 22, fontWeight: '700', textAlign: 'center' }}>{a.title}</Text>
|
||||||
|
<Pressable onPress={() => router.push(`/artist/${a.artist.id}`)}>
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{a.artist.name}
|
||||||
|
{a.year ? ` · ${a.year}` : ''} · {formatDuration(a.duration_ms)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Button title={t('player.play')} icon="play-arrow" onPress={() => void player.playList(a.tracks, 0, ctx)} />
|
||||||
|
<Button title={t('mobile.dl_album')} variant="secondary" icon="download" onPress={() => void downloads.enqueue(a.tracks)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TrackRow
|
||||||
|
track={item}
|
||||||
|
numbered={item.track_no ?? index + 1}
|
||||||
|
current={current?.id === item.id}
|
||||||
|
onPress={() => void player.playList(a?.tracks ?? [], index, ctx)}
|
||||||
|
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => album.refetch())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession } from '../../lib/session';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Cover, Icon, Screen } from '../../ui/kit';
|
||||||
|
|
||||||
|
export default function ArtistScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession();
|
||||||
|
const artist = useQuery({
|
||||||
|
queryKey: ['artist', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const albums = useQuery({
|
||||||
|
queryKey: ['artist-albums', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<FlatList
|
||||||
|
data={albums.data ?? []}
|
||||||
|
keyExtractor={(a) => a.id}
|
||||||
|
numColumns={2}
|
||||||
|
columnWrapperStyle={{ gap: 12, paddingHorizontal: 16 }}
|
||||||
|
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={{ padding: 16, gap: 10, alignItems: 'center' }}>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="arrow-back" color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Cover album={{ id }} size={140} round />
|
||||||
|
<Text style={{ color: colors.text, fontSize: 24, fontWeight: '700' }}>{artist.data?.name ?? '…'}</Text>
|
||||||
|
{artist.data && (
|
||||||
|
<Text style={{ color: colors.text2 }}>
|
||||||
|
{t('catalog.album_count', { count: artist.data.album_count })} · {t('catalog.track_count', { count: artist.data.track_count })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable onPress={() => router.push(`/album/${item.id}`)} style={{ flex: 1, gap: 6 }} accessibilityRole="button">
|
||||||
|
<Cover album={item} size={160} />
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{item.title}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{item.year ?? ''}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { createApiClient } from '@pipu/api-client';
|
||||||
|
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { forgetServer, loadServers } from '../lib/storage';
|
||||||
|
import { radius, useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Icon, Muted, Screen } from '../ui/kit';
|
||||||
|
|
||||||
|
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
||||||
|
|
||||||
|
/** Подключение: адрес → проверка /server-info (версия, совместимость) → вход. */
|
||||||
|
export default function ConnectScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { connect } = useSessionCtx();
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [result, setResult] = useState<ServerCheck | 'invalid' | null>(null);
|
||||||
|
const servers = useQuery({ queryKey: ['saved-servers'], queryFn: loadServers });
|
||||||
|
|
||||||
|
const onConnect = async (raw = address) => {
|
||||||
|
const baseUrl = normalizeServerUrl(raw);
|
||||||
|
if (!baseUrl) return setResult('invalid');
|
||||||
|
setChecking(true);
|
||||||
|
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
||||||
|
setChecking(false);
|
||||||
|
setResult(res);
|
||||||
|
if (res.status === 'ok') await connect(baseUrl, res.info.version);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t('app.name')}</H1>
|
||||||
|
<Muted>{t('app.tagline')}</Muted>
|
||||||
|
<Field
|
||||||
|
label={t('server.address')}
|
||||||
|
value={address}
|
||||||
|
onChangeText={setAddress}
|
||||||
|
onSubmitEditing={() => void onConnect()}
|
||||||
|
placeholder="https://music.home.lan"
|
||||||
|
keyboardType="url"
|
||||||
|
editable={!checking}
|
||||||
|
/>
|
||||||
|
{result === 'invalid' && <ErrorBox>{t('server.invalid_address')}</ErrorBox>}
|
||||||
|
{result !== null && result !== 'invalid' && result.status === 'unsupported_app' && (
|
||||||
|
<ErrorBox>{t('server.unsupported_app')}</ErrorBox>
|
||||||
|
)}
|
||||||
|
{result !== null && result !== 'invalid' && result.status === 'offline' && <ErrorBox>{t('server.offline')}</ErrorBox>}
|
||||||
|
<Button title={t('mobile.connect')} onPress={() => void onConnect()} busy={checking} disabled={!address.trim()} />
|
||||||
|
|
||||||
|
{!!servers.data?.length && (
|
||||||
|
<View style={{ gap: 8, marginTop: 16 }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>
|
||||||
|
{t('mobile.servers')}
|
||||||
|
</Text>
|
||||||
|
{servers.data.map((sv) => (
|
||||||
|
<View key={sv.url} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderRadius: radius.md, backgroundColor: colors.surface }}>
|
||||||
|
<Pressable style={{ flex: 1 }} onPress={() => void onConnect(sv.url)} accessibilityRole="button">
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{sv.url.replace(/^https?:\/\//, '')}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>pipu {sv.version}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void forgetServer(sv.url).then(() => servers.refetch())}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('mobile.forget_server')}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={20} color={colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const { server, session, disconnect } = useSessionCtx();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!session) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await session.login(username.trim(), password); // переход сделает Gate
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
setPassword('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t('login.title')}</H1>
|
||||||
|
<Muted>{server?.replace(/^https?:\/\//, '')}</Muted>
|
||||||
|
<Field label={t('login.username')} value={username} onChangeText={setUsername} autoComplete="username" />
|
||||||
|
<Field
|
||||||
|
label={t('login.password')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secure
|
||||||
|
autoComplete="password"
|
||||||
|
onSubmitEditing={() => void submit()}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button title={t('login.submit')} onPress={() => void submit()} busy={busy} disabled={!username.trim() || !password} />
|
||||||
|
<Button title={t('common:mobile.change_server')} variant="ghost" onPress={() => void disconnect()} />
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
|
||||||
|
import { useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { usePlayerState } from '../player/hooks';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { DownloadButton } from '../ui/downloads';
|
||||||
|
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
|
||||||
|
import { WaveOrb } from '../ui/wave/WaveOrb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полноэкранный плеер: вид `cover` (крупная обложка) или `wave` (визуализация волны в цветах обложки) — по профилю;
|
||||||
|
* прогресс с перемоткой, лайк / дизлайк, скачать, управление.
|
||||||
|
*/
|
||||||
|
export default function PlayerScreen() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const prefs = useUser().ui_prefs;
|
||||||
|
const [barWidth, setBarWidth] = useState(1);
|
||||||
|
const track = st.track;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||||
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const duration = st.durationMs || track.duration_ms;
|
||||||
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||||
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||||
|
<DownloadButton track={track} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
{prefs.mobile_player === 'wave' ? (
|
||||||
|
<WaveOrb
|
||||||
|
variant={prefs.wave_variant ?? 'orb'}
|
||||||
|
size={280}
|
||||||
|
playing={st.playing}
|
||||||
|
bpm={track.features?.bpm ?? 100}
|
||||||
|
energy={track.features?.energy ?? 0.6}
|
||||||
|
colors={track.album.cover_colors}
|
||||||
|
>
|
||||||
|
<Cover album={track.album} size={96} round />
|
||||||
|
</WaveOrb>
|
||||||
|
) : (
|
||||||
|
<Cover album={track.album} size={300} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Pressable
|
||||||
|
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
||||||
|
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||||
|
accessibilityRole="adjustable"
|
||||||
|
accessibilityLabel={t('player.progress')}
|
||||||
|
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
||||||
|
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
||||||
|
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
|
||||||
|
style={{ height: 24, justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
|
||||||
|
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||||
|
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||||
|
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => player.toggle()}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
|
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||||
|
<Icon name="skip-next" size={40} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||||
|
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../lib/session';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
/** Обязательный шаг: новые логин и пароль админа после первого запуска или замена временного пароля. */
|
||||||
|
export default function SetupScreen() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const admin = user.must_change_credentials;
|
||||||
|
const min = user.role === 'user' ? 8 : 12;
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const mismatch = repeat !== '' && repeat !== password;
|
||||||
|
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = admin
|
||||||
|
? await session.client.POST('/api/v1/me/bootstrap-credentials', { body: { new_username: username.trim(), new_password: password } })
|
||||||
|
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
|
||||||
|
await session.apply(unwrap(res));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
||||||
|
<H1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</H1>
|
||||||
|
<Muted>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</Muted>
|
||||||
|
{admin && <Field label={t('setup.new_username')} value={username} onChangeText={setUsername} error={fieldErr('new_username')} />}
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_password')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secure
|
||||||
|
hint={t('setup.min_length', { min })}
|
||||||
|
error={fieldErr('new_password')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('setup.repeat_password')}
|
||||||
|
value={repeat}
|
||||||
|
onChangeText={setRepeat}
|
||||||
|
secure
|
||||||
|
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error && !fieldErr('new_password') && !fieldErr('new_username') ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button
|
||||||
|
title={t('setup.submit')}
|
||||||
|
onPress={() => void submit()}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!password || password !== repeat || (admin && !username.trim())}
|
||||||
|
/>
|
||||||
|
<Button title={t('setup.logout')} variant="ghost" onPress={() => void session.logout()} />
|
||||||
|
</ScrollView>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
export type Quality = 'original' | 'high' | 'medium' | 'low';
|
||||||
|
export const QUALITIES: readonly Quality[] = ['original', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
/** Качество звука по типу сети и для загрузок (настройка устройства, на сервер не уходит). */
|
||||||
|
export interface QualityPrefs {
|
||||||
|
wifi: Quality;
|
||||||
|
cellular: Quality;
|
||||||
|
download: Quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUALITY_KEY = 'pipu.quality';
|
||||||
|
const CLEAR_CACHE_KEY = 'pipu.cache.clear';
|
||||||
|
const DEFAULT_QUALITY: QualityPrefs = { wifi: 'high', cellular: 'medium', download: 'high' };
|
||||||
|
|
||||||
|
let quality: QualityPrefs = DEFAULT_QUALITY;
|
||||||
|
let loaded: Promise<void> | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export const qualityPrefs = {
|
||||||
|
get: () => quality,
|
||||||
|
subscribe(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
async load(): Promise<QualityPrefs> {
|
||||||
|
loaded ??= SecureStore.getItemAsync(QUALITY_KEY)
|
||||||
|
.then((raw) => {
|
||||||
|
if (raw) quality = { ...DEFAULT_QUALITY, ...(JSON.parse(raw) as Partial<QualityPrefs>) };
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
await loaded;
|
||||||
|
return quality;
|
||||||
|
},
|
||||||
|
async set(patch: Partial<QualityPrefs>) {
|
||||||
|
quality = { ...quality, ...patch };
|
||||||
|
for (const fn of listeners) fn();
|
||||||
|
await SecureStore.setItemAsync(QUALITY_KEY, JSON.stringify(quality));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш недавних стримов ExoPlayer (KotlinAudio кладёт его в `cacheDir/TrackPlayer`). Пока сервис плеера жив,
|
||||||
|
* SimpleCache держит индекс в памяти — поэтому очистка откладывается до следующего холодного запуска.
|
||||||
|
*/
|
||||||
|
const PLAYER_CACHE = `${FileSystem.cacheDirectory}TrackPlayer`;
|
||||||
|
|
||||||
|
export const playerCache = {
|
||||||
|
async size(): Promise<number> {
|
||||||
|
const info = await FileSystem.getInfoAsync(PLAYER_CACHE, { size: true }).catch(() => null);
|
||||||
|
return info?.exists ? (info.size ?? 0) : 0;
|
||||||
|
},
|
||||||
|
async requestClear() {
|
||||||
|
await SecureStore.setItemAsync(CLEAR_CACHE_KEY, '1');
|
||||||
|
},
|
||||||
|
async clearPending(): Promise<boolean> {
|
||||||
|
return (await SecureStore.getItemAsync(CLEAR_CACHE_KEY)) === '1';
|
||||||
|
},
|
||||||
|
/** Вызывается до setupPlayer, когда сервис ещё не запущен. */
|
||||||
|
async clearIfRequested() {
|
||||||
|
if (!(await this.clearPending())) return;
|
||||||
|
await FileSystem.deleteAsync(PLAYER_CACHE, { idempotent: true }).catch(() => undefined);
|
||||||
|
await SecureStore.deleteItemAsync(CLEAR_CACHE_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { AuthSession, type AuthState } from '@pipu/core';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { currentServer, refreshStoreFor, rememberServer, setCurrentServer } from './storage';
|
||||||
|
|
||||||
|
interface SessionCtx {
|
||||||
|
/** Адрес сервера; null — сервер не выбран (экран подключения). */
|
||||||
|
server: string | null;
|
||||||
|
session: AuthSession | null;
|
||||||
|
ready: boolean;
|
||||||
|
connect: (url: string, version: string) => Promise<void>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<SessionCtx | null>(null);
|
||||||
|
|
||||||
|
function deviceName(): string {
|
||||||
|
return Constants.deviceName ?? 'Android';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSession(url: string) {
|
||||||
|
return new AuthSession({ baseUrl: url, clientType: 'android', deviceName: deviceName(), refreshStore: refreshStoreFor(url) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий сервер и сессия на нём; при запуске восстанавливает вход по сохранённому refresh-токену. */
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [server, setServer] = useState<string | null>(null);
|
||||||
|
const [session, setSession] = useState<AuthSession | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const url = await currentServer();
|
||||||
|
if (url) {
|
||||||
|
const s = makeSession(url);
|
||||||
|
await s.restore();
|
||||||
|
setServer(url);
|
||||||
|
setSession(s);
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async (url: string, version: string) => {
|
||||||
|
await rememberServer({ url, version });
|
||||||
|
await setCurrentServer(url);
|
||||||
|
const s = makeSession(url);
|
||||||
|
await s.restore(); // на этом сервере уже могли войти раньше
|
||||||
|
setServer(url);
|
||||||
|
setSession(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(async () => {
|
||||||
|
await setCurrentServer(null);
|
||||||
|
setServer(null);
|
||||||
|
setSession(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ server, session, ready, connect, disconnect }), [server, session, ready, connect, disconnect]);
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionCtx(): SessionCtx {
|
||||||
|
const c = useContext(Ctx);
|
||||||
|
if (!c) throw new Error('useSessionCtx outside SessionProvider');
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сессия вошедшего пользователя (экраны внутри приложения). */
|
||||||
|
export function useSession(): AuthSession {
|
||||||
|
const { session } = useSessionCtx();
|
||||||
|
if (!session) throw new Error('no session');
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNKNOWN: AuthState = { status: 'unknown' };
|
||||||
|
|
||||||
|
export function useAuthState(): AuthState {
|
||||||
|
const { session } = useSessionCtx();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(fn) => session?.subscribe(fn) ?? (() => undefined),
|
||||||
|
() => session?.state ?? UNKNOWN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
const state = useAuthState();
|
||||||
|
if (state.status !== 'signed_in') throw new Error('not signed in');
|
||||||
|
return state.user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
// Всё храним в SecureStore (Android Keystore): список серверов маленький, а refresh-токен — секрет.
|
||||||
|
const SERVERS_KEY = 'pipu.servers';
|
||||||
|
const CURRENT_KEY = 'pipu.server';
|
||||||
|
|
||||||
|
export interface SavedServer {
|
||||||
|
url: string;
|
||||||
|
version: string;
|
||||||
|
/** Последний вошедший логин — подставляется в форму входа. */
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключи SecureStore — только [A-Za-z0-9._-]: адрес сервера превращаем в безопасный суффикс. */
|
||||||
|
function keyFor(url: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of url) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
return `pipu.rt.${h.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadServers(): Promise<SavedServer[]> {
|
||||||
|
try {
|
||||||
|
const raw = await SecureStore.getItemAsync(SERVERS_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as SavedServer[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохраняет сервер наверх списка (последний использованный — первый). */
|
||||||
|
export async function rememberServer(server: SavedServer): Promise<void> {
|
||||||
|
const list = (await loadServers()).filter((s) => s.url !== server.url);
|
||||||
|
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify([server, ...list].slice(0, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgetServer(url: string): Promise<void> {
|
||||||
|
const list = (await loadServers()).filter((s) => s.url !== url);
|
||||||
|
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify(list));
|
||||||
|
await refreshStoreFor(url).clear();
|
||||||
|
if ((await currentServer()) === url) await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function currentServer(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCurrentServer(url: string | null): Promise<void> {
|
||||||
|
if (url) await SecureStore.setItemAsync(CURRENT_KEY, url);
|
||||||
|
else await SecureStore.deleteItemAsync(CURRENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh-токен сервера (у каждого сервера — свой) и последний пользователь — для запуска без сети. */
|
||||||
|
export function refreshStoreFor(url: string): RefreshStore {
|
||||||
|
const key = keyFor(url);
|
||||||
|
const userKey = key.replace('pipu.rt.', 'pipu.user.');
|
||||||
|
return {
|
||||||
|
get: () => SecureStore.getItemAsync(key),
|
||||||
|
set: (token) => SecureStore.setItemAsync(key, token),
|
||||||
|
clear: async () => {
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
await SecureStore.deleteItemAsync(userKey);
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
try {
|
||||||
|
const raw = await SecureStore.getItemAsync(userKey);
|
||||||
|
return raw ? (JSON.parse(raw) as AuthUser) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setUser: (user) => SecureStore.setItemAsync(userKey, JSON.stringify(user)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Короткий стабильный хэш строки — для имён файлов и ключей. */
|
||||||
|
export function shortHash(s: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
return h.toString(36);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { unwrap, type AuthSession } from '@pipu/core';
|
||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as IntentLauncher from 'expo-intent-launcher';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { sha256 } from 'js-sha256';
|
||||||
|
|
||||||
|
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||||
|
|
||||||
|
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||||
|
export type UpdateState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'checking' }
|
||||||
|
| { status: 'latest' }
|
||||||
|
| {
|
||||||
|
status: 'available';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
dismissed: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'downloading';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
| { status: 'verifying'; release: AndroidRelease; required: boolean }
|
||||||
|
| {
|
||||||
|
status: 'ready';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'error';
|
||||||
|
release: AndroidRelease;
|
||||||
|
required: boolean;
|
||||||
|
error: 'bad_checksum' | 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const CHECKED_KEY = 'pipu.update.checked';
|
||||||
|
const HASH_CHUNK = 1 << 20;
|
||||||
|
const APK_MIME = 'application/vnd.android.package-archive';
|
||||||
|
|
||||||
|
export function installedVersionCode(): number {
|
||||||
|
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
|
||||||
|
async function fileSha256(uri: string, size: number): Promise<string> {
|
||||||
|
const hash = sha256.create();
|
||||||
|
for (let pos = 0; pos < size; pos += HASH_CHUNK) {
|
||||||
|
const b64 = await FileSystem.readAsStringAsync(uri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
position: pos,
|
||||||
|
length: Math.min(HASH_CHUNK, size - pos),
|
||||||
|
});
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
hash.update(bytes);
|
||||||
|
}
|
||||||
|
return hash.hex();
|
||||||
|
}
|
||||||
|
|
||||||
|
class Updater {
|
||||||
|
private current: UpdateState = { status: 'idle' };
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private download: FileSystem.DownloadResumable | null = null;
|
||||||
|
|
||||||
|
get state(): UpdateState {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
private set(s: UpdateState) {
|
||||||
|
this.current = s;
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
|
||||||
|
async check(session: AuthSession, manual = false): Promise<void> {
|
||||||
|
if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return;
|
||||||
|
if (!manual) {
|
||||||
|
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
|
||||||
|
if (Date.now() - last < CHECK_EVERY_MS) return;
|
||||||
|
}
|
||||||
|
if (manual) this.set({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||||
|
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||||
|
const installed = installedVersionCode();
|
||||||
|
if (release.version_code > installed) {
|
||||||
|
this.set({
|
||||||
|
status: 'available',
|
||||||
|
release,
|
||||||
|
required: installed < release.min_supported,
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
|
||||||
|
this.set(manual ? { status: 'latest' } : { status: 'idle' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
const s = this.current;
|
||||||
|
if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true });
|
||||||
|
else if (s.status === 'latest') this.set({ status: 'idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
|
||||||
|
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
|
||||||
|
const s = this.current;
|
||||||
|
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
|
||||||
|
const { release, required } = s;
|
||||||
|
if (s.status === 'ready') return this.install(s.file);
|
||||||
|
|
||||||
|
// Любой запрос через client обновит access-токен, если он истёк.
|
||||||
|
await session.client.GET('/api/v1/app/android/latest').catch(() => undefined);
|
||||||
|
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
|
||||||
|
this.set({ status: 'downloading', release, required, progress: 0 });
|
||||||
|
try {
|
||||||
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||||
|
this.download = FileSystem.createDownloadResumable(
|
||||||
|
server + release.download_url,
|
||||||
|
file,
|
||||||
|
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
|
||||||
|
(p) => {
|
||||||
|
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
|
||||||
|
this.set({
|
||||||
|
status: 'downloading',
|
||||||
|
release,
|
||||||
|
required,
|
||||||
|
progress: total ? p.totalBytesWritten / total : 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = await this.download.downloadAsync();
|
||||||
|
this.download = null;
|
||||||
|
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
|
||||||
|
|
||||||
|
this.set({ status: 'verifying', release, required });
|
||||||
|
const info = await FileSystem.getInfoAsync(file);
|
||||||
|
const size = info.exists ? info.size : 0;
|
||||||
|
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
|
||||||
|
await FileSystem.deleteAsync(file, { idempotent: true });
|
||||||
|
this.set({ status: 'error', release, required, error: 'bad_checksum' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.set({ status: 'ready', release, required, file });
|
||||||
|
await this.install(file);
|
||||||
|
} catch {
|
||||||
|
this.download = null;
|
||||||
|
this.set({ status: 'error', release, required, error: 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async install(file: string) {
|
||||||
|
const uri = await FileSystem.getContentUriAsync(file);
|
||||||
|
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||||
|
data: uri,
|
||||||
|
type: APK_MIME,
|
||||||
|
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экран «Установка неизвестных приложений» для нашего пакета. */
|
||||||
|
async openInstallSettings() {
|
||||||
|
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
|
||||||
|
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updater = new Updater();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as SQLite from 'expo-sqlite';
|
||||||
|
|
||||||
|
let db: Promise<SQLite.SQLiteDatabase> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная база офлайн-режима (docs/mobile.md#офлайн). `owner` — хэш «сервер + пользователь»:
|
||||||
|
* скачанное, неотправленные события и реакции одного аккаунта не видны другому.
|
||||||
|
*/
|
||||||
|
export function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||||
|
db ??= (async () => {
|
||||||
|
const d = await SQLite.openDatabaseAsync('pipu.db');
|
||||||
|
await d.execAsync(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS downloads (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
track TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (owner, track_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_reactions (
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
reaction TEXT,
|
||||||
|
PRIMARY KEY (owner, track_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
return d;
|
||||||
|
})();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
||||||
|
import { unwrap, type AuthSession, type Track } from '@pipu/core';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
import { qualityPrefs } from '../lib/prefs';
|
||||||
|
import { shortHash } from '../lib/storage';
|
||||||
|
import { getDb } from './db';
|
||||||
|
|
||||||
|
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error';
|
||||||
|
|
||||||
|
export interface DownloadItem {
|
||||||
|
track: Track;
|
||||||
|
status: DownloadStatus;
|
||||||
|
progress: number;
|
||||||
|
bytes: number;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadsState {
|
||||||
|
items: ReadonlyMap<string, DownloadItem>;
|
||||||
|
wifiOnly: boolean;
|
||||||
|
autoLikes: boolean;
|
||||||
|
/** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */
|
||||||
|
allowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARALLEL = 2;
|
||||||
|
const WIFI_KEY = 'pipu.dl.wifi';
|
||||||
|
const AUTO_LIKES_KEY = 'pipu.dl.autolikes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузки для офлайна: файлы — в documentDirectory/offline/<owner>/, метаданные (DTO трека) — в SQLite.
|
||||||
|
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
|
||||||
|
*/
|
||||||
|
class Downloads {
|
||||||
|
private session: AuthSession | null = null;
|
||||||
|
private server = '';
|
||||||
|
private owner = '';
|
||||||
|
private active = new Map<string, FileSystem.DownloadResumable | null>();
|
||||||
|
private net: NetInfoState | null = null;
|
||||||
|
private unsubscribeNet: (() => void) | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false };
|
||||||
|
|
||||||
|
get state(): DownloadsState {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
private set(patch: Partial<DownloadsState>) {
|
||||||
|
this.current = { ...this.current, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setItem(id: string, patch: Partial<DownloadItem> | null) {
|
||||||
|
const items = new Map(this.current.items);
|
||||||
|
if (patch === null) items.delete(id);
|
||||||
|
else {
|
||||||
|
const prev = items.get(id);
|
||||||
|
if (!prev && !patch.track) return;
|
||||||
|
items.set(id, { ...(prev as DownloadItem), ...patch });
|
||||||
|
}
|
||||||
|
this.set({ items });
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dir() {
|
||||||
|
return `${FileSystem.documentDirectory}offline/${this.owner}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private file(trackId: string) {
|
||||||
|
return `${this.dir}${trackId}.opus`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cover(albumId: string) {
|
||||||
|
return `${this.dir}covers/${albumId}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */
|
||||||
|
async attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||||
|
const owner = session && userId ? shortHash(`${server}|${userId}`) : '';
|
||||||
|
if (owner === this.owner && session === this.session) return;
|
||||||
|
for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined);
|
||||||
|
this.active.clear();
|
||||||
|
this.session = session;
|
||||||
|
this.server = server;
|
||||||
|
this.owner = owner;
|
||||||
|
if (!owner) {
|
||||||
|
this.set({ items: new Map() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]);
|
||||||
|
await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined);
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner);
|
||||||
|
const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>(
|
||||||
|
'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at',
|
||||||
|
owner,
|
||||||
|
);
|
||||||
|
const items = new Map<string, DownloadItem>();
|
||||||
|
for (const r of rows) {
|
||||||
|
items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at });
|
||||||
|
}
|
||||||
|
this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' });
|
||||||
|
|
||||||
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
|
this.net = st;
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
this.net = await NetInfo.fetch();
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
private networkAllowed(): boolean {
|
||||||
|
if (!this.net?.isConnected) return false;
|
||||||
|
return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWifiOnly(v: boolean) {
|
||||||
|
await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0');
|
||||||
|
this.set({ wifiOnly: v });
|
||||||
|
this.set({ allowed: this.networkAllowed() });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAutoLikes(v: boolean) {
|
||||||
|
await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0');
|
||||||
|
this.set({ autoLikes: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloaded(trackId: string): boolean {
|
||||||
|
return this.current.items.get(trackId)?.status === 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальный файл трека, если он скачан. */
|
||||||
|
localTrack(trackId: string): string | undefined {
|
||||||
|
return this.isDownloaded(trackId) ? this.file(trackId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
localCover(track: Track): string | undefined {
|
||||||
|
return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обложка альбома на устройстве, если из него что-то скачано. */
|
||||||
|
localAlbumCover(albumId: string): string | undefined {
|
||||||
|
for (const it of this.current.items.values()) {
|
||||||
|
if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачанные треки в порядке добавления. */
|
||||||
|
downloaded(): Track[] {
|
||||||
|
return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(tracks: Track[]) {
|
||||||
|
if (!this.owner) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [n, t] of tracks.entries()) {
|
||||||
|
if (!t.available || this.current.items.has(t.id)) continue;
|
||||||
|
const addedAt = now + n;
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`,
|
||||||
|
this.owner,
|
||||||
|
t.id,
|
||||||
|
JSON.stringify(t),
|
||||||
|
addedAt,
|
||||||
|
);
|
||||||
|
this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt });
|
||||||
|
}
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(trackIds: string[]) {
|
||||||
|
const db = await getDb();
|
||||||
|
for (const id of trackIds) {
|
||||||
|
const d = this.active.get(id);
|
||||||
|
this.active.delete(id);
|
||||||
|
await d?.pauseAsync().catch(() => undefined);
|
||||||
|
await FileSystem.deleteAsync(this.file(id), { idempotent: true });
|
||||||
|
await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true });
|
||||||
|
await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id);
|
||||||
|
this.setItem(id, null);
|
||||||
|
}
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция в сохранённом DTO: офлайн-волна и списки скачанного видят её без сети. */
|
||||||
|
async setReaction(trackId: string, reaction: Track['reaction']) {
|
||||||
|
const it = this.current.items.get(trackId);
|
||||||
|
if (!it || it.track.reaction === reaction) return;
|
||||||
|
const track = { ...it.track, reaction };
|
||||||
|
this.setItem(trackId, { track });
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync('UPDATE downloads SET track = ? WHERE owner = ? AND track_id = ?', JSON.stringify(track), this.owner, trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Повторить загрузки с ошибкой. */
|
||||||
|
async retry() {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner);
|
||||||
|
for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 });
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Занято на устройстве и свободно в телефоне (байты). */
|
||||||
|
async usage(): Promise<{ used: number; free: number }> {
|
||||||
|
let used = 0;
|
||||||
|
for (const it of this.current.items.values()) used += it.bytes;
|
||||||
|
const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0);
|
||||||
|
return { used, free };
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump() {
|
||||||
|
if (!this.owner || !this.current.allowed) return;
|
||||||
|
for (const [id, it] of this.current.items) {
|
||||||
|
if (this.active.size >= PARALLEL) return;
|
||||||
|
if (it.status !== 'queued' || this.active.has(id)) continue;
|
||||||
|
this.active.set(id, null);
|
||||||
|
void this.run(id).finally(() => {
|
||||||
|
this.active.delete(id);
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(id: string) {
|
||||||
|
const session = this.session;
|
||||||
|
const owner = this.owner;
|
||||||
|
if (!session) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const status = async (s: DownloadStatus, patch: Partial<DownloadItem> = {}, track?: Track) => {
|
||||||
|
if (this.owner !== owner) return;
|
||||||
|
await db.runAsync(
|
||||||
|
'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?',
|
||||||
|
s,
|
||||||
|
patch.bytes ?? 0,
|
||||||
|
track ? JSON.stringify(track) : null,
|
||||||
|
owner,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await status('downloading', { progress: 0 });
|
||||||
|
// Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO.
|
||||||
|
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } }));
|
||||||
|
const track = res.items[0];
|
||||||
|
if (!track?.available || !track.stream_url) {
|
||||||
|
await status('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const part = `${this.file(id)}.part`;
|
||||||
|
const q = (await qualityPrefs.load()).download;
|
||||||
|
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => {
|
||||||
|
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
|
||||||
|
});
|
||||||
|
this.active.set(id, dl);
|
||||||
|
const out = await dl.downloadAsync();
|
||||||
|
if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь)
|
||||||
|
if (!out || out.status !== 200) throw new Error(`status ${out?.status}`);
|
||||||
|
await FileSystem.moveAsync({ from: part, to: this.file(id) });
|
||||||
|
|
||||||
|
if (track.album.cover_url) {
|
||||||
|
const cover = this.cover(track.album.id);
|
||||||
|
const info = await FileSystem.getInfoAsync(cover);
|
||||||
|
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
||||||
|
}
|
||||||
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||||
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||||
|
} catch {
|
||||||
|
await status('error', { progress: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abs(path: string) {
|
||||||
|
return /^https?:/.test(path) ? path : this.server + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloads = new Downloads();
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { ApiError, unwrap, type AuthSession, type PlayEvent } from '@pipu/core';
|
||||||
|
|
||||||
|
import { shortHash } from '../lib/storage';
|
||||||
|
import { getDb } from './db';
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
|
||||||
|
type Reaction = 'like' | 'dislike' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка событий прослушивания и реакций с запасом на офлайн: что не ушло из-за сети — в SQLite,
|
||||||
|
* досылается при появлении сети (`POST /events` идемпотентен по id события, реакция — PUT/DELETE,
|
||||||
|
* для трека хранится только последняя).
|
||||||
|
*/
|
||||||
|
export class PersistentEvents {
|
||||||
|
private readonly owner: string;
|
||||||
|
private draining = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly session: AuthSession,
|
||||||
|
server: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
this.owner = shortHash(`${server}|${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(events: PlayEvent[]) {
|
||||||
|
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sink для EventBuffer: при сетевой ошибке сохраняет пачку и «успешно» завершается. */
|
||||||
|
send = async (events: PlayEvent[]) => {
|
||||||
|
try {
|
||||||
|
await this.post(events);
|
||||||
|
void this.drain();
|
||||||
|
} catch (e) {
|
||||||
|
if (!isNetwork(e)) throw e;
|
||||||
|
const db = await getDb();
|
||||||
|
await db.runAsync('INSERT INTO pending_events (owner, body) VALUES (?, ?)', this.owner, JSON.stringify(events));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private async putReaction(trackId: string, reaction: Reaction) {
|
||||||
|
const params = { params: { path: { id: trackId } } };
|
||||||
|
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||||
|
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция на трек. Без сети — откладывается и возвращает `queued`; ошибку сервера пробрасывает. */
|
||||||
|
async react(trackId: string, reaction: Reaction): Promise<'sent' | 'queued'> {
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
await this.putReaction(trackId, reaction);
|
||||||
|
} catch (e) {
|
||||||
|
if (!isNetwork(e)) throw e;
|
||||||
|
await db.runAsync(
|
||||||
|
'INSERT OR REPLACE INTO pending_reactions (owner, track_id, reaction) VALUES (?, ?, ?)',
|
||||||
|
this.owner,
|
||||||
|
trackId,
|
||||||
|
reaction,
|
||||||
|
);
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
// Более свежая реакция ушла — отложенная устарела.
|
||||||
|
await db.runAsync('DELETE FROM pending_reactions WHERE owner = ? AND track_id = ?', this.owner, trackId);
|
||||||
|
void this.drain();
|
||||||
|
return 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Досылает отложенные реакции и пачки событий; останавливается на первой сетевой ошибке. */
|
||||||
|
async drain() {
|
||||||
|
if (this.draining) return;
|
||||||
|
this.draining = true;
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const reactions = await db.getAllAsync<{ track_id: string; reaction: Reaction }>(
|
||||||
|
'SELECT track_id, reaction FROM pending_reactions WHERE owner = ?',
|
||||||
|
this.owner,
|
||||||
|
);
|
||||||
|
for (const r of reactions) {
|
||||||
|
try {
|
||||||
|
await this.putReaction(r.track_id, r.reaction);
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetwork(e)) return;
|
||||||
|
// трек удалён из библиотеки и т. п. — отбрасываем
|
||||||
|
}
|
||||||
|
await db.runAsync(
|
||||||
|
'DELETE FROM pending_reactions WHERE owner = ? AND track_id = ? AND reaction IS ?',
|
||||||
|
this.owner,
|
||||||
|
r.track_id,
|
||||||
|
r.reaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const rows = await db.getAllAsync<{ id: number; body: string }>(
|
||||||
|
'SELECT id, body FROM pending_events WHERE owner = ? ORDER BY id LIMIT 20',
|
||||||
|
this.owner,
|
||||||
|
);
|
||||||
|
if (!rows.length) return;
|
||||||
|
for (const r of rows) {
|
||||||
|
const events = JSON.parse(r.body) as PlayEvent[];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < events.length; i += BATCH) await this.post(events.slice(i, i + BATCH));
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetwork(e)) return;
|
||||||
|
// Сервер отверг пачку (4xx) — повтор не поможет, отбрасываем.
|
||||||
|
}
|
||||||
|
await db.runAsync('DELETE FROM pending_events WHERE id = ?', r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetwork(e: unknown): boolean {
|
||||||
|
if (e instanceof ApiError) return e.code === 'common.network' || e.status >= 500;
|
||||||
|
return true; // fetch бросает TypeError без ответа сервера
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import {
|
||||||
|
EventBuffer,
|
||||||
|
PlayAttempt,
|
||||||
|
unwrap,
|
||||||
|
type AuthSession,
|
||||||
|
type PlayContext,
|
||||||
|
type Track,
|
||||||
|
} from '@pipu/core';
|
||||||
|
import TrackPlayer, {
|
||||||
|
AppKilledPlaybackBehavior,
|
||||||
|
Capability,
|
||||||
|
Event,
|
||||||
|
State,
|
||||||
|
type Track as RntpTrack,
|
||||||
|
} from 'react-native-track-player';
|
||||||
|
|
||||||
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||||
|
import { downloads } from '../offline/downloads';
|
||||||
|
import { PersistentEvents } from '../offline/events';
|
||||||
|
|
||||||
|
export interface WaveInfo {
|
||||||
|
sessionId: string;
|
||||||
|
activity: string | null;
|
||||||
|
mood: string | null;
|
||||||
|
fallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
ready: boolean;
|
||||||
|
track: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
playing: boolean;
|
||||||
|
buffering: boolean;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
wave: WaveInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||||
|
|
||||||
|
const WAVE_REFILL_AT = 2;
|
||||||
|
const EARLY_SKIP_MS = 30_000;
|
||||||
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
|
||||||
|
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||||
|
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||||
|
return tracks
|
||||||
|
.filter((t) => t.reaction !== 'dislike')
|
||||||
|
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||||
|
.sort((a, b) => a.key - b.key)
|
||||||
|
.map((x) => x.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||||
|
|
||||||
|
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||||
|
export function absolute(server: string, path: string | null | undefined): string | undefined {
|
||||||
|
if (!path) return undefined;
|
||||||
|
return /^https?:/.test(path) ? path : server + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
|
||||||
|
/** Качество стрима по типу сети — из настроек профиля. */
|
||||||
|
async function quality(): Promise<Quality> {
|
||||||
|
const prefs = await qualityPrefs.load();
|
||||||
|
try {
|
||||||
|
const net = await NetInfo.fetch();
|
||||||
|
return net.type === 'cellular' ? prefs.cellular : prefs.wifi;
|
||||||
|
} catch {
|
||||||
|
return prefs.wifi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плеер Android. Очередь живёт в ExoPlayer (react-native-track-player) — играет и с выгруженным UI;
|
||||||
|
* здесь — зеркало очереди с DTO треков, учёт прослушиваний (PlayAttempt из @pipu/core), события,
|
||||||
|
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||||
|
*/
|
||||||
|
class Controller {
|
||||||
|
private session: AuthSession | null = null;
|
||||||
|
private server = '';
|
||||||
|
private events: EventBuffer | null = null;
|
||||||
|
private persistent: PersistentEvents | null = null;
|
||||||
|
private unsubscribeNet: (() => void) | null = null;
|
||||||
|
private attempt: PlayAttempt | null = null;
|
||||||
|
private lastPositionMs = 0;
|
||||||
|
private lastSave = 0;
|
||||||
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
|
private waveLoading = false;
|
||||||
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
|
private setupDone: Promise<void> | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private state: PlayerState = {
|
||||||
|
ready: false, track: null, queue: [], index: 0, playing: false, buffering: false, positionMs: 0, durationMs: 0,
|
||||||
|
context: null, wave: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private update(patch: Partial<PlayerState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||||
|
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||||
|
if (this.session === session) return;
|
||||||
|
void this.events?.flush();
|
||||||
|
this.session = session;
|
||||||
|
this.server = server;
|
||||||
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||||
|
const sink = this.persistent;
|
||||||
|
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
||||||
|
void sink?.drain();
|
||||||
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
|
if (st.isConnected) void this.persistent?.drain();
|
||||||
|
});
|
||||||
|
if (!session) void this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(): Promise<void> {
|
||||||
|
this.setupDone ??= (async () => {
|
||||||
|
// Очистка кэша из профиля — только пока сервис плеера не запущен (иначе SimpleCache держит файлы).
|
||||||
|
if (!(await TrackPlayer.isServiceRunning().catch(() => true))) await playerCache.clearIfRequested();
|
||||||
|
try {
|
||||||
|
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
|
||||||
|
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
|
||||||
|
} catch {
|
||||||
|
// уже настроен (повторный запуск JS при живом сервисе)
|
||||||
|
}
|
||||||
|
await TrackPlayer.updateOptions({
|
||||||
|
capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious, Capability.SeekTo],
|
||||||
|
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
|
||||||
|
progressUpdateEventInterval: 1,
|
||||||
|
android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification },
|
||||||
|
});
|
||||||
|
this.bind();
|
||||||
|
this.update({ ready: true });
|
||||||
|
})();
|
||||||
|
return this.setupDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bind() {
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
|
||||||
|
this.update({ playing: state === State.Playing, buffering: state === State.Buffering || state === State.Loading });
|
||||||
|
if (state === State.Paused) this.save();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
|
||||||
|
const ms = Math.round(position * 1000);
|
||||||
|
this.attempt?.tick(ms);
|
||||||
|
this.lastPositionMs = ms;
|
||||||
|
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
||||||
|
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
||||||
|
this.finishAttempt(lastPosition);
|
||||||
|
if (index === undefined || index === null) return;
|
||||||
|
const track = this.state.queue[index];
|
||||||
|
if (!track) return;
|
||||||
|
this.attempt = new PlayAttempt(track, 0);
|
||||||
|
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||||
|
this.events?.push({
|
||||||
|
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||||
|
context_id: this.state.context?.id,
|
||||||
|
});
|
||||||
|
this.save();
|
||||||
|
void this.refillWave();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000, true);
|
||||||
|
void this.events?.flush();
|
||||||
|
});
|
||||||
|
TrackPlayer.addEventListener(Event.PlaybackError, () => {
|
||||||
|
// Ссылка могла устареть или трек пропал — переходим дальше, ExoPlayer не застрянет на ошибке.
|
||||||
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершение попытки: доиграно (позиция у конца) или пропущено. */
|
||||||
|
private finishAttempt(lastPositionS: number, ended = false) {
|
||||||
|
const a = this.attempt;
|
||||||
|
if (!a) return;
|
||||||
|
this.attempt = null;
|
||||||
|
const pos = Math.round(lastPositionS * 1000);
|
||||||
|
if (this.neutralSwitch) {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||||
|
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events?.flush();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = ended ? 'play_end' : a.outcome(pos);
|
||||||
|
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events?.flush();
|
||||||
|
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
|
||||||
|
else {
|
||||||
|
const early = a.playedMs < EARLY_SKIP_MS;
|
||||||
|
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
|
||||||
|
if (early) void this.refillWave(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||||
|
const q = await quality();
|
||||||
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
|
return tracks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
|
album: t.album.title,
|
||||||
|
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
||||||
|
duration: t.duration_ms / 1000,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
const list = tracks.filter(playable);
|
||||||
|
const target = tracks[index];
|
||||||
|
if (!target || !list.length) return;
|
||||||
|
await this.setup();
|
||||||
|
this.leaveWave();
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
const start = Math.max(list.indexOf(target), 0);
|
||||||
|
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
await TrackPlayer.add(await this.toRntp(list));
|
||||||
|
await TrackPlayer.skip(start);
|
||||||
|
await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prev() {
|
||||||
|
const { position } = await TrackPlayer.getProgress();
|
||||||
|
if (position > 3) await TrackPlayer.seekTo(0);
|
||||||
|
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ms: number) {
|
||||||
|
const t = this.state.track;
|
||||||
|
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||||
|
this.attempt?.seek(ms);
|
||||||
|
this.lastPositionMs = ms;
|
||||||
|
void TrackPlayer.seekTo(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реакция на трек: сразу в UI; без сети запоминается и уходит на сервер при появлении связи. */
|
||||||
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||||
|
if (!this.persistent) return;
|
||||||
|
const prev = track.reaction;
|
||||||
|
const apply = (r: Track['reaction']) => {
|
||||||
|
this.update({
|
||||||
|
queue: this.state.queue.map((t) => (t.id === track.id ? { ...t, reaction: r } : t)),
|
||||||
|
track: this.state.track?.id === track.id ? { ...this.state.track, reaction: r } : this.state.track,
|
||||||
|
});
|
||||||
|
void downloads.setReaction(track.id, r);
|
||||||
|
};
|
||||||
|
apply(reaction);
|
||||||
|
this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id });
|
||||||
|
try {
|
||||||
|
await this.persistent.react(track.id, reaction);
|
||||||
|
} catch (e) {
|
||||||
|
apply(prev);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (reaction === 'like' && downloads.state.autoLikes) void downloads.enqueue([{ ...track, reaction }]);
|
||||||
|
if (reaction) {
|
||||||
|
this.noteWave(track.id, reaction);
|
||||||
|
void this.refillWave(true);
|
||||||
|
}
|
||||||
|
if (reaction === 'dislike' && this.state.track?.id === track.id) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- «Моя волна» ---
|
||||||
|
|
||||||
|
async startWave(activity: string | null, mood: string | null) {
|
||||||
|
if (!this.session) return;
|
||||||
|
await this.setup();
|
||||||
|
this.leaveWave();
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||||
|
} catch (e) {
|
||||||
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||||
|
const local = offlineWave(downloads.downloaded());
|
||||||
|
if (!local.length) throw e;
|
||||||
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||||
|
}
|
||||||
|
const tracks = res.tracks.filter(playable);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
this.update({
|
||||||
|
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
||||||
|
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
||||||
|
});
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
await TrackPlayer.add(await this.toRntp(tracks));
|
||||||
|
await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
|
params: { path: { id: wave.sessionId } },
|
||||||
|
body: { activity, mood },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.replaceUpcoming(res.tracks);
|
||||||
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
|
if (res.tracks.some(playable)) {
|
||||||
|
this.neutralSwitch = true;
|
||||||
|
await TrackPlayer.skipToNext().catch(() => {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private leaveWave() {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave) return;
|
||||||
|
this.waveFeedback = [];
|
||||||
|
this.update({ wave: null });
|
||||||
|
void this.session?.client
|
||||||
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||||
|
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
||||||
|
private async replaceUpcoming(tracks: Track[]) {
|
||||||
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||||
|
await TrackPlayer.removeUpcomingTracks();
|
||||||
|
const add = tracks.filter(playable);
|
||||||
|
await TrackPlayer.add(await this.toRntp(add));
|
||||||
|
this.update({ queue: [...keep, ...add] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refillWave(refresh = false) {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave || !this.session || this.waveLoading) return;
|
||||||
|
const remaining = this.state.queue.length - this.state.index - 1;
|
||||||
|
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
||||||
|
this.waveLoading = true;
|
||||||
|
const feedback = this.waveFeedback;
|
||||||
|
this.waveFeedback = [];
|
||||||
|
try {
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||||
|
params: { path: { id: wave.sessionId } },
|
||||||
|
body: { feedback },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (this.state.wave?.sessionId !== wave.sessionId) return;
|
||||||
|
if (refresh) await this.replaceUpcoming(res.tracks);
|
||||||
|
else {
|
||||||
|
const add = res.tracks.filter(playable);
|
||||||
|
await TrackPlayer.add(await this.toRntp(add));
|
||||||
|
this.update({ queue: [...this.state.queue, ...add] });
|
||||||
|
}
|
||||||
|
this.update({ wave: { ...wave, fallback: res.fallback } });
|
||||||
|
} catch {
|
||||||
|
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
||||||
|
} finally {
|
||||||
|
this.waveLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- состояние ---
|
||||||
|
|
||||||
|
private save() {
|
||||||
|
const t = this.state.track;
|
||||||
|
if (!t || !this.session) return;
|
||||||
|
this.lastSave = Date.now();
|
||||||
|
void this.session.client
|
||||||
|
.PUT('/api/v1/me/playback-state', {
|
||||||
|
body: {
|
||||||
|
track_id: t.id,
|
||||||
|
position_ms: this.lastPositionMs,
|
||||||
|
queue: this.state.queue.slice(0, 500).map((x) => x.id),
|
||||||
|
queue_index: Math.min(this.state.index, 499),
|
||||||
|
context: this.state.context ? { type: this.state.context.type, id: this.state.context.id } : undefined,
|
||||||
|
device_id: 'android',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||||
|
async restore() {
|
||||||
|
if (!this.session || this.state.track) return;
|
||||||
|
await this.setup();
|
||||||
|
try {
|
||||||
|
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (!st.track_id || !st.queue.length) return;
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||||
|
const tracks = res.items.filter(playable);
|
||||||
|
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
await TrackPlayer.add(await this.toRntp(tracks));
|
||||||
|
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||||
|
} catch {
|
||||||
|
// офлайн или нет сохранённого состояния
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
this.attempt = null;
|
||||||
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||||
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
this.save();
|
||||||
|
void this.events?.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const player = new Controller();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import { player, type PlayerState } from './controller';
|
||||||
|
|
||||||
|
export function usePlayerState(): PlayerState {
|
||||||
|
return useSyncExternalStore(player.subscribe, player.getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { player };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import TrackPlayer, { Event } from 'react-native-track-player';
|
||||||
|
|
||||||
|
import { player } from './controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
|
||||||
|
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||||
|
*/
|
||||||
|
export async function PlaybackService(): Promise<void> {
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||||
|
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||||
|
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||||
|
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||||
|
if (permanent) void TrackPlayer.pause();
|
||||||
|
else if (paused) void TrackPlayer.pause();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { tokens } from '@pipu/design-tokens';
|
import { tokens, type ThemeName } from '@pipu/design-tokens';
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
// Пока только тёмная тема; переключение тем — вместе с профилем (M5).
|
export type Colors = Record<keyof typeof tokens.color.dark, string>;
|
||||||
export const colors = tokens.color.dark;
|
|
||||||
|
/** Активная тема (dark / light); выбирается в профиле (`ui_prefs.theme`, «system» — по настройке телефона). */
|
||||||
|
export const ThemeContext = createContext<ThemeName>('dark');
|
||||||
|
|
||||||
|
export function useThemeName(): ThemeName {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColors(): Colors {
|
||||||
|
return tokens.color[useThemeName()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Цвета до входа и загрузки настроек (сплэш) — тёмная тема. */
|
||||||
|
export const darkColors: Colors = tokens.color.dark;
|
||||||
export const radius = tokens.radius;
|
export const radius = tokens.radius;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useSessionCtx } from '../lib/session';
|
||||||
|
import { updater } from '../lib/update';
|
||||||
|
import { radius, useColors } from '../theme';
|
||||||
|
import { Button } from './kit';
|
||||||
|
|
||||||
|
export function useUpdateState() {
|
||||||
|
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||||
|
export function UpdateDialog() {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const st = useUpdateState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void updater.check(session);
|
||||||
|
const sub = AppState.addEventListener('change', (s) => {
|
||||||
|
if (s === 'active') void updater.check(session);
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
if (st.status === 'idle' || st.status === 'checking' || st.status === 'latest') return null;
|
||||||
|
if (st.status === 'available' && st.dismissed) return null;
|
||||||
|
|
||||||
|
const { release, required } = st;
|
||||||
|
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||||
|
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal transparent animationType="fade" visible onRequestClose={() => updater.dismiss()}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
maxHeight: '80%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }}>
|
||||||
|
{t('mobile.update_title', { version: release.version })}
|
||||||
|
</Text>
|
||||||
|
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||||
|
{release.notes_md.trim() !== '' && (
|
||||||
|
<ScrollView style={{ maxHeight: 240 }}>
|
||||||
|
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||||
|
{t('mobile.update_size', {
|
||||||
|
size: (release.size / 1048576).toFixed(1),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{st.status === 'downloading' && (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: colors.surface3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
width: `${Math.round(st.progress * 100)}%`,
|
||||||
|
backgroundColor: colors.accent2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
||||||
|
{t('mobile.update_downloading', {
|
||||||
|
percent: Math.round(st.progress * 100),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{st.status === 'verifying' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_verifying')}</Text>}
|
||||||
|
{st.status === 'error' && (
|
||||||
|
<Text style={{ color: colors.danger }}>
|
||||||
|
{t(st.error === 'bad_checksum' ? 'mobile.update_bad_checksum' : 'mobile.update_failed')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{st.status === 'ready' && (
|
||||||
|
<View style={{ gap: 4 }}>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_allow')}</Text>
|
||||||
|
<Pressable onPress={() => void updater.openInstallSettings()} accessibilityRole="link">
|
||||||
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.update_open_settings')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button title={t('mobile.update_install')} icon="system-update" busy={busy} onPress={action} />
|
||||||
|
{!required && !busy && <Button title={t('mobile.update_later')} variant="ghost" onPress={() => updater.dismiss()} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
||||||
|
function plainNotes(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/^#{1,6}\s*/gm, '')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||||
|
.replace(/^\s*[-*]\s+/gm, '• ')
|
||||||
|
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Track } from '@pipu/core';
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, Pressable } from 'react-native';
|
||||||
|
|
||||||
|
import { downloads, type DownloadItem, type DownloadsState } from '../offline/downloads';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Icon } from './kit';
|
||||||
|
|
||||||
|
export function useDownloads(): DownloadsState {
|
||||||
|
return useSyncExternalStore(downloads.subscribe, () => downloads.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadItem(trackId: string): DownloadItem | undefined {
|
||||||
|
return useSyncExternalStore(downloads.subscribe, () => downloads.state.items.get(trackId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка «скачать / скачано» у трека: повторное нажатие удаляет загрузку. */
|
||||||
|
export function DownloadButton({ track, size = 26 }: { track: Track; size?: number }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const item = useDownloadItem(track.id);
|
||||||
|
const busy = item?.status === 'queued' || item?.status === 'downloading';
|
||||||
|
const onPress = () => void (item ? downloads.remove([track.id]) : downloads.enqueue([track]));
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
hitSlop={10}
|
||||||
|
disabled={!track.available}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(item ? 'mobile.dl_remove' : 'mobile.dl_download')}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accent2} />
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
name={item?.status === 'done' ? 'download-done' : item?.status === 'error' ? 'error-outline' : 'download'}
|
||||||
|
size={size}
|
||||||
|
color={item?.status === 'done' ? colors.accent2 : item?.status === 'error' ? colors.danger : colors.text2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { ApiError, type Track } from '@pipu/core';
|
||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type StyleProp,
|
||||||
|
type TextInputProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { useSessionCtx } from '../lib/session';
|
||||||
|
import { downloads } from '../offline/downloads';
|
||||||
|
import { absolute } from '../player/controller';
|
||||||
|
import { radius, useColors, type Colors } from '../theme';
|
||||||
|
|
||||||
|
export type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||||
|
|
||||||
|
export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <MaterialIcons name={name} size={size} color={color ?? colors.text2} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
|
||||||
|
const s = useStyles();
|
||||||
|
void scroll;
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
|
||||||
|
{children}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function H1({ children }: { children: ReactNode }) {
|
||||||
|
const s = useStyles();
|
||||||
|
return <Text style={s.h1}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
|
||||||
|
const s = useStyles();
|
||||||
|
return <Text style={[s.muted, style as object]}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
secure,
|
||||||
|
...rest
|
||||||
|
}: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 6 }}>
|
||||||
|
<Text style={s.label}>{label}</Text>
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
placeholderTextColor={colors.text3}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry={secure && !shown}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
style={[s.input, error ? { borderColor: colors.danger } : null, secure ? { paddingRight: 48 } : null]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{secure && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShown((v) => !v)}
|
||||||
|
style={s.reveal}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')}
|
||||||
|
>
|
||||||
|
<Icon name={shown ? 'visibility-off' : 'visibility'} size={20} color={colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{(error ?? hint) && <Text style={{ color: error ? colors.danger : colors.text3, fontSize: 12 }}>{error ?? hint}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
title,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
busy?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: IconName;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||||
|
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: disabled || busy, busy }}
|
||||||
|
style={({ pressed }) => [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
|
||||||
|
>
|
||||||
|
{busy ? <ActivityIndicator color={fg} /> : icon ? <Icon name={icon} size={20} color={fg} /> : null}
|
||||||
|
<Text style={{ color: fg, fontWeight: '600', fontSize: 15 }}>{title}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ children }: { children?: ReactNode }) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
if (!children) return null;
|
||||||
|
return (
|
||||||
|
<View style={s.error} accessibilityRole="alert">
|
||||||
|
<Text style={{ color: colors.danger, fontWeight: '500' }}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст ошибки API: перевод errors:<code>. */
|
||||||
|
export function useErrorText(): (err: unknown) => string {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||||
|
return (err) => {
|
||||||
|
const code = err instanceof ApiError ? err.code : 'common.internal';
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
return i18n.exists(key) ? tr(key, err instanceof ApiError ? err.params : {}) : tr('errors:common.internal');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Cover({
|
||||||
|
album,
|
||||||
|
size,
|
||||||
|
round,
|
||||||
|
}: {
|
||||||
|
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||||
|
size: number;
|
||||||
|
round?: boolean;
|
||||||
|
}) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const remote = server ? absolute(server, album.cover_url) : undefined;
|
||||||
|
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||||
|
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||||
|
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||||
|
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
|
||||||
|
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||||
|
return (
|
||||||
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{uri ? (
|
||||||
|
<Image source={{ uri }} style={{ width: size, height: size }} />
|
||||||
|
) : (
|
||||||
|
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const sec = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackRow({
|
||||||
|
track,
|
||||||
|
current,
|
||||||
|
onPress,
|
||||||
|
onLike,
|
||||||
|
numbered,
|
||||||
|
meta,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
track: Track;
|
||||||
|
current?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onLike?: () => void;
|
||||||
|
numbered?: number;
|
||||||
|
meta?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const liked = track.reaction === 'like';
|
||||||
|
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!track.available}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`${track.title}, ${track.artists.map((a) => a.name).join(', ')}`}
|
||||||
|
style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]}
|
||||||
|
>
|
||||||
|
{numbered !== undefined ? (
|
||||||
|
<Text style={s.num}>{numbered}</Text>
|
||||||
|
) : (
|
||||||
|
<Cover album={track.album} size={44} />
|
||||||
|
)}
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text numberOfLines={1} style={[s.rowTitle, current && { color: colors.accent2 }]}>
|
||||||
|
{track.title}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={s.rowSub}>
|
||||||
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
|
{meta ? ` · ${meta}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{onLike && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onLike}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
>
|
||||||
|
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{trailing}
|
||||||
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||||
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
|
||||||
|
export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) {
|
||||||
|
const colors = useColors();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||||
|
<Pressable style={{ flex: 1, backgroundColor: colors.overlay }} onPress={onClose} accessibilityLabel={title} />
|
||||||
|
<SafeAreaView edges={['bottom']} style={{ backgroundColor: colors.surface, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }}>
|
||||||
|
<View style={{ padding: 20, gap: 16 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }} accessibilityRole="header">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||||
|
<Icon name="close" color={colors.text2} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
const s = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
return (
|
||||||
|
<View style={s.segmented} accessibilityRole="tablist">
|
||||||
|
{options.map((o) => (
|
||||||
|
<Pressable
|
||||||
|
key={o.value}
|
||||||
|
onPress={() => onChange(o.value)}
|
||||||
|
accessibilityRole="tab"
|
||||||
|
accessibilityState={{ selected: value === o.value }}
|
||||||
|
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = new Map<Colors, ReturnType<typeof makeStyles>>();
|
||||||
|
|
||||||
|
/** Стили кита под активную тему (создаются один раз на тему). */
|
||||||
|
function useStyles() {
|
||||||
|
const colors = useColors();
|
||||||
|
let st = styles.get(colors);
|
||||||
|
if (!st) styles.set(colors, (st = makeStyles(colors)));
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeStyles = (colors: Colors) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 },
|
||||||
|
muted: { color: colors.text2, lineHeight: 21 },
|
||||||
|
label: { color: colors.text2, fontSize: 13, fontWeight: '600' },
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.line2,
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
|
||||||
|
error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 },
|
||||||
|
num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] },
|
||||||
|
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||||
|
rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 },
|
||||||
|
duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||||
|
segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' },
|
||||||
|
segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { QUALITIES, qualityPrefs, type Quality, type QualityPrefs } from '../lib/prefs';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, Field, Segmented, Sheet, useErrorText } from './kit';
|
||||||
|
|
||||||
|
export function useQualityPrefs(): QualityPrefs {
|
||||||
|
return useSyncExternalStore(qualityPrefs.subscribe, qualityPrefs.get);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист «Качество звука»: Wi-Fi / сотовая / загрузки. */
|
||||||
|
export function QualitySheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const prefs = useQualityPrefs();
|
||||||
|
const row = (key: keyof QualityPrefs) => (
|
||||||
|
<View style={{ gap: 8 }}>
|
||||||
|
<Text style={{ color: colors.text2, fontSize: 13, fontWeight: '600' }}>{t(`mobile.quality_${key}`)}</Text>
|
||||||
|
<Segmented<Quality>
|
||||||
|
value={prefs[key]}
|
||||||
|
options={QUALITIES.map((q) => ({ value: q, label: t(`mobile.q_${q}`) }))}
|
||||||
|
onChange={(q) => void qualityPrefs.set({ [key]: q })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Sheet visible={visible} title={t('mobile.quality')} onClose={onClose}>
|
||||||
|
{row('wifi')}
|
||||||
|
{row('cellular')}
|
||||||
|
{row('download')}
|
||||||
|
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.quality_hint')}</Text>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лист смены пароля: после смены сервер выдаёт новую пару токенов, остальные сессии завершаются. */
|
||||||
|
export function PasswordSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const mismatch = repeat !== '' && repeat !== next;
|
||||||
|
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tokens = unwrap(await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }));
|
||||||
|
await session.apply(tokens);
|
||||||
|
setDone(true);
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet visible={visible} title={t('settings.password')} onClose={close}>
|
||||||
|
{done ? (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: colors.ok }}>{t('settings.password_changed')}</Text>
|
||||||
|
<Button title={t('form.close')} onPress={close} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field label={t('settings.current_password')} value={current} onChangeText={setCurrent} secure autoComplete="current-password" error={fieldErr('current_password')} />
|
||||||
|
<Field label={t('settings.new_password')} value={next} onChangeText={setNext} secure autoComplete="new-password" error={fieldErr('new_password')} />
|
||||||
|
<Field
|
||||||
|
label={t('settings.repeat_password')}
|
||||||
|
value={repeat}
|
||||||
|
onChangeText={setRepeat}
|
||||||
|
secure
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<ErrorBox>{error && !fieldErr('current_password') && !fieldErr('new_password') ? errorText(error) : null}</ErrorBox>
|
||||||
|
<Button
|
||||||
|
title={t('settings.change_password')}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current || !next || mismatch || repeat === ''}
|
||||||
|
onPress={() => void submit()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
||||||
|
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { AppState, Pressable, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { useThemeName } from '../../theme';
|
||||||
|
import { drawLiquid, drawOrb } from './draw';
|
||||||
|
|
||||||
|
export interface WaveOrbProps {
|
||||||
|
variant: 'orb' | 'liquid';
|
||||||
|
playing: boolean;
|
||||||
|
bpm?: number;
|
||||||
|
energy?: number;
|
||||||
|
colors?: readonly string[];
|
||||||
|
size: number;
|
||||||
|
label?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Моя волна» на Skia (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
|
||||||
|
* при нажатии сжимается с отскоком. Кадры считаются в UI-потоке; вне экрана и в фоне анимация стоит.
|
||||||
|
* Без `onPress` — декоративная визуализация (полноэкранный плеер в виде «волна»).
|
||||||
|
*/
|
||||||
|
export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, size, label, onPress, children }: WaveOrbProps) {
|
||||||
|
const dark = useThemeName() === 'dark';
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const [focused, setFocused] = useState(true);
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
return () => setFocused(false);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||||
|
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
||||||
|
|
||||||
|
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||||
|
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||||
|
const pressT = useSharedValue(0);
|
||||||
|
// Новый объект на каждом кадре — пересчёт картинки (одно и то же число могло бы не вызвать его).
|
||||||
|
const time = useSharedValue({ ts: 0, dt: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
props.value = { variant, playing, bpm, energy, colors: palette, dark, reduced };
|
||||||
|
}, [variant, playing, bpm, energy, palette[0], palette[1], palette[2], dark, reduced]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (st) => setForeground(st === 'active'));
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const frame = useFrameCallback((info) => {
|
||||||
|
time.value = { ts: info.timestamp, dt: Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000) };
|
||||||
|
}, false);
|
||||||
|
useEffect(() => {
|
||||||
|
frame.setActive(focused && foreground);
|
||||||
|
}, [focused, foreground]);
|
||||||
|
|
||||||
|
const S = size * 1.6;
|
||||||
|
const picture = useDerivedValue(() => {
|
||||||
|
const dt = time.value.dt;
|
||||||
|
const s = state.value;
|
||||||
|
Object.assign(s, props.value);
|
||||||
|
s.size = size;
|
||||||
|
if (pressT.value === 0 && s.pressT === 1) s.pressV -= 0.12; // отпустили — отскок
|
||||||
|
s.pressT = pressT.value;
|
||||||
|
step(s, dt);
|
||||||
|
const r = size * 0.5 * waveScale(s);
|
||||||
|
return createPicture(
|
||||||
|
(canvas) => {
|
||||||
|
if (s.variant === 'liquid') drawLiquid(canvas, S / 2, S / 2, r, s);
|
||||||
|
else drawOrb(canvas, S / 2, S / 2, r, s);
|
||||||
|
},
|
||||||
|
{ width: S, height: S },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const labelStyle = useAnimatedStyle(() => {
|
||||||
|
void time.value.ts; // пересчёт на каждом кадре
|
||||||
|
const s = state.value;
|
||||||
|
return { transform: [{ scale: waveScale(s) + s.pulse * 0.03 }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Canvas style={{ position: 'absolute', width: S, height: S, left: (size - S) / 2, top: (size - S) / 2 }} pointerEvents="none">
|
||||||
|
<Picture picture={picture} />
|
||||||
|
</Canvas>
|
||||||
|
<Animated.View style={[{ alignItems: 'center', justifyContent: 'center', gap: 4 }, labelStyle]} pointerEvents="none">
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
if (!onPress) return <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">{body}</View>;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={() => (pressT.value = 1)}
|
||||||
|
onPressOut={() => (pressT.value = 0)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={{ selected: playing }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Отрисовка «Моей волны» на Skia — тот же алгоритм, что Canvas 2D в вебе (apps/web/src/widgets/wave/draw.ts).
|
||||||
|
// Функции — worklet'ы: кадр собирается в UI-потоке Reanimated, JS-поток не участвует.
|
||||||
|
import { mix, rgb, rgba, type Rgb, type WaveState } from '@pipu/core';
|
||||||
|
import { BlendMode, ClipOp, PaintStyle, Skia, TileMode, type SkCanvas, type SkPaint } from '@shopify/react-native-skia';
|
||||||
|
|
||||||
|
function radial(cx: number, cy: number, r0: number, r1: number, colors: string[], pos: number[]): SkPaint {
|
||||||
|
'worklet';
|
||||||
|
const p = Skia.Paint();
|
||||||
|
p.setAntiAlias(true);
|
||||||
|
p.setShader(
|
||||||
|
Skia.Shader.MakeTwoPointConicalGradient(
|
||||||
|
{ x: cx, y: cy },
|
||||||
|
r0,
|
||||||
|
{ x: cx, y: cy },
|
||||||
|
r1,
|
||||||
|
colors.map((c) => Skia.Color(c)),
|
||||||
|
pos,
|
||||||
|
TileMode.Clamp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Градиент со смещённым центром (аналог createRadialGradient(x0, y0, 0, x1, y1, r)). */
|
||||||
|
function conical(x0: number, y0: number, x1: number, y1: number, r: number, colors: string[]): SkPaint {
|
||||||
|
'worklet';
|
||||||
|
const p = Skia.Paint();
|
||||||
|
p.setAntiAlias(true);
|
||||||
|
p.setShader(Skia.Shader.MakeTwoPointConicalGradient({ x: x0, y: y0 }, 0, { x: x1, y: y1 }, r, colors.map((c) => Skia.Color(c)), [0, 1], TileMode.Clamp));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawOrb(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||||
|
'worklet';
|
||||||
|
const t = s.t;
|
||||||
|
const c1 = rgb(s.colors[0]);
|
||||||
|
const c2 = rgb(s.colors[1]);
|
||||||
|
const c3 = rgb(s.colors[2]);
|
||||||
|
canvas.drawCircle(
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r * 1.65,
|
||||||
|
radial(cx, cy, r * 0.6, r * 1.65, [rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12), rgba(c2, 0.1 + s.pulse * 0.12), rgba(c1, 0)], [0, 0.5, 1]),
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
const clip = Skia.Path.Make();
|
||||||
|
clip.addCircle(cx, cy, r);
|
||||||
|
canvas.clipPath(clip, ClipOp.Intersect, true);
|
||||||
|
const bg = Skia.Paint();
|
||||||
|
bg.setColor(Skia.Color(s.dark ? '#060B1C' : '#0B1533'));
|
||||||
|
const box = Skia.XYWHRect(cx - r, cy - r, r * 2, r * 2);
|
||||||
|
canvas.drawRect(box, bg);
|
||||||
|
|
||||||
|
const blobs: Rgb[] = [c1, c2, c3, c1, c2];
|
||||||
|
for (let i = 0; i < blobs.length; i++) {
|
||||||
|
const c = blobs[i]!;
|
||||||
|
const a = t * (0.25 + i * 0.07) + i * 1.7;
|
||||||
|
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
|
||||||
|
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
|
||||||
|
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
|
||||||
|
const g = radial(bx, by, 0, br, [rgba(c, 0.55), rgba(c, 0)], [0, 1]);
|
||||||
|
g.setBlendMode(BlendMode.Plus);
|
||||||
|
canvas.drawRect(box, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = 9;
|
||||||
|
for (let l = 0; l < lines; l++) {
|
||||||
|
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
|
||||||
|
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
|
||||||
|
const path = Skia.Path.Make();
|
||||||
|
for (let x = -r; x <= r; x += 4) {
|
||||||
|
const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4);
|
||||||
|
if (x === -r) path.moveTo(cx + x, y);
|
||||||
|
else path.lineTo(cx + x, y);
|
||||||
|
}
|
||||||
|
const stroke = Skia.Paint();
|
||||||
|
stroke.setAntiAlias(true);
|
||||||
|
stroke.setStyle(PaintStyle.Stroke);
|
||||||
|
stroke.setStrokeWidth(1.5);
|
||||||
|
stroke.setColor(Skia.Color(`rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`));
|
||||||
|
stroke.setBlendMode(BlendMode.Plus);
|
||||||
|
canvas.drawPath(path, stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawRect(box, conical(cx - r * 0.35, cy - r * 0.45, cx - r * 0.35, cy - r * 0.45, r * 0.9, ['rgba(255,255,255,0.18)', 'rgba(255,255,255,0)']));
|
||||||
|
canvas.restore();
|
||||||
|
|
||||||
|
const ring = Skia.Paint();
|
||||||
|
ring.setAntiAlias(true);
|
||||||
|
ring.setStyle(PaintStyle.Stroke);
|
||||||
|
ring.setStrokeWidth(1);
|
||||||
|
ring.setColor(Skia.Color(rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3)));
|
||||||
|
canvas.drawCircle(cx, cy, r - 0.5, ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawLiquid(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||||
|
'worklet';
|
||||||
|
const t = s.t;
|
||||||
|
const cols: Rgb[] = [rgb(s.colors[0]), rgb(s.colors[1]), rgb(s.colors[2])];
|
||||||
|
canvas.drawCircle(cx, cy, r * 1.6, radial(cx, cy, r * 0.4, r * 1.6, [rgba(cols[0]!, 0.3 + s.pulse * 0.3), rgba(cols[0]!, 0)], [0, 1]));
|
||||||
|
for (let L = 0; L < 3; L++) {
|
||||||
|
const path = Skia.Path.Make();
|
||||||
|
const P = 140;
|
||||||
|
for (let j = 0; j <= P; j++) {
|
||||||
|
const th = (j / P) * Math.PI * 2;
|
||||||
|
const w =
|
||||||
|
1 +
|
||||||
|
0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) +
|
||||||
|
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) +
|
||||||
|
0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
|
||||||
|
0.08 * s.pulse * Math.sin(2 * th + L) +
|
||||||
|
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
|
||||||
|
const rr = r * (0.86 - L * 0.07) * w;
|
||||||
|
const x = cx + Math.cos(th) * rr;
|
||||||
|
const y = cy + Math.sin(th) * rr;
|
||||||
|
if (j) path.lineTo(x, y);
|
||||||
|
else path.moveTo(x, y);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
|
||||||
|
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
|
||||||
|
const c = cols[L]!;
|
||||||
|
const g = conical(cx + ox, cy + oy, cx, cy, r * 1.1, [rgba(mix(c, [255, 255, 255], 0.25), 0.85), rgba(c, 0.25)]);
|
||||||
|
g.setAlphaf(s.dark ? 0.6 : 0.75);
|
||||||
|
g.setBlendMode(s.dark ? BlendMode.Plus : BlendMode.SrcOver);
|
||||||
|
canvas.drawPath(path, g);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
|||||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||||
|
COPY patches/ patches/
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY apps/mobile/package.json apps/mobile/
|
COPY apps/mobile/package.json apps/mobile/
|
||||||
COPY packages/api-client/package.json packages/api-client/
|
COPY packages/api-client/package.json packages/api-client/
|
||||||
|
|||||||
@@ -14,18 +14,24 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||||
"@fontsource/onest": "^5.2.6",
|
"@fontsource/onest": "^5.2.6",
|
||||||
"@fontsource/unbounded": "^5.2.6",
|
"@fontsource/unbounded": "^5.2.6",
|
||||||
|
"@material-symbols/svg-400": "^0.31.9",
|
||||||
"@pipu/api-client": "workspace:*",
|
"@pipu/api-client": "workspace:*",
|
||||||
"@pipu/core": "workspace:*",
|
"@pipu/core": "workspace:*",
|
||||||
"@pipu/design-tokens": "workspace:*",
|
"@pipu/design-tokens": "workspace:*",
|
||||||
"@pipu/i18n": "workspace:*",
|
"@pipu/i18n": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.80.7",
|
"@tanstack/react-query": "^5.80.7",
|
||||||
|
"@tanstack/react-router": "^1.120.20",
|
||||||
|
"hash-wasm": "^4.12.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-i18next": "^15.5.3"
|
"react-i18next": "^15.5.3",
|
||||||
|
"tus-js-client": "^4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
|||||||
+303
-29
@@ -1,51 +1,325 @@
|
|||||||
import { createApiClient } from '@pipu/api-client';
|
import { AuthSession } from '@pipu/core';
|
||||||
import { createI18n } from '@pipu/i18n';
|
import { createI18n } from '@pipu/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { createAppRouter, safeRedirect } from './app/router';
|
||||||
|
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const info = {
|
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||||
name: 'pipu-music',
|
const api = fakeApi(users);
|
||||||
version: '0.1.0',
|
|
||||||
api_version: 'v1',
|
|
||||||
supported_locales: ['ru', 'en'],
|
|
||||||
min_android_version_code: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function renderApp(fetchImpl: typeof fetch) {
|
|
||||||
const i18n = await createI18n('ru');
|
const i18n = await createI18n('ru');
|
||||||
const client = createApiClient({ baseUrl: 'http://localhost', fetch: fetchImpl });
|
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: api.fetch, clientType: 'web' });
|
||||||
|
if (opts.signedInAs) await session.login(opts.signedInAs.username, opts.signedInAs.password);
|
||||||
|
else await session.restore();
|
||||||
|
const router = createAppRouter(session, { history: createMemoryHistory({ initialEntries: [path] }) });
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(
|
render(
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
<App session={session} router={router} queryClient={queryClient} />
|
||||||
<App client={client} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
</I18nextProvider>,
|
||||||
);
|
);
|
||||||
|
return { api, router, session, i18n, user: userEvent.setup() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('App', () => {
|
describe('sign-in', () => {
|
||||||
it('shows the server version when the server is online', async () => {
|
it('sends a guest to the login page and back after signing in', async () => {
|
||||||
await renderApp(async () => Response.json(info));
|
const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
|
||||||
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
|
const { user, router } = await renderApp([alice], '/settings');
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
expect(router.state.location.pathname).toBe('/login');
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Логин'), 'alice');
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), 'wrong');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Неверный логин или пароль');
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Настройки' });
|
||||||
|
expect(router.state.location.pathname).toBe('/settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error when the server is unreachable', async () => {
|
it('shows the bootstrap hint on first start', async () => {
|
||||||
await renderApp(async () => {
|
await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
|
||||||
throw new TypeError('Failed to fetch');
|
expect(await screen.findByText(/Первый запуск/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces the bootstrap admin to change credentials', async () => {
|
||||||
|
const admin = makeUser('admin', 'admin', { must_change_credentials: true });
|
||||||
|
const { user, router, api } = await renderApp([admin], '/admin/users', { signedInAs: admin });
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Смените логин и пароль' });
|
||||||
|
expect(router.state.location.pathname).toBe('/setup/admin');
|
||||||
|
|
||||||
|
const login = screen.getByLabelText('Новый логин');
|
||||||
|
expect(login).toHaveValue('admin');
|
||||||
|
await user.clear(login);
|
||||||
|
await user.type(login, 'owner');
|
||||||
|
await user.type(screen.getByLabelText('Новый пароль'), 'short');
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'shorter');
|
||||||
|
expect(screen.getByText('Пароли не совпадают')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Сохранить и продолжить' })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.clear(screen.getByLabelText('Повторите пароль'));
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'short');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
expect(await screen.findByText('Пароль должен быть не короче 12 символов')).toBeInTheDocument();
|
||||||
|
|
||||||
|
for (const label of ['Новый пароль', 'Повторите пароль']) {
|
||||||
|
await user.clear(screen.getByLabelText(label));
|
||||||
|
await user.type(screen.getByLabelText(label), 'a-strong-owner-pass');
|
||||||
|
}
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||||
|
expect(router.state.location.pathname).toBe('/');
|
||||||
|
expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({
|
||||||
|
new_username: 'owner',
|
||||||
});
|
});
|
||||||
expect(await screen.findByText(/Сервер не отвечает/)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches the interface language', async () => {
|
it('forces a temporary password to be replaced', async () => {
|
||||||
await renderApp(async () => Response.json(info));
|
const bob = makeUser('bob', 'user', { must_change_password: true });
|
||||||
await screen.findByText('Сервер 0.1.0 на связи');
|
const { user } = await renderApp([bob], '/', { signedInAs: bob });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
|
await screen.findByRole('heading', { name: 'Задайте новый пароль' });
|
||||||
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
|
await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass');
|
||||||
|
await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
|
||||||
|
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs out from the profile menu', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Выйти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
expect(router.state.location.pathname).toBe('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('navigation by role', () => {
|
||||||
|
it('hides the admin section from listeners and blocks the route', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice });
|
||||||
|
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||||
|
expect(router.state.location.pathname).toBe('/');
|
||||||
|
expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows users but not the audit log to moderators', async () => {
|
||||||
|
const mod = makeUser('mod', 'moderator');
|
||||||
|
await renderApp([mod], '/', { signedInAs: mod });
|
||||||
|
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the audit log to admins', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
await renderApp([root], '/admin/audit', { signedInAs: root });
|
||||||
|
expect(await screen.findByText('Записей пока нет')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('users admin', () => {
|
||||||
|
it('lets an admin create a moderator and shows the temporary password once', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const anna = makeUser('anna', 'user', { last_login_at: new Date().toISOString() });
|
||||||
|
const { user } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||||
|
|
||||||
|
const table = await screen.findByRole('table');
|
||||||
|
await within(table).findByText('@anna');
|
||||||
|
expect(within(table).getByText('(вы)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
|
||||||
|
await user.type(within(dialog).getByLabelText('Логин'), 'ilya');
|
||||||
|
await user.selectOptions(within(dialog).getByRole('combobox'), 'moderator');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Создать' }));
|
||||||
|
|
||||||
|
const secret = await screen.findByRole('dialog', { name: 'Временный пароль' });
|
||||||
|
expect(within(secret).getByTestId('temporary-password')).toHaveTextContent('Temp-Pass-123');
|
||||||
|
await user.click(within(secret).getByRole('button', { name: 'Готово' }));
|
||||||
|
expect(screen.queryByTestId('temporary-password')).not.toBeInTheDocument();
|
||||||
|
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers moderators only what they may do', async () => {
|
||||||
|
const mod = makeUser('mod', 'moderator');
|
||||||
|
const anna = makeUser('anna', 'user');
|
||||||
|
const other = makeUser('other', 'moderator');
|
||||||
|
const { user } = await renderApp([mod, anna, other], '/admin/users', { signedInAs: mod });
|
||||||
|
|
||||||
|
await screen.findByText('@anna');
|
||||||
|
expect(screen.queryByRole('button', { name: 'Действия с other' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Действия с mod' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||||
|
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||||
|
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
|
||||||
|
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by role', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const { user, api } = await renderApp([root, makeUser('anna', 'user'), makeUser('mod', 'moderator')], '/admin/users', {
|
||||||
|
signedInAs: root,
|
||||||
|
});
|
||||||
|
await screen.findByText('@anna');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Модератор' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('@anna')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getByText('@mod')).toBeInTheDocument();
|
||||||
|
expect(api.calls.some((c) => c.path === '/api/v1/admin/users' && c.method === 'GET')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('language and settings', () => {
|
||||||
|
it('saves the language in the profile', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/settings', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Настройки' });
|
||||||
|
const groups = screen.getAllByRole('group', { name: 'Язык' });
|
||||||
|
await user.click(within(groups[0]!).getByRole('button', { name: 'EN' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Settings' });
|
||||||
|
await waitFor(() => expect(api.users.get(alice.id)?.locale).toBe('en'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the theme from the profile', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user } = await renderApp([alice], '/settings', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Светлая' }));
|
||||||
|
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light'));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Тёмная' }));
|
||||||
|
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches the language on the login page without a session', async () => {
|
||||||
|
const { user } = await renderApp([], '/login');
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'EN' }));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search box', () => {
|
||||||
|
it('opens the search page on Enter and an item with the arrows', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const box = await screen.findByRole('combobox', { name: 'Поиск' });
|
||||||
|
|
||||||
|
await user.type(box, 'rbyj');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
const options = within(list).getAllByRole('option');
|
||||||
|
expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']);
|
||||||
|
expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/search'));
|
||||||
|
expect(router.state.location.search).toEqual({ q: 'rbyj' });
|
||||||
|
|
||||||
|
await user.clear(box);
|
||||||
|
await user.type(box, 'кино');
|
||||||
|
await screen.findByRole('listbox');
|
||||||
|
await user.keyboard('{ArrowDown}{Enter}');
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa'));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses on the slash key', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const box = await screen.findByRole('combobox', { name: 'Поиск' });
|
||||||
|
await user.keyboard('/');
|
||||||
|
expect(box).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('player', () => {
|
||||||
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
|
||||||
|
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
|
||||||
|
await user.click(row);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||||
|
expect(row).toHaveAttribute('aria-current', 'true');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Нравится' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.calls).toContainEqual({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101',
|
||||||
|
body: { reaction: 'like' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления.
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
await user.keyboard(' ');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Уход со страницы досылает события и сохраняет состояние плеера.
|
||||||
|
window.dispatchEvent(new Event('pagehide'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
|
||||||
|
expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' });
|
||||||
|
});
|
||||||
|
expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({
|
||||||
|
track_id: '00000000-0000-4000-8000-000000000101',
|
||||||
|
queue_index: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('my wave', () => {
|
||||||
|
it('starts the wave and switches the mood on the fly', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Бегаю' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Запустить «Мою волну»' }));
|
||||||
|
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument());
|
||||||
|
expect(api.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/wave/sessions')?.body).toEqual({
|
||||||
|
activity: 'running',
|
||||||
|
mood: null,
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: 'Пауза «Моей волны»' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Спокойное' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.calls.find((c) => c.method === 'PATCH' && c.path.startsWith('/api/v1/wave/sessions/'))?.body).toEqual({
|
||||||
|
activity: 'running',
|
||||||
|
mood: 'calm',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(within(bar).getByText('Группа крови')).toBeInTheDocument(); // музыка не прервалась
|
||||||
|
expect(screen.getByText('Бегаю · Спокойное')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeRedirect', () => {
|
||||||
|
it.each([
|
||||||
|
['/settings', '/settings'],
|
||||||
|
['//evil.example', '/'],
|
||||||
|
['https://evil.example', '/'],
|
||||||
|
[undefined, '/'],
|
||||||
|
])('%s → %s', (input, expected) => {
|
||||||
|
expect(safeRedirect(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+24
-51
@@ -1,57 +1,30 @@
|
|||||||
import { createApiClient, type ApiClient } from '@pipu/api-client';
|
import type { AuthSession } from '@pipu/core';
|
||||||
import { checkServer } from '@pipu/core';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { SUPPORTED_LOCALES } from '@pipu/i18n';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const defaultClient = createApiClient();
|
import type { createAppRouter } from './app/router';
|
||||||
|
import { SessionProvider } from './app/session';
|
||||||
|
|
||||||
export function App({ client = defaultClient }: { client?: ApiClient }) {
|
export function createQueryClient() {
|
||||||
const { t, i18n } = useTranslation();
|
return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
||||||
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
|
}
|
||||||
|
|
||||||
|
export function App({
|
||||||
|
session,
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
}: {
|
||||||
|
session: AuthSession;
|
||||||
|
router: ReturnType<typeof createAppRouter>;
|
||||||
|
queryClient?: QueryClient;
|
||||||
|
}) {
|
||||||
|
const [client] = useState(() => queryClient ?? createQueryClient());
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<SessionProvider session={session}>
|
||||||
<header className="top">
|
<QueryClientProvider client={client}>
|
||||||
<div className="logo">
|
<RouterProvider router={router} />
|
||||||
<span className="logo-dot" aria-hidden />
|
</QueryClientProvider>
|
||||||
{t('app.name')}
|
</SessionProvider>
|
||||||
</div>
|
|
||||||
<div className="langs" role="group" aria-label={t('language.label')}>
|
|
||||||
{SUPPORTED_LOCALES.map((l) => (
|
|
||||||
<button
|
|
||||||
key={l}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={i18n.language === l}
|
|
||||||
onClick={() => {
|
|
||||||
void i18n.changeLanguage(l);
|
|
||||||
document.documentElement.lang = l;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="main">
|
|
||||||
<section className="card">
|
|
||||||
<h1>{t('app.name')}</h1>
|
|
||||||
<p className="muted">{t('app.tagline')}</p>
|
|
||||||
{server.data?.status === 'ok' ? (
|
|
||||||
<div className="status" data-state="ok" role="status">
|
|
||||||
{t('server.online', { version: server.data.info.version })}
|
|
||||||
</div>
|
|
||||||
) : server.data ? (
|
|
||||||
<div className="status" data-state="error" role="status">
|
|
||||||
{t('server.offline')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="status" data-state="pending" role="status">
|
|
||||||
{t('server.checking')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import type { AuthSession, AuthUser } from '@pipu/core';
|
||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
type ParsedLocation,
|
||||||
|
} from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { AuditPage } from '../pages/admin/AuditPage';
|
||||||
|
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||||
|
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||||
|
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||||
|
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||||
|
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||||
|
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||||
|
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||||
|
import { UsersPage } from '../pages/admin/UsersPage';
|
||||||
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
|
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||||
|
import { SettingsPage } from '../pages/SettingsPage';
|
||||||
|
import { SetupPage } from '../pages/SetupPage';
|
||||||
|
import { AppShell } from '../widgets/AppShell';
|
||||||
|
import { RouteError } from '../widgets/RouteError';
|
||||||
|
|
||||||
|
export interface RouterContext {
|
||||||
|
session: AuthSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
|
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
|
||||||
|
if (user.must_change_credentials) return '/setup/admin';
|
||||||
|
if (user.must_change_password) return '/setup/password';
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedInUser(session: AuthSession): AuthUser | null {
|
||||||
|
return session.state.status === 'signed_in' ? session.state.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
|
||||||
|
export function safeRedirect(target: unknown): string {
|
||||||
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
||||||
|
const user = signedInUser(session);
|
||||||
|
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: Outlet,
|
||||||
|
errorComponent: RouteError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||||
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||||
|
|
||||||
|
const loginRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/login',
|
||||||
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
|
beforeLoad: ({ context, search }) => {
|
||||||
|
const user = signedInUser(context.session);
|
||||||
|
if (!user) return;
|
||||||
|
const home = homeFor(user);
|
||||||
|
if (home !== '/') throw redirect({ to: home });
|
||||||
|
throw redirect({ href: safeRedirect(search.redirect) });
|
||||||
|
},
|
||||||
|
component: LoginPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupAdminRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/setup/admin',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
|
},
|
||||||
|
component: () => <SetupPage variant="admin" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupPasswordRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/setup/password',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
|
},
|
||||||
|
component: () => <SetupPage variant="password" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
|
||||||
|
const appRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
id: 'app',
|
||||||
|
beforeLoad: ({ context, location }) => {
|
||||||
|
const user = requireUser(context.session, location);
|
||||||
|
const home = homeFor(user);
|
||||||
|
if (home !== '/') throw redirect({ to: home });
|
||||||
|
return { user };
|
||||||
|
},
|
||||||
|
component: AppShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||||
|
if (context.user.role !== 'admin') throw redirect({ to: '/' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
|
|
||||||
|
const usersRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/users',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (context.user.role === 'user') throw redirect({ to: '/' });
|
||||||
|
},
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/audit',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: AuditPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/history',
|
||||||
|
component: () => <CollectionPage tab="history" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const likesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/likes',
|
||||||
|
component: () => <CollectionPage tab="likes" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dislikesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/dislikes',
|
||||||
|
component: () => <CollectionPage tab="dislikes" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/search',
|
||||||
|
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
|
||||||
|
const type = search.type;
|
||||||
|
return {
|
||||||
|
q: typeof search.q === 'string' ? search.q : '',
|
||||||
|
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: function SearchRoute() {
|
||||||
|
const { q, type } = searchRoute.useSearch();
|
||||||
|
return <SearchPage q={q} type={type ?? 'all'} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/artist/$id',
|
||||||
|
component: function ArtistRoute() {
|
||||||
|
const { id } = artistRoute.useParams();
|
||||||
|
return <ArtistPage key={id} id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/album/$id',
|
||||||
|
component: function AlbumRoute() {
|
||||||
|
const { id } = albumRoute.useParams();
|
||||||
|
return <AlbumPage key={id} id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourcesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/sources',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: SourcesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patternsRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/sources/$id/patterns',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: function PatternsRoute() {
|
||||||
|
const { id } = patternsRoute.useParams();
|
||||||
|
return <PatternsPage id={id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const libraryRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/library',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (context.user.role === 'user') throw redirect({ to: '/' });
|
||||||
|
},
|
||||||
|
component: LibraryPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Предложение треков — модераторы и админ (docs/uploads.md#1). */
|
||||||
|
const staffOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||||
|
if (context.user.role === 'user') throw redirect({ to: '/' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/new', beforeLoad: staffOnly, component: SuggestPage });
|
||||||
|
|
||||||
|
const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/mine', beforeLoad: staffOnly, component: MyUploadsPage });
|
||||||
|
|
||||||
|
const uploadQueueRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/uploads',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: UploadQueuePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminSettingsRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/admin/settings',
|
||||||
|
beforeLoad: adminOnly,
|
||||||
|
component: AdminSettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
androidAppRoute,
|
||||||
|
loginRoute,
|
||||||
|
setupAdminRoute,
|
||||||
|
setupPasswordRoute,
|
||||||
|
appRoute.addChildren([
|
||||||
|
homeRoute,
|
||||||
|
settingsRoute,
|
||||||
|
searchRoute,
|
||||||
|
historyRoute,
|
||||||
|
likesRoute,
|
||||||
|
dislikesRoute,
|
||||||
|
artistRoute,
|
||||||
|
albumRoute,
|
||||||
|
usersRoute,
|
||||||
|
auditRoute,
|
||||||
|
sourcesRoute,
|
||||||
|
patternsRoute,
|
||||||
|
libraryRoute,
|
||||||
|
suggestRoute,
|
||||||
|
myUploadsRoute,
|
||||||
|
uploadQueueRoute,
|
||||||
|
adminSettingsRoute,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||||
|
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||||
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
|
session.subscribe(() => void router.invalidate());
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof createAppRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { AuthSession, AuthState, AuthUser } from '@pipu/core';
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||||
|
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const SessionContext = createContext<AuthSession | null>(null);
|
||||||
|
|
||||||
|
export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider value={session}>
|
||||||
|
<UserEffects session={session} />
|
||||||
|
{children}
|
||||||
|
</SessionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): AuthSession {
|
||||||
|
const s = useContext(SessionContext);
|
||||||
|
if (!s) throw new Error('useSession outside SessionProvider');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthState(): AuthState {
|
||||||
|
const session = useSession();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(fn) => session.subscribe(fn),
|
||||||
|
() => session.state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий пользователь; вызывать только на защищённых страницах. */
|
||||||
|
export function useUser(): AuthUser {
|
||||||
|
const state = useAuthState();
|
||||||
|
if (state.status !== 'signed_in') throw new Error('useUser: not signed in');
|
||||||
|
return state.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
|
||||||
|
|
||||||
|
/** Применяет настройки вошедшего пользователя: язык и тему. */
|
||||||
|
function UserEffects({ session }: { session: AuthSession }) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const state = useSyncExternalStore(
|
||||||
|
(fn) => session.subscribe(fn),
|
||||||
|
() => session.state,
|
||||||
|
);
|
||||||
|
const user = state.status === 'signed_in' ? state.user : null;
|
||||||
|
const locale = user?.locale;
|
||||||
|
const theme = user?.ui_prefs.theme;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locale && isLocale(locale) && locale !== i18n.language) void i18n.changeLanguage(locale);
|
||||||
|
}, [locale, i18n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тёмная тема по умолчанию (docs/design.md); system — по ОС через prefers-color-scheme (tokens.css). */
|
||||||
|
export function applyTheme(theme: string | undefined) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === 'system') delete root.dataset.theme;
|
||||||
|
else root.dataset.theme = theme === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.artistChip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
margin: -10px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.artistChip:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistChip {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCover {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMeta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc {
|
||||||
|
margin: 14px 12px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:has(.trackAlbum) {
|
||||||
|
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:has(.lead) {
|
||||||
|
grid-template-columns: 44px 44px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:has(.lead):has(.trackAlbum) {
|
||||||
|
grid-template-columns: 44px 44px minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimmed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq span {
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent2);
|
||||||
|
transform-origin: bottom;
|
||||||
|
animation: pipuEq 0.55s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq span:nth-child(2) {
|
||||||
|
animation-duration: 0.72s;
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq span:nth-child(3) {
|
||||||
|
animation-duration: 0.89s;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eqPaused span {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playHint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(5, 7, 13, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:not(.unavailable) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .playHint,
|
||||||
|
.track:focus-visible .playHint {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current .trackTitle {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .like,
|
||||||
|
.track:focus-within .like,
|
||||||
|
.liked {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liked {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackTitle {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackArtists,
|
||||||
|
.trackAlbum {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.track:has(.trackAlbum) {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackAlbum {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import type { PlayContext } from '../player/player';
|
||||||
|
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
|
export type Track = components['schemas']['Track'];
|
||||||
|
export type AlbumSummary = components['schemas']['AlbumSummary'];
|
||||||
|
export type ArtistRef = components['schemas']['ArtistRef'];
|
||||||
|
|
||||||
|
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||||
|
|
||||||
|
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
|
||||||
|
function fallbackColors(id: string): string[] {
|
||||||
|
let h = 0;
|
||||||
|
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
const base = h % 360;
|
||||||
|
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||||
|
export function coverSrc(url: string, cssPx: number): string {
|
||||||
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||||
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||||
|
return `${url}&size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
|
||||||
|
export function Cover({
|
||||||
|
album,
|
||||||
|
size = 48,
|
||||||
|
round,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||||
|
size?: number | string;
|
||||||
|
round?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(s.cover, round && s.round, className)}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
|
||||||
|
height: typeof size === 'number' ? size : undefined,
|
||||||
|
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{album.cover_url ? (
|
||||||
|
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Исполнители через запятую, каждый — ссылка. */
|
||||||
|
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{artists.map((a, i) => (
|
||||||
|
<Fragment key={a.id}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||||
|
{a.name}
|
||||||
|
</Link>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||||
|
return (
|
||||||
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||||
|
<Cover album={album} size="100%" className={s.cardCover} />
|
||||||
|
<span className={s.cardTitle}>{album.title}</span>
|
||||||
|
<span className={s.cardMeta}>
|
||||||
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
||||||
|
return (
|
||||||
|
<div className={s.grid}>
|
||||||
|
{albums.map((a) => (
|
||||||
|
<AlbumCard key={a.id} album={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
|
||||||
|
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
|
||||||
|
*/
|
||||||
|
export function TrackList({
|
||||||
|
tracks,
|
||||||
|
showAlbum,
|
||||||
|
numbered,
|
||||||
|
context,
|
||||||
|
rowMeta,
|
||||||
|
lead,
|
||||||
|
disliked,
|
||||||
|
}: {
|
||||||
|
tracks: Track[];
|
||||||
|
showAlbum?: boolean;
|
||||||
|
numbered?: boolean;
|
||||||
|
context: PlayContext;
|
||||||
|
/** Дополнительный текст под исполнителями. */
|
||||||
|
rowMeta?: (t: Track, i: number) => string | undefined;
|
||||||
|
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
|
||||||
|
lead?: (t: Track, i: number) => string;
|
||||||
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||||
|
disliked?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const { track: current, playing } = usePlayerState();
|
||||||
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
|
let lastDisc: number | null = null;
|
||||||
|
return (
|
||||||
|
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||||
|
{tracks.map((tr, i) => {
|
||||||
|
const disc = tr.disc_no ?? 1;
|
||||||
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||||
|
lastDisc = disc;
|
||||||
|
const isCurrent = current?.id === tr.id;
|
||||||
|
const play = () => tr.available && player.playList(tracks, i, context);
|
||||||
|
const liked = tr.reaction === 'like';
|
||||||
|
const meta = rowMeta?.(tr, i);
|
||||||
|
return (
|
||||||
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
tabIndex={tr.available ? 0 : -1}
|
||||||
|
aria-current={isCurrent || undefined}
|
||||||
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||||
|
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||||
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||||
|
{tr.available && (
|
||||||
|
<span className={s.playHint} aria-hidden>
|
||||||
|
<Icon src={playIcon} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||||
|
<span className={s.trackTitle}>{tr.title}</span>
|
||||||
|
<span className={s.trackArtists}>
|
||||||
|
<ArtistLinks artists={tr.artists} />
|
||||||
|
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{showAlbum && (
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||||
|
{tr.album.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className={s.end}>
|
||||||
|
{disliked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, s.liked)}
|
||||||
|
aria-label={t('player.undislike')}
|
||||||
|
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={undoIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, liked && s.liked)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
|
||||||
|
export function Equalizer({ playing }: { playing: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistChip({ artist }: { artist: ArtistRef }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
|
||||||
|
<Cover album={{ id: artist.id }} size={88} round />
|
||||||
|
<span className={s.cardTitle}>{artist.name}</span>
|
||||||
|
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
|
||||||
|
/** Сколько лайков и дизлайков — счётчики в меню (total из первой страницы списка). */
|
||||||
|
export function useCollectionCounts() {
|
||||||
|
const session = useSession();
|
||||||
|
const total = (path: '/api/v1/me/likes' | '/api/v1/me/dislikes') => ({
|
||||||
|
queryKey: ['collection-count', path],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET(path, { params: { query: { limit: 1 } } })).total,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const likes = useQuery(total('/api/v1/me/likes'));
|
||||||
|
const dislikes = useQuery(total('/api/v1/me/dislikes'));
|
||||||
|
return { likes: likes.data, dislikes: dislikes.data };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
|
||||||
|
|
||||||
|
export interface PickedFile {
|
||||||
|
file: File;
|
||||||
|
relPath: string; // «Кино/1988 - Группа крови/01 - Группа крови.flac» или просто имя файла
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
|
||||||
|
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
|
||||||
|
|
||||||
|
export function isAudio(name: string): boolean {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
return AUDIO_EXTENSIONS.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
|
||||||
|
export function fromInput(list: FileList | null): PickedFile[] {
|
||||||
|
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файлы из перетаскивания: папки обходятся рекурсивно (FileSystemEntry API). */
|
||||||
|
export async function fromDrop(dt: DataTransfer): Promise<PickedFile[]> {
|
||||||
|
const entries = [...dt.items]
|
||||||
|
.filter((it) => it.kind === 'file')
|
||||||
|
.map((it) => it.webkitGetAsEntry?.())
|
||||||
|
.filter((e): e is FileSystemEntry => !!e);
|
||||||
|
if (!entries.length) return fromInput(dt.files);
|
||||||
|
const out: PickedFile[] = [];
|
||||||
|
for (const e of entries) await walk(e, '', out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(entry: FileSystemEntry, prefix: string, out: PickedFile[]) {
|
||||||
|
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject));
|
||||||
|
out.push({ file, relPath: path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory || entry.name.startsWith('.')) return;
|
||||||
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
|
// readEntries отдаёт порциями (~100), пока не вернёт пустой массив.
|
||||||
|
for (;;) {
|
||||||
|
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||||
|
if (!batch.length) break;
|
||||||
|
for (const child of batch) await walk(child, path, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
|
||||||
|
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс.
|
||||||
|
import { createSHA256 } from 'hash-wasm';
|
||||||
|
|
||||||
|
export interface HashRequest {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HashResponse =
|
||||||
|
| { id: string; type: 'progress'; done: number }
|
||||||
|
| { id: string; type: 'done'; sha256: string }
|
||||||
|
| { id: string; type: 'error'; message: string };
|
||||||
|
|
||||||
|
const ctx = self as unknown as DedicatedWorkerGlobalScope;
|
||||||
|
|
||||||
|
ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
|
||||||
|
const { id, file } = e.data;
|
||||||
|
try {
|
||||||
|
const h = await createSHA256();
|
||||||
|
h.init();
|
||||||
|
const reader = file.stream().getReader();
|
||||||
|
let done = 0;
|
||||||
|
let lastReport = 0;
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
h.update(chunk.value);
|
||||||
|
done += chunk.value.byteLength;
|
||||||
|
if (done - lastReport > 8 << 20) {
|
||||||
|
lastReport = done;
|
||||||
|
ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { unwrap, type AuthSession } from '@pipu/core';
|
||||||
|
import * as tus from 'tus-js-client';
|
||||||
|
|
||||||
|
import { isAudio, type PickedFile } from './files';
|
||||||
|
import type { HashRequest, HashResponse } from './hash.worker';
|
||||||
|
|
||||||
|
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
|
||||||
|
// (ничего не резервирует) → POST /uploads (резерв квоты) → tus, до 4 файлов параллельно, куски по 8 МБ.
|
||||||
|
// Синглтон модуля: загрузка продолжается при переходах по приложению, но не переживает перезагрузку страницы.
|
||||||
|
|
||||||
|
export type Stage =
|
||||||
|
| 'hashing'
|
||||||
|
| 'checking'
|
||||||
|
| 'ready'
|
||||||
|
| 'duplicate'
|
||||||
|
| 'too_large'
|
||||||
|
| 'uploading'
|
||||||
|
| 'done'
|
||||||
|
| 'rejected'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface UploadItem {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
relPath: string;
|
||||||
|
stage: Stage;
|
||||||
|
progress: number; // 0..1 текущего этапа (хэш или загрузка)
|
||||||
|
sha256?: string;
|
||||||
|
trackId?: string | null;
|
||||||
|
possibleDuplicate?: boolean;
|
||||||
|
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
|
||||||
|
message?: string;
|
||||||
|
uploadFileId?: string;
|
||||||
|
tusUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploaderState {
|
||||||
|
items: UploadItem[];
|
||||||
|
skipped: number; // не аудио (обложки, .cue, …)
|
||||||
|
busy: boolean; // идёт загрузка
|
||||||
|
batchId: string | null;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARALLEL = 4;
|
||||||
|
const CHUNK = 8 << 20;
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
export class Uploader {
|
||||||
|
private state: UploaderState = { items: [], skipped: 0, busy: false, batchId: null, error: null };
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
private hashQueue: string[] = [];
|
||||||
|
private hashing = false;
|
||||||
|
private uploads = new Map<string, tus.Upload>();
|
||||||
|
private refreshing: Promise<unknown> | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly session: AuthSession) {}
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private set(patch: Partial<UploaderState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private patch(id: string, p: Partial<UploadItem>) {
|
||||||
|
this.set({ items: this.state.items.map((it) => (it.id === id ? { ...it, ...p } : it)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private item(id: string) {
|
||||||
|
return this.state.items.find((it) => it.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
|
||||||
|
add(files: PickedFile[]) {
|
||||||
|
if (this.state.busy) return;
|
||||||
|
const fresh: UploadItem[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
if (!isAudio(f.file.name)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||||
|
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
|
||||||
|
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||||
|
}
|
||||||
|
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
|
||||||
|
this.hashQueue.push(...fresh.map((it) => it.id));
|
||||||
|
void this.hashNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
if (this.state.busy) return;
|
||||||
|
this.hashQueue = this.hashQueue.filter((x) => x !== id);
|
||||||
|
this.set({ items: this.state.items.filter((it) => it.id !== id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Очистить список (после завершения или чтобы начать заново). */
|
||||||
|
clear() {
|
||||||
|
if (this.state.busy) return;
|
||||||
|
this.hashQueue = [];
|
||||||
|
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashWorker(): Worker {
|
||||||
|
this.worker ??= new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
return this.worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hashNext() {
|
||||||
|
if (this.hashing) return;
|
||||||
|
this.hashing = true;
|
||||||
|
try {
|
||||||
|
while (this.hashQueue.length) {
|
||||||
|
const id = this.hashQueue.shift()!;
|
||||||
|
const it = this.item(id);
|
||||||
|
if (!it) continue;
|
||||||
|
try {
|
||||||
|
const sha256 = await this.hash(it);
|
||||||
|
this.patch(id, { sha256, stage: 'checking', progress: 1 });
|
||||||
|
} catch (err) {
|
||||||
|
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.precheck();
|
||||||
|
} finally {
|
||||||
|
this.hashing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hash(it: UploadItem): Promise<string> {
|
||||||
|
const w = this.hashWorker();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const onMessage = (e: MessageEvent<HashResponse>) => {
|
||||||
|
const m = e.data;
|
||||||
|
if (m.id !== it.id) return;
|
||||||
|
if (m.type === 'progress') {
|
||||||
|
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.removeEventListener('message', onMessage);
|
||||||
|
if (m.type === 'done') resolve(m.sha256);
|
||||||
|
else reject(new Error(m.message));
|
||||||
|
};
|
||||||
|
w.addEventListener('message', onMessage);
|
||||||
|
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предпроверка всех посчитанных файлов: дубли отсеиваются до загрузки. */
|
||||||
|
private async precheck() {
|
||||||
|
const pending = this.state.items.filter((it) => it.stage === 'checking' && it.sha256);
|
||||||
|
if (!pending.length) return;
|
||||||
|
try {
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/uploads/precheck', {
|
||||||
|
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const r of res.files) {
|
||||||
|
const stage: Stage = r.result === 'accepted' ? 'ready' : r.result;
|
||||||
|
this.patch(r.client_id, { stage, trackId: r.track_id, possibleDuplicate: r.possible_duplicate });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Предпроверка — подсказка: при сбое файлы всё равно можно отправить, сервер проверит дубли сам.
|
||||||
|
for (const it of pending) this.patch(it.id, { stage: 'ready' });
|
||||||
|
this.set({ error: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready(): UploadItem[] {
|
||||||
|
return this.state.items.filter((it) => it.stage === 'ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать пачку (резерв квоты) и загрузить принятые файлы. */
|
||||||
|
async start(comment: string) {
|
||||||
|
const ready = this.ready;
|
||||||
|
if (this.state.busy || !ready.length) return;
|
||||||
|
this.set({ busy: true, error: null });
|
||||||
|
try {
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/uploads', {
|
||||||
|
body: {
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
files: ready.map((it) => ({
|
||||||
|
client_id: it.id,
|
||||||
|
name: it.file.name,
|
||||||
|
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
||||||
|
size: it.file.size,
|
||||||
|
sha256: it.sha256!,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.set({ batchId: res.batch_id });
|
||||||
|
const queue: { id: string; url: string }[] = [];
|
||||||
|
for (const f of res.files) {
|
||||||
|
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
|
||||||
|
else if (f.tus_url && f.upload_file_id) {
|
||||||
|
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
|
||||||
|
queue.push({ id: f.client_id, url: f.tus_url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||||
|
} catch (err) {
|
||||||
|
this.set({ error: err });
|
||||||
|
} finally {
|
||||||
|
this.set({ busy: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
|
||||||
|
async retryFailed() {
|
||||||
|
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl);
|
||||||
|
if (this.state.busy || !failed.length) return;
|
||||||
|
this.set({ busy: true, error: null });
|
||||||
|
try {
|
||||||
|
const queue = failed.map((it) => {
|
||||||
|
this.patch(it.id, { stage: 'uploading', message: undefined });
|
||||||
|
return { id: it.id, url: it.tusUrl! };
|
||||||
|
});
|
||||||
|
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||||
|
} finally {
|
||||||
|
this.set({ busy: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(queue: { id: string; url: string }[]) {
|
||||||
|
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private upload(id: string, url: string): Promise<void> {
|
||||||
|
const it = this.item(id);
|
||||||
|
if (!it) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const up = new tus.Upload(it.file, {
|
||||||
|
uploadUrl: url,
|
||||||
|
chunkSize: CHUNK,
|
||||||
|
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
||||||
|
storeFingerprintForResuming: false,
|
||||||
|
onBeforeRequest: async (req) => {
|
||||||
|
await this.refreshing;
|
||||||
|
const token = this.session.accessToken;
|
||||||
|
if (token) req.setHeader('Authorization', `Bearer ${token}`);
|
||||||
|
},
|
||||||
|
onShouldRetry: (err) => {
|
||||||
|
const status = (err as tus.DetailedError).originalResponse?.getStatus();
|
||||||
|
if (status === 401) {
|
||||||
|
this.refreshing = this.session.refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
|
||||||
|
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423;
|
||||||
|
},
|
||||||
|
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
this.uploads.delete(id);
|
||||||
|
this.patch(id, { stage: 'done', progress: 1 });
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
this.uploads.delete(id);
|
||||||
|
this.patch(id, { stage: 'error', message: err.message });
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.uploads.set(id, up);
|
||||||
|
up.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: { session: AuthSession; uploader: Uploader } | null = null;
|
||||||
|
|
||||||
|
/** Загрузчик текущей сессии (новый при смене пользователя). */
|
||||||
|
export function uploaderFor(session: AuthSession): Uploader {
|
||||||
|
if (instance?.session !== session) instance = { session, uploader: new Uploader(session) };
|
||||||
|
return instance.uploader;
|
||||||
|
}
|
||||||
+36
-5
@@ -1,12 +1,14 @@
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
import { createI18n, pickLocale } from '@pipu/i18n';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { createAppRouter } from './app/router';
|
||||||
|
import { applyTheme } from './app/session';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -19,9 +21,37 @@ function storedLocale(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «Chrome / Windows» — подпись сессии в списке устройств. */
|
||||||
|
function deviceName(): string {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const browser = /Edg\//.test(ua)
|
||||||
|
? 'Edge'
|
||||||
|
: /Firefox\//.test(ua)
|
||||||
|
? 'Firefox'
|
||||||
|
: /Chrome\//.test(ua)
|
||||||
|
? 'Chrome'
|
||||||
|
: /Safari\//.test(ua)
|
||||||
|
? 'Safari'
|
||||||
|
: 'Browser';
|
||||||
|
const os = /Windows/.test(ua)
|
||||||
|
? 'Windows'
|
||||||
|
: /Android/.test(ua)
|
||||||
|
? 'Android'
|
||||||
|
: /iPhone|iPad/.test(ua)
|
||||||
|
? 'iOS'
|
||||||
|
: /Mac OS X/.test(ua)
|
||||||
|
? 'macOS'
|
||||||
|
: /Linux/.test(ua)
|
||||||
|
? 'Linux'
|
||||||
|
: '';
|
||||||
|
return os ? `${browser} / ${os}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(undefined);
|
||||||
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
||||||
document.documentElement.lang = i18n.language;
|
document.documentElement.lang = i18n.language;
|
||||||
i18n.on('languageChanged', (l) => {
|
i18n.on('languageChanged', (l) => {
|
||||||
|
document.documentElement.lang = l;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LOCALE_KEY, l);
|
localStorage.setItem(LOCALE_KEY, l);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,14 +59,15 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
||||||
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
|
await session.restore();
|
||||||
|
const router = createAppRouter(session);
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<App session={session} router={router} />
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
|
import { Button } from '../shared/ui/ui';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function AlbumPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const album = useQuery({
|
||||||
|
queryKey: ['album', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
|
const a = album.data;
|
||||||
|
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
const ctx = { type: 'album' as const, id, label: a?.title };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||||
|
<Cover album={a ?? { id }} size={200} />
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('catalog.album')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
||||||
|
{a && (
|
||||||
|
<span className={c.heroMeta}>
|
||||||
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
|
{a.artist.name}
|
||||||
|
</Link>
|
||||||
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a && a.tracks.some((tr) => tr.available) && (
|
||||||
|
<div className={c.heroActions}>
|
||||||
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
|
{t('catalog.play')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={c.pill}
|
||||||
|
icon={shuffleIcon}
|
||||||
|
onClick={() =>
|
||||||
|
player.playList(
|
||||||
|
[...a.tracks].sort(() => Math.random() - 0.5),
|
||||||
|
0,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('collection.shuffle')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { formatBytes } from '../shared/lib/format';
|
||||||
|
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||||
|
import { Icon } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||||
|
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||||
|
*/
|
||||||
|
export function AppDownloadPage() {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const latest = useQuery({
|
||||||
|
queryKey: ['android-latest'],
|
||||||
|
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
const server = location.origin;
|
||||||
|
const copy = () =>
|
||||||
|
void navigator.clipboard?.writeText(server).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
const r = latest.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||||
|
<span className={s.badge}>Android</span>
|
||||||
|
<h1>{t('app.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
|
{latest.isPending ? null : r ? (
|
||||||
|
<>
|
||||||
|
<a className={s.download} href={r.download_url} download>
|
||||||
|
<Icon src={downloadIcon} size={22} />
|
||||||
|
<span>
|
||||||
|
{t('app.download')}
|
||||||
|
<span className={s.downloadMeta}>
|
||||||
|
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ol className={s.steps}>
|
||||||
|
<li>{t('app.step_install')}</li>
|
||||||
|
<li>
|
||||||
|
{t('app.step_server')}
|
||||||
|
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||||
|
<code>{server}</code>
|
||||||
|
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>{t('app.step_updates')}</li>
|
||||||
|
</ol>
|
||||||
|
<details className={s.details}>
|
||||||
|
<summary>{t('app.checksum')}</summary>
|
||||||
|
<code>{r.sha256}</code>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||||
|
import { Button } from '../shared/ui/ui';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function ArtistPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const artist = useQuery({
|
||||||
|
queryKey: ['artist', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const top = useQuery({
|
||||||
|
queryKey: ['artist-top', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/artists/{id}/top-tracks', { params: { path: { id }, query: { limit: 6 } } })).items,
|
||||||
|
});
|
||||||
|
const albums = useQuery({
|
||||||
|
queryKey: ['artist-albums', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
|
const a = artist.data;
|
||||||
|
const tracks = top.data ?? [];
|
||||||
|
const ctx = { type: 'artist' as const, id, label: a?.name };
|
||||||
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
<Cover album={{ id }} size={168} round />
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||||
|
{a && (
|
||||||
|
<span className={c.heroMeta}>
|
||||||
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a && (
|
||||||
|
<div className={c.heroActions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className={c.pill}
|
||||||
|
icon={waveIcon}
|
||||||
|
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
{t('catalog.artist_wave')}
|
||||||
|
</Button>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||||
|
{t('catalog.play')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
<h2>{t('catalog.top_tracks')}</h2>
|
||||||
|
<TrackList tracks={tracks} showAlbum context={ctx} lead={(_, i) => String(i + 1)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{own.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
<h2>{t('catalog.albums')}</h2>
|
||||||
|
<AlbumGrid albums={own} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{appears.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
<h2>{t('catalog.appears_on')}</h2>
|
||||||
|
<AlbumGrid albums={appears} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Logo } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
||||||
|
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={s.screen}>
|
||||||
|
<div className={s.backdrop} aria-hidden>
|
||||||
|
<span className={s.blob1} />
|
||||||
|
<span className={s.blob2} />
|
||||||
|
<span className={s.blob3} />
|
||||||
|
</div>
|
||||||
|
<header className={s.top}>
|
||||||
|
<Logo />
|
||||||
|
<LanguageSwitch />
|
||||||
|
</header>
|
||||||
|
<main className={s.center}>
|
||||||
|
<section className={s.card}>{children}</section>
|
||||||
|
{footer && <div className={s.footer}>{footer}</div>}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { TrackList, type Track } from '../entities/catalog';
|
||||||
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
||||||
|
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
|
||||||
|
|
||||||
|
const HERO: Record<CollectionTab, { icon: string; tile: string }> = {
|
||||||
|
history: { icon: historyFillIcon, tile: 'linear-gradient(140deg, #35D3FF, #2F80FF 60%, #1B3FA8)' },
|
||||||
|
likes: { icon: likeFillIcon, tile: 'linear-gradient(140deg, #5E9BFF, #2F80FF 50%, #7C5CFF)' },
|
||||||
|
dislikes: { icon: dislikeFillIcon, tile: 'linear-gradient(140deg, #4A5572, #1A2236)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const counts = useCollectionCounts();
|
||||||
|
const hero = HERO[tab];
|
||||||
|
const sub =
|
||||||
|
tab === 'likes'
|
||||||
|
? t('collection.sub_likes', { count: counts.likes ?? 0 })
|
||||||
|
: t(tab === 'history' ? 'collection.sub_history' : 'collection.sub_dislikes');
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.colHero}>
|
||||||
|
<span className={c.tile} style={{ background: hero.tile }} aria-hidden>
|
||||||
|
<Icon src={hero.icon} size={52} />
|
||||||
|
</span>
|
||||||
|
<div className={c.heroText}>
|
||||||
|
<span className={c.kicker}>{t('collection.title')}</span>
|
||||||
|
<h1 className={c.colTitle}>{t(`collection.${tab}`)}</h1>
|
||||||
|
<span className={c.heroSub}>{sub}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={c.underlineTabs} role="tablist" aria-label={t('collection.title')}>
|
||||||
|
{TABS.map((k) => (
|
||||||
|
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.underlineTab}>
|
||||||
|
{t(`collection.${k}`)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ дня в локальном времени: история группируется на клиенте. */
|
||||||
|
function dayKey(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
||||||
|
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
|
||||||
|
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
|
||||||
|
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function History() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const history = useInfiniteQuery({
|
||||||
|
queryKey: ['history'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
|
||||||
|
});
|
||||||
|
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
const groups: { key: string; title: string; items: typeof items }[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const key = dayKey(it.played_at);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last?.key === key) last.items.push(it);
|
||||||
|
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
|
||||||
|
}
|
||||||
|
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(true)}>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||||
|
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.key} className={c.section}>
|
||||||
|
<h2 className={c.day}>{g.title}</h2>
|
||||||
|
<TrackList
|
||||||
|
tracks={g.items.map((i) => i.track)}
|
||||||
|
showAlbum
|
||||||
|
context={{ type: 'history' }}
|
||||||
|
lead={(_, i) => time.format(new Date(g.items[i]!.played_at))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{history.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirm && (
|
||||||
|
<Dialog
|
||||||
|
title={t('collection.clear_history')}
|
||||||
|
onClose={() => setConfirm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||||
|
{t('form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirm(false);
|
||||||
|
clear.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('collection.clear_confirm')}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
|
||||||
|
const total = list.data?.pages[0]?.total ?? 0;
|
||||||
|
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{reaction === 'like' && tracks.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||||
|
{t('collection.play_all')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={c.pill}
|
||||||
|
icon={shuffleIcon}
|
||||||
|
onClick={() => player.playList([...tracks].sort(() => Math.random() - 0.5), 0, context)}
|
||||||
|
>
|
||||||
|
{t('collection.shuffle')}
|
||||||
|
</Button>
|
||||||
|
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
|
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||||
|
)}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<TrackList
|
||||||
|
tracks={tracks}
|
||||||
|
showAlbum
|
||||||
|
context={context}
|
||||||
|
lead={(_, i) => String(i + 1)}
|
||||||
|
disliked={reaction === 'dislike'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import {
|
||||||
|
pauseIcon,
|
||||||
|
playIcon,
|
||||||
|
waveBedtimeIcon,
|
||||||
|
waveBoltIcon,
|
||||||
|
waveCarIcon,
|
||||||
|
waveFitnessIcon,
|
||||||
|
waveHappyIcon,
|
||||||
|
waveHeadphonesIcon,
|
||||||
|
waveRainyIcon,
|
||||||
|
waveRunIcon,
|
||||||
|
waveSpaIcon,
|
||||||
|
waveSunIcon,
|
||||||
|
waveWeekendIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { cx, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||||
|
import s from './home.module.css';
|
||||||
|
import { Onboarding } from './Onboarding';
|
||||||
|
|
||||||
|
/** Иконки Material Symbols по именам из /wave/options. */
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
directions_run: waveRunIcon,
|
||||||
|
fitness_center: waveFitnessIcon,
|
||||||
|
wb_sunny: waveSunIcon,
|
||||||
|
headphones: waveHeadphonesIcon,
|
||||||
|
directions_car: waveCarIcon,
|
||||||
|
weekend: waveWeekendIcon,
|
||||||
|
bedtime: waveBedtimeIcon,
|
||||||
|
bolt: waveBoltIcon,
|
||||||
|
sentiment_satisfied: waveHappyIcon,
|
||||||
|
spa: waveSpaIcon,
|
||||||
|
rainy: waveRainyIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||||
|
|
||||||
|
type Tr = (key: string) => string;
|
||||||
|
|
||||||
|
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||||
|
export function HomePage() {
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const onboarding = useQuery({
|
||||||
|
queryKey: ['onboarding'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
if (onboarding.data?.required) {
|
||||||
|
return (
|
||||||
|
<Onboarding
|
||||||
|
options={onboarding.data}
|
||||||
|
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <WaveHome />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
||||||
|
function WaveHome() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tr = t as unknown as Tr;
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
||||||
|
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
|
||||||
|
|
||||||
|
const options = useQuery({
|
||||||
|
queryKey: ['wave-options'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const waveOn = st.wave !== null;
|
||||||
|
const playing = waveOn && st.playing;
|
||||||
|
const track = waveOn ? st.track : null;
|
||||||
|
const colors: [string, string, string] =
|
||||||
|
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
|
||||||
|
|
||||||
|
const run = (fn: () => Promise<void> | void) => {
|
||||||
|
setError(null);
|
||||||
|
void Promise.resolve(fn()).catch(setError);
|
||||||
|
};
|
||||||
|
const onWave = () => {
|
||||||
|
if (!waveOn) run(() => player.startWave(activity, mood));
|
||||||
|
else run(() => player.toggle());
|
||||||
|
};
|
||||||
|
const choose = (kind: 'activity' | 'mood', key: string) => {
|
||||||
|
const nextActivity = kind === 'activity' ? (activity === key ? null : key) : activity;
|
||||||
|
const nextMood = kind === 'mood' ? (mood === key ? null : key) : mood;
|
||||||
|
setActivity(nextActivity);
|
||||||
|
setMood(nextMood);
|
||||||
|
// Смена контекста во время игры — без остановки музыки.
|
||||||
|
if (waveOn) run(() => player.setWaveContext(nextActivity, nextMood));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctxLabel =
|
||||||
|
[activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') ||
|
||||||
|
t('wave.for_you');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.home}>
|
||||||
|
<WaveButton
|
||||||
|
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||||
|
playing={playing}
|
||||||
|
bpm={track?.features?.bpm ?? undefined}
|
||||||
|
energy={track?.features?.energy ?? undefined}
|
||||||
|
colors={colors}
|
||||||
|
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||||
|
size={320}
|
||||||
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||||
|
onClick={onWave}
|
||||||
|
>
|
||||||
|
<Icon src={playing ? pauseIcon : playIcon} size={44} />
|
||||||
|
<span className={s.orbLabel}>{t('wave.title')}</span>
|
||||||
|
<span className={s.orbContext}>{ctxLabel}</span>
|
||||||
|
</WaveButton>
|
||||||
|
|
||||||
|
{st.wave?.fallback && <p className={s.note}>{t('wave.fallback')}</p>}
|
||||||
|
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||||
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
|
||||||
|
{options.data && (
|
||||||
|
<div className={s.picker}>
|
||||||
|
<Chips
|
||||||
|
title={t('wave.activity_label')}
|
||||||
|
options={options.data.activities}
|
||||||
|
value={activity}
|
||||||
|
label={(k) => tr(`wave.activities.${k}`)}
|
||||||
|
onPick={(k) => choose('activity', k)}
|
||||||
|
/>
|
||||||
|
<Chips
|
||||||
|
title={t('wave.mood_label')}
|
||||||
|
options={options.data.moods}
|
||||||
|
value={mood}
|
||||||
|
label={(k) => tr(`wave.moods.${k}`)}
|
||||||
|
onPick={(k) => choose('mood', k)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MoodPicker: одно занятие и одно настроение; повторный клик снимает выбор. */
|
||||||
|
function Chips({
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
options: { key: string; icon: string }[];
|
||||||
|
value: string | null;
|
||||||
|
label: (key: string) => string;
|
||||||
|
onPick: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={s.group} aria-label={title}>
|
||||||
|
<h2 className={s.groupTitle}>{title}</h2>
|
||||||
|
<div className={s.chips}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className={cx(s.chip, value === o.key && s.chipOn)}
|
||||||
|
aria-pressed={value === o.key}
|
||||||
|
onClick={() => onPick(o.key)}
|
||||||
|
>
|
||||||
|
{ICONS[o.icon] && <Icon src={ICONS[o.icon]!} size={18} />}
|
||||||
|
{label(o.key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const info = useQuery({
|
||||||
|
queryKey: ['server-info'],
|
||||||
|
queryFn: async () => (await session.client.GET('/api/v1/server-info')).data ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Переход выполнит роутер: смена состояния сессии пересчитывает guards.
|
||||||
|
await session.login(username.trim(), password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
setPassword('');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
footer={
|
||||||
|
info.data ? (
|
||||||
|
<>
|
||||||
|
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1>{t('login.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||||
|
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('login.username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('login.password')}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className={s.submit}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!username.trim() || !password}
|
||||||
|
>
|
||||||
|
{t('login.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
/** Сущность каталога не найдена (удалена сканом) или не загрузилась. */
|
||||||
|
export function NotFound({ error }: { error: unknown }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const missing = error instanceof ApiError && error.status === 404;
|
||||||
|
return (
|
||||||
|
<div className={s.empty} role="alert">
|
||||||
|
<p>{missing ? t('catalog.not_found') : errorText(error)}</p>
|
||||||
|
<Link to="/">{t('catalog.back_home')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover } from '../entities/catalog';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { checkIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './onboarding.module.css';
|
||||||
|
|
||||||
|
type Options = components['schemas']['Onboarding'];
|
||||||
|
|
||||||
|
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
|
||||||
|
const MIN_ARTISTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
|
||||||
|
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
|
||||||
|
*/
|
||||||
|
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [artists, setArtists] = useState<Set<string>>(new Set());
|
||||||
|
const [genres, setGenres] = useState<Set<string>>(new Set());
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async (skip: boolean) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.PUT('/api/v1/me/onboarding', {
|
||||||
|
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: onDone,
|
||||||
|
});
|
||||||
|
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
|
||||||
|
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
|
||||||
|
update((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={s.head}>
|
||||||
|
<h1>{t('onboarding.title')}</h1>
|
||||||
|
<p>{t('onboarding.subtitle')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-label={t('onboarding.artists')}>
|
||||||
|
<h2 className={s.label}>{t('onboarding.artists')}</h2>
|
||||||
|
<div className={s.artists}>
|
||||||
|
{options.artists.map((a) => {
|
||||||
|
const on = artists.has(a.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className={cx(s.artist, on && s.on)}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => toggle(setArtists, a.id)}
|
||||||
|
>
|
||||||
|
<span className={s.avatar}>
|
||||||
|
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
|
||||||
|
{on && (
|
||||||
|
<span className={s.check}>
|
||||||
|
<Icon src={checkIcon} size={22} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={s.name}>{a.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{options.genres.length > 0 && (
|
||||||
|
<section aria-label={t('onboarding.genres')}>
|
||||||
|
<h2 className={s.label}>{t('onboarding.genres')}</h2>
|
||||||
|
<div className={s.genres}>
|
||||||
|
{options.genres.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.name}
|
||||||
|
type="button"
|
||||||
|
className={s.genre}
|
||||||
|
aria-pressed={genres.has(g.name)}
|
||||||
|
onClick={() => toggle(setGenres, g.name)}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
<div className={s.actions}>
|
||||||
|
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
|
||||||
|
{t('onboarding.skip')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
|
||||||
|
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, FormError } from '../shared/ui/ui';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
|
||||||
|
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
|
||||||
|
|
||||||
|
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const query = q.trim();
|
||||||
|
|
||||||
|
const results = useInfiniteQuery({
|
||||||
|
queryKey: ['search', query, type],
|
||||||
|
enabled: query !== '',
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam, signal }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/search', {
|
||||||
|
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const pages = results.data?.pages ?? [];
|
||||||
|
const first = pages[0];
|
||||||
|
const artists = pages.flatMap((p) => p.artists);
|
||||||
|
const albums = pages.flatMap((p) => p.albums);
|
||||||
|
const tracks = pages.flatMap((p) => p.tracks);
|
||||||
|
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
|
||||||
|
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('search.title', { query })}</h1>
|
||||||
|
{first?.corrected_query && (
|
||||||
|
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
|
||||||
|
{TYPES.map((k) => (
|
||||||
|
<Link
|
||||||
|
key={k}
|
||||||
|
to="/search"
|
||||||
|
search={{ q, type: k === 'all' ? undefined : k }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={type === k}
|
||||||
|
className={c.tab}
|
||||||
|
>
|
||||||
|
{t(`search.${k}`)}
|
||||||
|
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
||||||
|
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
|
||||||
|
|
||||||
|
{artists.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.artists')}</h2>}
|
||||||
|
<div className={c.artistRow}>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<ArtistChip key={a.id} artist={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.albums')}</h2>}
|
||||||
|
<AlbumGrid albums={albums} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<section className={c.section}>
|
||||||
|
{type === 'all' && <h2>{t('search.tracks')}</h2>}
|
||||||
|
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{results.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
|
||||||
|
{t('search.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||||
|
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={`${s.page} ${s.narrow}`}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<h1>{t('settings.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<ProfileSection />
|
||||||
|
<AppearanceSection />
|
||||||
|
<PasswordSection />
|
||||||
|
<SessionsSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [name, setName] = useState(user.display_name ?? '');
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
|
||||||
|
onSuccess: (u) => session.setUser(u),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.account')}</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={s.row}>
|
||||||
|
<Field
|
||||||
|
label={t('settings.display_name')}
|
||||||
|
value={name}
|
||||||
|
onChange={(v) => {
|
||||||
|
setName(v);
|
||||||
|
save.reset();
|
||||||
|
}}
|
||||||
|
maxLength={64}
|
||||||
|
hint={`@${user.username}`}
|
||||||
|
/>
|
||||||
|
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
|
||||||
|
{t('settings.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const theme: Theme = user.ui_prefs.theme ?? 'dark';
|
||||||
|
const wave: WaveVariant = user.ui_prefs.wave_variant ?? 'orb';
|
||||||
|
// Оптимистично: настройка применяется сразу, сервер подтверждает.
|
||||||
|
const setPrefs = async (prefs: components['schemas']['UiPrefs']) => {
|
||||||
|
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
|
||||||
|
try {
|
||||||
|
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
|
||||||
|
} catch {
|
||||||
|
session.setUser(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const setTheme = (value: Theme) => setPrefs({ theme: value });
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.appearance')}</h2>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.theme')}</span>
|
||||||
|
<Segmented<Theme>
|
||||||
|
label={t('settings.theme')}
|
||||||
|
value={theme}
|
||||||
|
onChange={(v) => void setTheme(v)}
|
||||||
|
options={[
|
||||||
|
{ value: 'dark', label: t('settings.theme_dark') },
|
||||||
|
{ value: 'light', label: t('settings.theme_light') },
|
||||||
|
{ value: 'system', label: t('settings.theme_system') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.wave_variant')}</span>
|
||||||
|
<Segmented<WaveVariant>
|
||||||
|
label={t('settings.wave_variant')}
|
||||||
|
value={wave}
|
||||||
|
onChange={(v) => void setPrefs({ wave_variant: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'orb', label: t('settings.wave_orb') },
|
||||||
|
{ value: 'liquid', label: t('settings.wave_liquid') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.player_view')}</span>
|
||||||
|
<Segmented<'bar' | 'island'>
|
||||||
|
label={t('settings.player_view')}
|
||||||
|
value={user.ui_prefs.web_player ?? 'bar'}
|
||||||
|
onChange={(v) => void setPrefs({ web_player: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'bar', label: t('settings.player_bar') },
|
||||||
|
{ value: 'island', label: t('settings.player_island') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.search_position')}</span>
|
||||||
|
<Segmented<'left' | 'center' | 'right'>
|
||||||
|
label={t('settings.search_position')}
|
||||||
|
value={user.ui_prefs.search_position ?? 'center'}
|
||||||
|
onChange={(v) => void setPrefs({ search_position: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: t('settings.search_left') },
|
||||||
|
{ value: 'center', label: t('settings.search_center') },
|
||||||
|
{ value: 'right', label: t('settings.search_right') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.ambient')}</span>
|
||||||
|
<Segmented<'on' | 'off'>
|
||||||
|
label={t('settings.ambient')}
|
||||||
|
value={user.ui_prefs.ambient === false ? 'off' : 'on'}
|
||||||
|
onChange={(v) => void setPrefs({ ambient: v === 'on' })}
|
||||||
|
options={[
|
||||||
|
{ value: 'on', label: t('settings.on') },
|
||||||
|
{ value: 'off', label: t('settings.off') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('language.label')}</span>
|
||||||
|
<LanguageSwitch />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordSection() {
|
||||||
|
const { t } = useTranslation(['common', 'auth']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const mismatch = repeat !== '' && repeat !== next;
|
||||||
|
|
||||||
|
const change = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
|
||||||
|
),
|
||||||
|
onSuccess: async (tokens) => {
|
||||||
|
await session.apply(tokens);
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const err = change.error;
|
||||||
|
const currentError = fieldError(err, 'current_password');
|
||||||
|
const nextError = fieldError(err, 'new_password');
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!mismatch) change.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.password')}</h2>
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('settings.current_password')}
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={setCurrent}
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={currentError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.new_password')}
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={setNext}
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
|
||||||
|
error={nextError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.repeat_password')}
|
||||||
|
type="password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={setRepeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
|
||||||
|
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
|
||||||
|
<div className={s.actions}>
|
||||||
|
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
|
||||||
|
{t('settings.change_password')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsSection() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const sessions = useQuery({
|
||||||
|
queryKey: ['sessions'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
|
||||||
|
});
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: async (id: string) =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('settings.sessions')}</h2>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
{sessions.data?.map((ss) => (
|
||||||
|
<div key={ss.id} className={s.session}>
|
||||||
|
<div>
|
||||||
|
<div>{ss.device_name ?? ss.client_type}</div>
|
||||||
|
<div className={s.sessionMeta}>
|
||||||
|
{ss.current
|
||||||
|
? t('settings.session_current')
|
||||||
|
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!ss.current && (
|
||||||
|
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
|
||||||
|
{t('settings.session_end')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
|
<div>
|
||||||
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
|
{t('settings.logout_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
|
import { lockResetIcon, shieldIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/** Обязательный шаг после входа: новые логин и пароль админа (admin) или замена временного пароля (password). */
|
||||||
|
export function SetupPage({ variant }: { variant: 'admin' | 'password' }) {
|
||||||
|
return useAuthState().status === 'signed_in' ? <SetupForm variant={variant} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetupForm({ variant }: { variant: 'admin' | 'password' }) {
|
||||||
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const admin = variant === 'admin';
|
||||||
|
const minLength = user.role === 'user' ? 8 : 12;
|
||||||
|
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const mismatch = repeat !== '' && repeat !== password;
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mismatch) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = admin
|
||||||
|
? await session.client.POST('/api/v1/me/bootstrap-credentials', {
|
||||||
|
body: { new_username: username.trim(), new_password: password },
|
||||||
|
})
|
||||||
|
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
|
||||||
|
await session.apply(unwrap(res));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usernameError = fieldError(error, 'new_username');
|
||||||
|
const passwordError = fieldError(error, 'new_password');
|
||||||
|
const generalError = error && !usernameError && !passwordError ? errorText(error) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<span className={s.badge}>
|
||||||
|
<Icon src={admin ? shieldIcon : lockResetIcon} size={16} />
|
||||||
|
{t(admin ? 'setup.admin_badge' : 'setup.password_badge')}
|
||||||
|
</span>
|
||||||
|
<h1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</h1>
|
||||||
|
<p className={s.lead}>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</p>
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
{admin && (
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
error={usernameError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
label={t('setup.new_password')}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint={t('setup.min_length', { min: minLength })}
|
||||||
|
error={passwordError}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('setup.repeat_password')}
|
||||||
|
type="password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={setRepeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
|
||||||
|
/>
|
||||||
|
<FormError>{generalError}</FormError>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className={s.submit}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!password || password !== repeat || (admin && !username.trim())}
|
||||||
|
>
|
||||||
|
{t('setup.submit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className={s.link} onClick={() => void session.logout()}>
|
||||||
|
{t('setup.logout')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import { UploadSettingsSection } from './UploadQueuePage';
|
||||||
|
|
||||||
|
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
|
||||||
|
export function AdminSettingsPage() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('server_settings.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('server_settings.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UploadSettingsSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
|
import { Button, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
|
||||||
|
type Entry = components['schemas']['AuditEntry'];
|
||||||
|
|
||||||
|
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
|
||||||
|
function details(e: Entry, roleName: (r: string) => string): string {
|
||||||
|
const p = e.payload ?? {};
|
||||||
|
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||||
|
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||||
|
(v): v is string => typeof v === 'string' && v !== '',
|
||||||
|
);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['admin-audit'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('audit.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('audit.col_when')}</th>
|
||||||
|
<th>{t('audit.col_who')}</th>
|
||||||
|
<th>{t('audit.col_action')}</th>
|
||||||
|
<th>{t('audit.col_details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
|
||||||
|
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
|
||||||
|
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
|
||||||
|
<td className={s.muted}>{details(e, roleName)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
|
||||||
|
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||||
|
</div>
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('audit.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
|
|
||||||
|
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
||||||
|
export function LibraryPage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const admin = user.role === 'admin';
|
||||||
|
|
||||||
|
const status = useQuery({
|
||||||
|
queryKey: ['library-status'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
|
||||||
|
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
|
||||||
|
});
|
||||||
|
const errors = useQuery({
|
||||||
|
queryKey: ['library-errors'],
|
||||||
|
enabled: admin,
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
||||||
|
});
|
||||||
|
const reindex = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||||
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||||
|
});
|
||||||
|
const d = status.data;
|
||||||
|
const tiles = d
|
||||||
|
? ([
|
||||||
|
['artists', d.artists],
|
||||||
|
['albums', d.albums],
|
||||||
|
['tracks', d.tracks],
|
||||||
|
['files', d.files],
|
||||||
|
['missing', d.missing_files],
|
||||||
|
['errors', d.file_errors],
|
||||||
|
] as const)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('library.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('library.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
{admin && (
|
||||||
|
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
|
||||||
|
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
|
||||||
|
<div className={l.tiles}>
|
||||||
|
{tiles.map(([k, v]) => (
|
||||||
|
<div key={k} className={l.tile}>
|
||||||
|
<span className={l.tileValue}>{v.toLocaleString()}</span>
|
||||||
|
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{d && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.analysis')}</h2>
|
||||||
|
<ScanLineBar
|
||||||
|
done={d.analysis.done}
|
||||||
|
total={d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed}
|
||||||
|
label={t('library.analysis_progress', {
|
||||||
|
done: d.analysis.done,
|
||||||
|
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{d.analysis.failed > 0 && (
|
||||||
|
<div className={l.retryRow}>
|
||||||
|
<span className={l.scanError}>
|
||||||
|
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||||
|
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||||
|
</span>
|
||||||
|
{admin && (
|
||||||
|
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||||
|
{t('library.analysis_retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{d && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.wave')}</h2>
|
||||||
|
<div className={l.tiles}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['wave_served', d.wave.served.toLocaleString()],
|
||||||
|
['wave_sessions', d.wave.sessions.toLocaleString()],
|
||||||
|
['wave_listen', pct(d.wave.listen_through_rate)],
|
||||||
|
['wave_early_skip', pct(d.wave.early_skip_rate)],
|
||||||
|
['wave_likes', d.wave.like_rate === null ? '—' : d.wave.like_rate.toFixed(1)],
|
||||||
|
] as const
|
||||||
|
).map(([k, v]) => (
|
||||||
|
<div key={k} className={l.tile}>
|
||||||
|
<span className={l.tileValue}>{v}</span>
|
||||||
|
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{d.wave.by_model.length > 1 && (
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{t('library.wave_model')}</th>
|
||||||
|
<th scope="col">{t('library.wave_served')}</th>
|
||||||
|
<th scope="col">{t('library.wave_listen')}</th>
|
||||||
|
<th scope="col">{t('library.wave_early_skip')}</th>
|
||||||
|
<th scope="col">{t('library.wave_likes')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{d.wave.by_model.map((m) => (
|
||||||
|
<tr key={m.model_ver}>
|
||||||
|
<td>{m.model_ver || '—'}</td>
|
||||||
|
<td>{m.served.toLocaleString()}</td>
|
||||||
|
<td>{pct(m.listen_through_rate)}</td>
|
||||||
|
<td>{pct(m.early_skip_rate)}</td>
|
||||||
|
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{d && d.sources.length > 0 && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.sources')}</h2>
|
||||||
|
{d.sources.map((src) => (
|
||||||
|
<div key={src.id} className={l.cardFoot}>
|
||||||
|
<div className={l.cardTitle}>
|
||||||
|
{src.name} <StatusBadge status={src.status} />
|
||||||
|
</div>
|
||||||
|
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{admin && errors.data && errors.data.items.length > 0 && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('library.error_files')}</h2>
|
||||||
|
{errors.data.items.map((e) => (
|
||||||
|
<div key={e.file_id} className={l.cardFoot}>
|
||||||
|
<span className={l.location}>{e.rel_path}</span>
|
||||||
|
<span className={l.scanError}>{e.error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%`);
|
||||||
|
|
||||||
|
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||||
|
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<div className={l.scan}>
|
||||||
|
<div className={l.scanText} id={id}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
import { WriteTemplateSection } from './WriteTemplateSection';
|
||||||
|
|
||||||
|
type Pattern = components['schemas']['PathPattern'];
|
||||||
|
type Preview = components['schemas']['PatternPreview'];
|
||||||
|
|
||||||
|
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
|
||||||
|
const EXAMPLES = [
|
||||||
|
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
||||||
|
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
||||||
|
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ошибка компиляции из 422: номер шаблона и причина. */
|
||||||
|
function patternError(err: unknown): { index: number; reason: string } | null {
|
||||||
|
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
|
||||||
|
return { index: Number(err.params.index), reason: String(err.params.reason) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternsPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const source = useQuery({
|
||||||
|
queryKey: ['source', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
const saved = useQuery({
|
||||||
|
queryKey: ['patterns', id],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
|
||||||
|
});
|
||||||
|
const [rows, setRows] = useState<Pattern[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (saved.data) setRows(saved.data);
|
||||||
|
}, [saved.data]);
|
||||||
|
|
||||||
|
const preview = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { patterns: rows, sample: 50 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { patterns: rows },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (r) => setRows(r.patterns),
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = save.error ?? preview.error;
|
||||||
|
const perr = patternError(err);
|
||||||
|
const update = (i: number, p: Partial<Pattern>) => {
|
||||||
|
save.reset();
|
||||||
|
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
|
||||||
|
};
|
||||||
|
const move = (i: number, d: -1 | 1) =>
|
||||||
|
setRows((rs) => {
|
||||||
|
const next = [...rs];
|
||||||
|
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
|
||||||
|
<p className={s.subtitle}>
|
||||||
|
<Link to="/admin/sources">{t('sources.title')}</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className={s.section}>
|
||||||
|
<p className={s.note}>{t('patterns.intro')}</p>
|
||||||
|
<p className={l.groups}>{t('patterns.groups')}</p>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className={l.patternRow}>
|
||||||
|
<span className={l.patternNum}>{i + 1}</span>
|
||||||
|
<Field
|
||||||
|
label={t('patterns.regex', { n: i + 1 })}
|
||||||
|
value={r.regex}
|
||||||
|
onChange={(v) => update(i, { regex: v })}
|
||||||
|
maxLength={1000}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
mono
|
||||||
|
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
|
||||||
|
/>
|
||||||
|
<div className={l.patternTools} style={{ marginTop: 22 }}>
|
||||||
|
<label className={l.check}>
|
||||||
|
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
|
||||||
|
{t('patterns.enabled')}
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
|
||||||
|
<Icon src={arrowUpIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t('patterns.down')}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<Icon src={arrowDownIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t('patterns.remove')}
|
||||||
|
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
|
||||||
|
>
|
||||||
|
<Icon src={deleteIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
|
||||||
|
{t('patterns.add')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
|
||||||
|
>
|
||||||
|
{t('patterns.examples')}
|
||||||
|
</Button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
|
||||||
|
{t('patterns.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('patterns.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
|
||||||
|
<FormError>{err && !perr ? errorText(err) : null}</FormError>
|
||||||
|
</section>
|
||||||
|
{preview.data && <PreviewTable preview={preview.data} />}
|
||||||
|
{source.data && <WriteTemplateSection source={source.data} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTable({ preview }: { preview: Preview }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const fields = ['artist', 'album', 'title'] as const;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
|
||||||
|
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('patterns.col_path')}</th>
|
||||||
|
<th>{t('patterns.col_pattern')}</th>
|
||||||
|
<th>{t('patterns.col_result')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.items.map((it) => (
|
||||||
|
<tr key={it.rel_path}>
|
||||||
|
<td className={l.match}>{it.rel_path}</td>
|
||||||
|
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
|
||||||
|
{it.skipped
|
||||||
|
? t('patterns.skipped')
|
||||||
|
: it.pattern_index === null
|
||||||
|
? t('patterns.no_match')
|
||||||
|
: `#${it.pattern_index + 1}`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={l.result}>
|
||||||
|
{fields.map((f) => {
|
||||||
|
const fromPath = f in it.path_fields;
|
||||||
|
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
|
||||||
|
return (
|
||||||
|
<span key={f}>
|
||||||
|
<b>{it.result[f]}</b>{' '}
|
||||||
|
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span className={l.fromTags}>
|
||||||
|
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
{it.error && <span className={l.scanError}>{it.error}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
|
import { cx } from '../../shared/ui/ui';
|
||||||
|
import s from './library.module.css';
|
||||||
|
|
||||||
|
type ScanProgress = components['schemas']['ScanProgress'];
|
||||||
|
export type SourceStatus = components['schemas']['SourceStatus'];
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: SourceStatus }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return <span className={cx(s.status, s[`st_${status}`])}>{t(`sources.status_${status}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прогресс идущего скана или итог последнего. */
|
||||||
|
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const id = useId();
|
||||||
|
if (scan?.running) {
|
||||||
|
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className={s.scan}>
|
||||||
|
<div className={s.scanText} id={id}>
|
||||||
|
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||||
|
</div>
|
||||||
|
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={s.scan}>
|
||||||
|
<div className={s.scanText}>
|
||||||
|
{lastScanAt ? t('sources.last_scan', { date: formatDateTime(lastScanAt, i18n.language) }) : t('sources.never_scanned')}
|
||||||
|
</div>
|
||||||
|
{scan?.error ? (
|
||||||
|
<div className={s.scanError}>{t('sources.scan_failed', { error: scan.error })}</div>
|
||||||
|
) : (
|
||||||
|
scan && <div className={s.scanSummary}>{t('sources.scan_done', { ...scan })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
|
|
||||||
|
type Source = components['schemas']['Source'];
|
||||||
|
type TestResult = components['schemas']['SourceTestResult'];
|
||||||
|
|
||||||
|
const SCAN_POLL_MS = 2000;
|
||||||
|
|
||||||
|
export function SourcesPage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [editing, setEditing] = useState<Source | 'new' | null>(null);
|
||||||
|
|
||||||
|
const sources = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||||
|
// Пока идёт скан — опрашиваем прогресс.
|
||||||
|
refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('sources.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('sources.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
|
||||||
|
{t('sources.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||||
|
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||||
|
<div className={l.list}>
|
||||||
|
{sources.data?.map((src) => (
|
||||||
|
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(src: Source): string {
|
||||||
|
if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`;
|
||||||
|
return `LIBRARY_HOST/${src.local_path ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [test, setTest] = useState<TestResult | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const id = source.id;
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
|
||||||
|
const scan = useMutation({
|
||||||
|
mutationFn: async (full: boolean) =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const check = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })),
|
||||||
|
onSuccess: setTest,
|
||||||
|
});
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: async (enabled: boolean) =>
|
||||||
|
unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const error = scan.error ?? check.error ?? patch.error ?? remove.error;
|
||||||
|
const running = source.scan?.running ?? false;
|
||||||
|
const disabled = source.status === 'disabled';
|
||||||
|
|
||||||
|
const menu: MenuItem[] = [
|
||||||
|
{ label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) },
|
||||||
|
{ label: t('sources.edit'), onSelect: onEdit },
|
||||||
|
disabled
|
||||||
|
? { label: t('sources.enable'), onSelect: () => patch.mutate(true) }
|
||||||
|
: { label: t('sources.disable'), onSelect: () => patch.mutate(false) },
|
||||||
|
{ label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={l.card} aria-label={source.name}>
|
||||||
|
<div className={l.cardHead}>
|
||||||
|
<div className={l.cardTitle}>
|
||||||
|
{source.name}
|
||||||
|
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
|
||||||
|
<StatusBadge status={source.status} />
|
||||||
|
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
|
||||||
|
</div>
|
||||||
|
<span className={l.location}>{location(source)}</span>
|
||||||
|
{source.status === 'error' && source.status_message && (
|
||||||
|
<span className={l.message}>{source.status_message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={l.cardActions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
busy={scan.isPending}
|
||||||
|
disabled={running || source.status !== 'mounted'}
|
||||||
|
onClick={() => scan.mutate(false)}
|
||||||
|
>
|
||||||
|
{t('sources.scan')}
|
||||||
|
</Button>
|
||||||
|
<Button busy={check.isPending} onClick={() => check.mutate()}>
|
||||||
|
{t('sources.test')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
|
||||||
|
{t('sources.patterns')}
|
||||||
|
</Button>
|
||||||
|
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
|
||||||
|
</div>
|
||||||
|
<div className={l.cardFoot}>
|
||||||
|
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||||
|
{test && (
|
||||||
|
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
||||||
|
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{test && test.write_ok !== null && test.write_ok !== undefined && (
|
||||||
|
<span className={`${l.test} ${test.write_ok ? l.testOk : l.testFail}`} role="status">
|
||||||
|
{test.write_ok ? t('write_template.write_ok') : t('write_template.write_fail', { message: test.write_message ?? '' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<FormError>{error ? errorText(error) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
{confirmDelete && (
|
||||||
|
<Dialog
|
||||||
|
title={t('sources.delete')}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmDelete(false);
|
||||||
|
remove.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('users.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVALS = [0, 60, 360, 1440];
|
||||||
|
|
||||||
|
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local');
|
||||||
|
const [name, setName] = useState(source?.name ?? '');
|
||||||
|
const [localPath, setLocalPath] = useState(source?.local_path ?? '');
|
||||||
|
const [server, setServer] = useState(source?.nfs_server ?? '');
|
||||||
|
const [exportPath, setExportPath] = useState(source?.nfs_export ?? '');
|
||||||
|
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
|
||||||
|
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
|
||||||
|
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const common = {
|
||||||
|
name: name.trim(),
|
||||||
|
tag_priority: priority,
|
||||||
|
...(type === 'local'
|
||||||
|
? { local_path: localPath }
|
||||||
|
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
|
||||||
|
};
|
||||||
|
if (source)
|
||||||
|
return unwrap(
|
||||||
|
await session.client.PATCH('/api/v1/admin/sources/{id}', {
|
||||||
|
params: { path: { id: source.id } },
|
||||||
|
body: { ...common, scan_interval_minutes: interval },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/sources', {
|
||||||
|
body: { ...common, type, scan_interval_minutes: interval || null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const err = save.error;
|
||||||
|
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
|
||||||
|
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
|
||||||
|
{t('sources.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
|
||||||
|
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
|
||||||
|
{!source && (
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.type')}</span>
|
||||||
|
<Segmented
|
||||||
|
label={t('sources.type')}
|
||||||
|
value={type}
|
||||||
|
onChange={setType}
|
||||||
|
options={[
|
||||||
|
{ value: 'local', label: t('sources.type_local') },
|
||||||
|
{ value: 'nfs', label: t('sources.type_nfs') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'local' ? (
|
||||||
|
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_server')}
|
||||||
|
value={server}
|
||||||
|
onChange={setServer}
|
||||||
|
hint={t('sources.nfs_server_hint')}
|
||||||
|
error={fieldError(err, 'nfs_server')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_export')}
|
||||||
|
value={exportPath}
|
||||||
|
onChange={setExportPath}
|
||||||
|
hint={t('sources.nfs_export_hint')}
|
||||||
|
error={fieldError(err, 'nfs_export')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('sources.nfs_options')}
|
||||||
|
value={options}
|
||||||
|
onChange={setOptions}
|
||||||
|
hint={t('sources.nfs_options_hint')}
|
||||||
|
error={fieldError(err, 'nfs_options')}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||||
|
<Segmented
|
||||||
|
label={t('sources.tag_priority')}
|
||||||
|
value={priority}
|
||||||
|
onChange={setPriority}
|
||||||
|
options={[
|
||||||
|
{ value: 'tags_first', label: t('sources.tags_first') },
|
||||||
|
{ value: 'path_first', label: t('sources.path_first') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.interval')}</span>
|
||||||
|
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
|
||||||
|
{[...new Set([...INTERVALS, interval])].map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<FormError>{general}</FormError>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */
|
||||||
|
function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const session = useSession();
|
||||||
|
const dirs = useQuery({
|
||||||
|
queryKey: ['host-dirs', value],
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })),
|
||||||
|
});
|
||||||
|
const parts = value ? value.split('/') : [];
|
||||||
|
return (
|
||||||
|
<div className={l.field}>
|
||||||
|
<span className={l.label}>{t('sources.folder')}</span>
|
||||||
|
<div className={l.picker}>
|
||||||
|
<div className={l.crumbs}>
|
||||||
|
<button type="button" className={l.dir} onClick={() => onChange('')}>
|
||||||
|
LIBRARY_HOST
|
||||||
|
</button>
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<span key={i} className={l.crumbs}>
|
||||||
|
<Icon src={chevronRightIcon} size={14} />
|
||||||
|
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dirs.data?.dirs.map((d) => (
|
||||||
|
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
|
||||||
|
<Icon src={folderIcon} size={18} className={l.dirIcon} />
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { ApiError, unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||||
|
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
||||||
|
import u from '../uploads/uploads.module.css';
|
||||||
|
|
||||||
|
type Meta = components['schemas']['UploadMeta'];
|
||||||
|
type Settings = components['schemas']['UploadSettings'];
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
review: ['pending_review'],
|
||||||
|
failed: ['import_failed'],
|
||||||
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
||||||
|
imported: ['imported'],
|
||||||
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||||
|
} satisfies Record<string, UploadStatus[]>;
|
||||||
|
type Tab = keyof typeof TABS;
|
||||||
|
|
||||||
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
||||||
|
export function UploadQueuePage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [tab, setTab] = useState<Tab>('review');
|
||||||
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['admin-uploads', tab, onlyPossible],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/admin/uploads', {
|
||||||
|
params: {
|
||||||
|
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
refetchInterval: (q) =>
|
||||||
|
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||||
|
});
|
||||||
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||||
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
};
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/uploads/review', {
|
||||||
|
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: async (id: string) =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
||||||
|
|
||||||
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
||||||
|
const batches = useMemo(() => {
|
||||||
|
const m = new Map<string, UploadFile[]>();
|
||||||
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
||||||
|
return [...m.values()];
|
||||||
|
}, [items]);
|
||||||
|
const toggle = (ids: string[], on: boolean) =>
|
||||||
|
setSelected((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const selectable = tab === 'review' || tab === 'failed';
|
||||||
|
const ids = [...selected];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('uploads.queue_title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<QuotaMeter />
|
||||||
|
</div>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||||
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
||||||
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
||||||
|
{t(`uploads.tab_${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
||||||
|
{t('uploads.only_possible')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||||
|
)}
|
||||||
|
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
||||||
|
|
||||||
|
{batches.map((files) => (
|
||||||
|
<BatchCard
|
||||||
|
key={files[0]!.batch_id}
|
||||||
|
files={files}
|
||||||
|
selectable={selectable}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggle}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onRetry={(id) => retry.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('uploads.load_more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ids.length > 0 && (
|
||||||
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
||||||
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
{tab === 'review' && (
|
||||||
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
||||||
|
{t('uploads.approve')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditDialog
|
||||||
|
file={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
review.reset();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rejecting && (
|
||||||
|
<RejectDialog
|
||||||
|
count={ids.length}
|
||||||
|
busy={review.isPending}
|
||||||
|
onClose={() => setRejecting(false)}
|
||||||
|
onReject={(reason) => {
|
||||||
|
setRejecting(false);
|
||||||
|
review.mutate({ decision: 'reject', ids, reason });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
||||||
|
function problemError(p: components['schemas']['Problem']) {
|
||||||
|
return new ApiError(p.status, p.code, p.params ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchCard({
|
||||||
|
files,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
files: UploadFile[];
|
||||||
|
selectable: boolean;
|
||||||
|
selected: Set<string>;
|
||||||
|
onToggle: (ids: string[], on: boolean) => void;
|
||||||
|
onEdit: (f: UploadFile) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const first = files[0]!;
|
||||||
|
const ids = files.map((f) => f.id);
|
||||||
|
const all = ids.every((id) => selected.has(id));
|
||||||
|
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
||||||
|
return (
|
||||||
|
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||||
|
<div className={u.batchHead}>
|
||||||
|
{selectable && (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||||
|
)}
|
||||||
|
<b>{first.author.username}</b>
|
||||||
|
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||||
|
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||||
|
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||||
|
</div>
|
||||||
|
{files.map((f) => (
|
||||||
|
<QueueRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
selectable={selectable}
|
||||||
|
checked={selected.has(f.id)}
|
||||||
|
onCheck={(on) => onToggle([f.id], on)}
|
||||||
|
onEdit={() => onEdit(f)}
|
||||||
|
onRetry={() => onRetry(f.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueRow({
|
||||||
|
file: f,
|
||||||
|
selectable,
|
||||||
|
checked,
|
||||||
|
onCheck,
|
||||||
|
onEdit,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
file: UploadFile;
|
||||||
|
selectable: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
onCheck: (on: boolean) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const reason = useReasonText()(f.status_reason);
|
||||||
|
const m = f.metadata;
|
||||||
|
const quality = [
|
||||||
|
f.format?.toUpperCase(),
|
||||||
|
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
||||||
|
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
||||||
|
f.bit_depth ? `${f.bit_depth} bit` : null,
|
||||||
|
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
||||||
|
formatBytes(f.size_bytes, i18n.language),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
||||||
|
return (
|
||||||
|
<div className={u.queueRow}>
|
||||||
|
{selectable ? (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className={u.meta}>
|
||||||
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||||
|
{m && (
|
||||||
|
<span className={u.sub}>
|
||||||
|
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={u.sub}>{quality}</span>
|
||||||
|
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
||||||
|
{f.client_rel_path ?? f.original_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={u.meta}>
|
||||||
|
<div className={u.flags}>
|
||||||
|
<UploadStatusBadge status={f.status} />
|
||||||
|
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||||
|
{f.matched_album_id ? (
|
||||||
|
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||||
|
) : (
|
||||||
|
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||||
|
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
||||||
|
{f.duplicate_level && f.status === 'duplicate' && (
|
||||||
|
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={u.rowActions}>
|
||||||
|
{editable && (
|
||||||
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||||
|
<Icon src={editIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{f.status === 'import_failed' && (
|
||||||
|
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
||||||
|
{t('uploads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||||
|
|
||||||
|
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
||||||
|
const [source, setSource] = useState(file.target_source_id ?? '');
|
||||||
|
const sources = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||||
|
});
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
|
||||||
|
params: { path: { id: file.id } },
|
||||||
|
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
||||||
|
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
||||||
|
);
|
||||||
|
const num = (k: 'year' | 'track' | 'disc') => (
|
||||||
|
<Field
|
||||||
|
label={t(`uploads.meta_${k}`)}
|
||||||
|
value={m[k] === null ? '' : String(m[k])}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.edit_title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
||||||
|
<div className={u.grid}>
|
||||||
|
{text('artist')}
|
||||||
|
{text('albumartist')}
|
||||||
|
{text('album')}
|
||||||
|
{text('title')}
|
||||||
|
{text('genre')}
|
||||||
|
{num('year')}
|
||||||
|
{num('track')}
|
||||||
|
{num('disc')}
|
||||||
|
</div>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.target_source')}</span>
|
||||||
|
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||||
|
<option value="">{t('uploads.target_default')}</option>
|
||||||
|
{accepting.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={s.note}>{t('uploads.tags_note')}</p>
|
||||||
|
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectDialog({
|
||||||
|
count,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.reject_title', { count })}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.reject_reason')}</span>
|
||||||
|
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GB = 1024 ** 3;
|
||||||
|
|
||||||
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||||
|
export function UploadSettingsSection() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const settings = useQuery({
|
||||||
|
queryKey: ['upload-settings'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
|
||||||
|
});
|
||||||
|
const sources = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.data) setForm(toForm(settings.data));
|
||||||
|
}, [settings.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const f = form!;
|
||||||
|
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
||||||
|
return unwrap(
|
||||||
|
await session.client.PATCH('/api/v1/admin/settings/uploads', {
|
||||||
|
body: {
|
||||||
|
queue_limit_bytes: gb(f.queue),
|
||||||
|
max_file_bytes: gb(f.maxFile),
|
||||||
|
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||||
|
default_source_id: f.source || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
queryClient.setQueryData(['upload-settings'], data);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!form) return null;
|
||||||
|
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
||||||
|
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
||||||
|
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('uploads.settings_title')}</h2>
|
||||||
|
<div className={u.grid}>
|
||||||
|
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||||
|
<Field
|
||||||
|
label={t('uploads.set_per_user')}
|
||||||
|
value={form.perUser}
|
||||||
|
hint={t('uploads.set_per_user_hint')}
|
||||||
|
onChange={(v) => setForm({ ...form, perUser: v })}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.set_source')}</span>
|
||||||
|
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||||
|
<option value="">{t('uploads.set_source_auto')}</option>
|
||||||
|
{sources.data
|
||||||
|
?.filter((x) => x.accepts_uploads)
|
||||||
|
.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(st: Settings) {
|
||||||
|
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
||||||
|
return {
|
||||||
|
queue: gb(st.queue_limit_bytes),
|
||||||
|
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||||
|
maxFile: gb(st.max_file_bytes),
|
||||||
|
source: st.default_source_id ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { formatRelative } from '../../shared/lib/format';
|
||||||
|
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||||
|
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
|
||||||
|
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
|
||||||
|
type AdminUser = components['schemas']['AdminUser'];
|
||||||
|
|
||||||
|
const ROLES: Role[] = ['admin', 'moderator', 'user'];
|
||||||
|
|
||||||
|
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms: number): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(u: AdminUser): 'blocked' | 'must_change' | 'active' {
|
||||||
|
if (u.is_blocked) return 'blocked';
|
||||||
|
if (u.must_change_password || u.must_change_credentials) return 'must_change';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const me = useUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [role, setRole] = useState<Role | null>(null);
|
||||||
|
const query = useDebounced(q.trim(), 200);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||||
|
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['admin-users', query, role],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/admin/users', {
|
||||||
|
params: { query: { q: query || undefined, role: role ?? undefined, cursor: pageParam, limit: 50 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const users = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id: string; body: components['schemas']['UpdateUserRequest'] }) =>
|
||||||
|
unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: async (u: AdminUser) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/users/{id}/reset-password', { params: { path: { id: u.id } } }),
|
||||||
|
),
|
||||||
|
onSuccess: (r, u) => {
|
||||||
|
setSecret({ username: u.username, password: r.temporary_password });
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: async (u: AdminUser) =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
|
||||||
|
onSuccess: refresh,
|
||||||
|
onError: setActionError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runConfirm = () => {
|
||||||
|
if (!confirm) return;
|
||||||
|
const { kind, user } = confirm;
|
||||||
|
setConfirm(null);
|
||||||
|
setActionError(null);
|
||||||
|
if (kind === 'reset') reset.mutate(user);
|
||||||
|
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
|
||||||
|
if (kind === 'delete') remove.mutate(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||||
|
if (can('reset_password'))
|
||||||
|
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||||
|
if (can('block'))
|
||||||
|
items.push(
|
||||||
|
u.is_blocked
|
||||||
|
? { label: t('users.unblock'), onSelect: () => patch.mutate({ id: u.id, body: { is_blocked: false } }) }
|
||||||
|
: { label: t('users.block'), onSelect: () => setConfirm({ kind: 'block', user: u }) },
|
||||||
|
);
|
||||||
|
if (can('change_role'))
|
||||||
|
for (const r of ROLES.filter((r) => r !== u.role))
|
||||||
|
items.push({
|
||||||
|
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
|
||||||
|
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
|
||||||
|
});
|
||||||
|
if (can('delete'))
|
||||||
|
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('users.title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('users.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" icon={addIcon} onClick={() => setCreating(true)}>
|
||||||
|
{t('users.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<label className={s.search}>
|
||||||
|
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t('users.search')}
|
||||||
|
aria-label={t('users.search')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('users.col_role')}>
|
||||||
|
<button type="button" className={s.chip} aria-pressed={role === null} onClick={() => setRole(null)}>
|
||||||
|
{t('users.all')}
|
||||||
|
</button>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<button key={r} type="button" className={s.chip} aria-pressed={role === r} onClick={() => setRole(r)}>
|
||||||
|
{t(`common:roles.${r}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
|
||||||
|
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('users.col_user')}</th>
|
||||||
|
<th>{t('users.col_role')}</th>
|
||||||
|
<th>{t('users.col_status')}</th>
|
||||||
|
<th>{t('users.col_last_login')}</th>
|
||||||
|
<th aria-hidden />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const items = actionsFor(u);
|
||||||
|
const st = status(u);
|
||||||
|
return (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div className={s.userCell}>
|
||||||
|
<Avatar name={u.display_name ?? u.username} />
|
||||||
|
<div>
|
||||||
|
<div className={s.userName}>
|
||||||
|
{u.display_name ?? u.username}
|
||||||
|
{u.id === me.id && <span className={s.you}>({t('users.you')})</span>}
|
||||||
|
</div>
|
||||||
|
<div className={s.userLogin}>@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
|
||||||
|
</td>
|
||||||
|
<td className={s.muted}>
|
||||||
|
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
|
||||||
|
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||||
|
</div>
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('users.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateUserDialog
|
||||||
|
actorRole={me.role}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(username, password) => {
|
||||||
|
setCreating(false);
|
||||||
|
setSecret({ username, password });
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirm && (
|
||||||
|
<Dialog
|
||||||
|
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
|
||||||
|
{t('users.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secret && <TemporaryPasswordDialog {...secret} onClose={() => setSecret(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserDialog({
|
||||||
|
actorRole,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
actorRole: Role;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (username: string, password: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const roles = assignableRoles(actorRole);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [role, setRole] = useState<Role>('user');
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/users', {
|
||||||
|
body: { username: username.trim(), display_name: name.trim() || undefined, role },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
|
||||||
|
});
|
||||||
|
const usernameError = fieldError(create.error, 'username');
|
||||||
|
const nameError = fieldError(create.error, 'display_name');
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
create.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('create.title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="create-user" variant="primary" busy={create.isPending} disabled={!username.trim()}>
|
||||||
|
{t('create.submit')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="create-user" onSubmit={submit} noValidate className={s.sessions}>
|
||||||
|
<Field
|
||||||
|
label={t('create.username')}
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
hint={t('create.username_hint')}
|
||||||
|
error={usernameError}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field label={t('create.display_name')} value={name} onChange={setName} maxLength={64} error={nameError} />
|
||||||
|
{roles.length > 1 && (
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span className={s.subtitle}>{t('create.role')}</span>
|
||||||
|
<select className={s.select} value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{t(`common:roles.${r}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<p className={s.note}>{t('create.password_note')}</p>
|
||||||
|
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(password);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
// Буфер обмена недоступен (http, права) — пароль виден на экране.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('temp_password.title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
{t('temp_password.done')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('temp_password.text', { username })}</p>
|
||||||
|
<div className={s.secret}>
|
||||||
|
<span data-testid="temporary-password">{password}</span>
|
||||||
|
<Button variant="ghost" icon={copied ? checkIcon : copyIcon} onClick={() => void copy()}>
|
||||||
|
{copied ? t('common:form.copied') : t('common:form.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { Button, cx, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import l from './library.module.css';
|
||||||
|
|
||||||
|
type Source = components['schemas']['Source'];
|
||||||
|
type Check = components['schemas']['WriteTemplateCheck'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приём загрузок и шаблон пути новых файлов (docs/uploads.md#7): автоподбор по существующей структуре
|
||||||
|
* и проверка обратимости регэкспами хранилища. Сохранить можно и с расхождениями — админ видит предупреждение.
|
||||||
|
*/
|
||||||
|
export function WriteTemplateSection({ source }: { source: Source }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const id = source.id;
|
||||||
|
const [accepts, setAccepts] = useState(source.accepts_uploads);
|
||||||
|
const [template, setTemplate] = useState(source.write_template ?? '');
|
||||||
|
const [check, setCheck] = useState<Check | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setAccepts(source.accepts_uploads);
|
||||||
|
setTemplate(source.write_template ?? '');
|
||||||
|
}, [source.accepts_uploads, source.write_template]);
|
||||||
|
|
||||||
|
const runCheck = useMutation({
|
||||||
|
mutationFn: async (tpl: string) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { template: tpl },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: setCheck,
|
||||||
|
});
|
||||||
|
const suggest = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
|
||||||
|
.template,
|
||||||
|
onSuccess: (tpl) => {
|
||||||
|
setTemplate(tpl);
|
||||||
|
runCheck.mutate(tpl);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.PATCH('/api/v1/admin/sources/{id}', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { accepts_uploads: accepts, write_template: template },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: async (src) => {
|
||||||
|
queryClient.setQueryData(['source', id], src);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = save.error ?? suggest.error ?? runCheck.error;
|
||||||
|
const tplError = fieldError(err, 'write_template') ?? fieldError(err, 'template');
|
||||||
|
const dirty = accepts !== source.accepts_uploads || template !== (source.write_template ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('write_template.title')}</h2>
|
||||||
|
<p className={s.note}>{t('write_template.intro')}</p>
|
||||||
|
<label className={l.checkRow}>
|
||||||
|
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
|
||||||
|
<span>{t('write_template.accepts')}</span>
|
||||||
|
</label>
|
||||||
|
{accepts && source.type === 'nfs' && <p className={s.note}>{t('write_template.nfs_rw_hint')}</p>}
|
||||||
|
<Field
|
||||||
|
label={t('write_template.template')}
|
||||||
|
value={template}
|
||||||
|
onChange={(v) => {
|
||||||
|
setTemplate(v);
|
||||||
|
setCheck(null);
|
||||||
|
save.reset();
|
||||||
|
}}
|
||||||
|
hint={t('write_template.hint')}
|
||||||
|
error={tplError}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
|
||||||
|
{t('write_template.suggest')}
|
||||||
|
</Button>
|
||||||
|
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
|
||||||
|
{t('write_template.check')}
|
||||||
|
</Button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || (accepts && !template.trim())} onClick={() => save.mutate()}>
|
||||||
|
{t('write_template.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{accepts && !template.trim() && <p className={s.note}>{t('write_template.required')}</p>}
|
||||||
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('write_template.saved')}</span>}
|
||||||
|
<FormError>{err && !tplError ? errorText(err) : null}</FormError>
|
||||||
|
{check && <CheckResult check={check} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckResult({ check }: { check: Check }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
if (check.total === 0) return <p className={s.note}>{t('write_template.no_tracks')}</p>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={cx(s.note, check.ok ? s.ok : l.scanError)} role="status">
|
||||||
|
{t(check.ok ? 'write_template.check_ok' : 'write_template.check_warn', {
|
||||||
|
round_trip: check.round_trip,
|
||||||
|
same_path: check.same_path,
|
||||||
|
total: check.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{check.mismatches.length > 0 && (
|
||||||
|
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('write_template.col_real')}</th>
|
||||||
|
<th>{t('write_template.col_rendered')}</th>
|
||||||
|
<th>{t('write_template.col_problem')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{check.mismatches.map((m) => (
|
||||||
|
<tr key={m.rel_path}>
|
||||||
|
<td className={l.match}>{m.rel_path}</td>
|
||||||
|
<td className={l.match}>{m.rendered ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
{m.error ??
|
||||||
|
(m.fields.length
|
||||||
|
? m.fields.map((f) => (f === 'pattern' ? t('write_template.no_pattern') : f)).join(', ')
|
||||||
|
: t('write_template.other_path'))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 14px 20px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFoot {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_mounted {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_pending {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st_disabled {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanText,
|
||||||
|
.scanSummary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanError {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorDetail {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text3);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--violet));
|
||||||
|
transition: width 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testOk {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.testFail {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirIcon {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileValue {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternNum {
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patternTools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMatch {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fromTags {
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--amb-op);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop span {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob1 {
|
||||||
|
width: 62vmax;
|
||||||
|
height: 62vmax;
|
||||||
|
left: -18vmax;
|
||||||
|
top: -24vmax;
|
||||||
|
background: radial-gradient(circle, rgba(47, 128, 255, 0.34), transparent 65%);
|
||||||
|
animation: drift1 16s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob2 {
|
||||||
|
width: 55vmax;
|
||||||
|
height: 55vmax;
|
||||||
|
right: -20vmax;
|
||||||
|
bottom: -26vmax;
|
||||||
|
background: radial-gradient(circle, rgba(124, 92, 255, 0.3), transparent 65%);
|
||||||
|
animation: drift2 21s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob3 {
|
||||||
|
width: 40vmax;
|
||||||
|
height: 40vmax;
|
||||||
|
left: 40%;
|
||||||
|
top: 30%;
|
||||||
|
background: radial-gradient(circle, rgba(53, 211, 255, 0.16), transparent 65%);
|
||||||
|
animation: drift1 26s ease-in-out infinite alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift1 {
|
||||||
|
to {
|
||||||
|
transform: translate(6vmax, 4vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift2 {
|
||||||
|
to {
|
||||||
|
transform: translate(-5vmax, -6vmax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.backdrop span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 36px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: -6px 0 4px;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--violet-soft);
|
||||||
|
color: var(--violet);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Страница Android-приложения (/android). */
|
||||||
|
.download {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:hover {
|
||||||
|
background: var(--accent-fill2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadMeta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artistRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab[aria-selected='true'] {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab[aria-selected='true'] .tabCount {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 28px;
|
||||||
|
margin: -32px -36px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroBody {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(28px, 4vw, 48px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta a {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: -20px -16px 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Шапка коллекции (макет «Коллекция»): плитка-иконка, надзаголовок, крупный заголовок. */
|
||||||
|
.colHero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text2);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underlineTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underlineTab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underlineTab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underlineTab[aria-selected='true'] {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.home {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none; /* главная — только элементы управления волной */
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbLabel {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbContext {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: -12px 0 0;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip > span {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--surface3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipOn,
|
||||||
|
.chipOn:hover {
|
||||||
|
background: var(--accent-fill);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipOn > span {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.on .avatar {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px var(--bg),
|
||||||
|
0 0 0 5px var(--accent);
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genres {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre[aria-pressed='true'] {
|
||||||
|
background: var(--accent-fill);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-a);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px 0 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface2);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed='true'] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Горизонтальная прокрутка только там, где таблица не помещается: она обрезала бы меню действий. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userCell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLogin {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.you {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_admin {
|
||||||
|
background: var(--violet-soft);
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_moderator {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role_user {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_active {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_blocked {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_must_change {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px dashed var(--line2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { formatBytes, formatDateTime } from '../../shared/lib/format';
|
||||||
|
import { addIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, cx, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
|
||||||
|
import u from './uploads.module.css';
|
||||||
|
|
||||||
|
const FILTERS = {
|
||||||
|
all: undefined,
|
||||||
|
pending: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'import_failed'],
|
||||||
|
imported: ['imported'],
|
||||||
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||||
|
} satisfies Record<string, UploadStatus[] | undefined>;
|
||||||
|
type Filter = keyof typeof FILTERS;
|
||||||
|
|
||||||
|
const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review'];
|
||||||
|
|
||||||
|
/** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */
|
||||||
|
export function MyUploadsPage() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ['my-uploads', filter],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/uploads/mine', {
|
||||||
|
params: { query: { status: FILTERS[filter] ? [...FILTERS[filter]] : undefined, cursor: pageParam, limit: 50 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
// Обработка и импорт идут в фоне — пока есть такие файлы, обновляем список.
|
||||||
|
refetchInterval: (q) =>
|
||||||
|
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||||
|
});
|
||||||
|
const withdraw = useMutation({
|
||||||
|
mutationFn: async (id: string) =>
|
||||||
|
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('uploads.mine_title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('uploads.mine_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<QuotaMeter />
|
||||||
|
</div>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||||
|
{(Object.keys(FILTERS) as Filter[]).map((f) => (
|
||||||
|
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => setFilter(f)}>
|
||||||
|
{t(`uploads.filter_${f}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Link to="/uploads/new">
|
||||||
|
<Button variant="primary" icon={addIcon}>
|
||||||
|
{t('uploads.suggest_title')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
|
||||||
|
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('uploads.col_file')}</th>
|
||||||
|
<th>{t('uploads.col_status')}</th>
|
||||||
|
<th>{t('uploads.col_size')}</th>
|
||||||
|
<th>{t('uploads.col_date')}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((f) => (
|
||||||
|
<MineRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
busy={withdraw.isPending && withdraw.variables === f.id}
|
||||||
|
onWithdraw={() => withdraw.mutate(f.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('uploads.load_more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolean; onWithdraw: () => void }) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
// Технические подробности ошибки импорта — для админа; автору достаточно причины.
|
||||||
|
const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason;
|
||||||
|
const reason = useReasonText()(raw);
|
||||||
|
const m = f.metadata;
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className={u.trackCell}>
|
||||||
|
<div className={u.meta}>
|
||||||
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||||
|
<span className={cx(u.sub, u.ellipsis)} title={f.client_rel_path ?? f.original_name}>
|
||||||
|
{f.client_rel_path ?? f.original_name}
|
||||||
|
</span>
|
||||||
|
{f.status === 'imported' && f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={u.meta}>
|
||||||
|
<UploadStatusBadge status={f.status} />
|
||||||
|
{f.status === 'uploading' && (
|
||||||
|
<span className={u.sub}>{t('uploads.received', { pct: Math.round((f.received_bytes / Math.max(f.size_bytes, 1)) * 100) })}</span>
|
||||||
|
)}
|
||||||
|
{f.status === 'duplicate' && <span className={u.sub}>{t('uploads.dup_found')}</span>}
|
||||||
|
{reason && f.status !== 'pending_review' && <span className={u.bad}>{reason}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={s.muted}>{formatBytes(f.size_bytes, i18n.language)}</td>
|
||||||
|
<td className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
|
||||||
|
<td>
|
||||||
|
{WITHDRAWABLE.includes(f.status) && (
|
||||||
|
<Button variant="ghost" busy={busy} onClick={onWithdraw}>
|
||||||
|
{t('uploads.withdraw')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
|
||||||
|
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { formatBytes } from '../../shared/lib/format';
|
||||||
|
import { closeIcon, folderIcon, musicIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, cx, FormError, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import { QuotaMeter, useUploadQuota } from './common';
|
||||||
|
import u from './uploads.module.css';
|
||||||
|
|
||||||
|
const ACCEPT = AUDIO_EXTENSIONS.map((e) => `.${e}`).join(',');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
|
||||||
|
* резерв места → загрузка по tus с докачкой. Дубли отсеиваются до загрузки и не тратят квоту.
|
||||||
|
*/
|
||||||
|
export function SuggestPage() {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const uploader = uploaderFor(session);
|
||||||
|
const st = useSyncExternalStore(uploader.subscribe, uploader.getState);
|
||||||
|
const quota = useUploadQuota();
|
||||||
|
const [drag, setDrag] = useState(false);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const filesRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dirRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// webkitdirectory нет в типах React — ставим атрибут напрямую.
|
||||||
|
useEffect(() => {
|
||||||
|
dirRef.current?.setAttribute('webkitdirectory', '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ready = st.items.filter((it) => it.stage === 'ready');
|
||||||
|
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
|
||||||
|
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
|
||||||
|
const finished = !st.busy && st.batchId !== null;
|
||||||
|
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
|
||||||
|
const uploaded = st.items.filter((it) => it.stage === 'done').length;
|
||||||
|
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
|
||||||
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
|
|
||||||
|
const onDrop = async (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(false);
|
||||||
|
uploader.add(await fromDrop(e.dataTransfer));
|
||||||
|
};
|
||||||
|
const start = async () => {
|
||||||
|
await uploader.start(comment);
|
||||||
|
setComment('');
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('uploads.suggest_title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('uploads.suggest_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<QuotaMeter />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx(u.drop, drag && u.dropActive)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDrag(false)}
|
||||||
|
onDrop={(e) => void onDrop(e)}
|
||||||
|
>
|
||||||
|
<Icon src={musicIcon} size={36} />
|
||||||
|
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
|
||||||
|
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
|
||||||
|
<div className={u.dropActions}>
|
||||||
|
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
|
||||||
|
{t('uploads.pick_files')}
|
||||||
|
</Button>
|
||||||
|
<Button icon={folderIcon} disabled={st.busy} onClick={() => dirRef.current?.click()}>
|
||||||
|
{t('uploads.pick_folder')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={filesRef}
|
||||||
|
className={u.hiddenInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPT}
|
||||||
|
onChange={(e) => {
|
||||||
|
uploader.add(fromInput(e.target.files));
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={dirRef}
|
||||||
|
className={u.hiddenInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => {
|
||||||
|
uploader.add(fromInput(e.target.files));
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
|
||||||
|
|
||||||
|
{st.items.length > 0 && (
|
||||||
|
<div className={u.files} aria-label={t('uploads.files')}>
|
||||||
|
{st.items.map((it) => (
|
||||||
|
<FileRow key={it.id} item={it} locked={st.busy || finished} onRemove={() => uploader.remove(it.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{st.items.length > 0 && !finished && (
|
||||||
|
<section className={s.section}>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.comment')}</span>
|
||||||
|
<textarea
|
||||||
|
className={u.textarea}
|
||||||
|
value={comment}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder={t('uploads.comment_hint')}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{free !== null && readyBytes > free && (
|
||||||
|
<p className={u.warn}>{t('uploads.not_enough_space', { need: fmt(readyBytes), free: fmt(free) })}</p>
|
||||||
|
)}
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="ghost" disabled={st.busy} onClick={() => uploader.clear()}>
|
||||||
|
{t('uploads.clear')}
|
||||||
|
</Button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" busy={st.busy} disabled={hashing || !ready.length} onClick={() => void start()}>
|
||||||
|
{st.busy
|
||||||
|
? t('uploads.uploading_now')
|
||||||
|
: hashing
|
||||||
|
? t('uploads.hashing_wait')
|
||||||
|
: t('uploads.send', { count: ready.length, size: fmt(readyBytes) })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finished && (
|
||||||
|
<section className={s.section} role="status">
|
||||||
|
{uploaded > 0 && <p className={s.ok}>{t('uploads.sent', { count: uploaded })}</p>}
|
||||||
|
{failedUploads > 0 && <p className={u.bad}>{t('uploads.failed_some', { count: failedUploads })}</p>}
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
{failedUploads > 0 && (
|
||||||
|
<Button variant="primary" onClick={() => void uploader.retryFailed()}>
|
||||||
|
{t('uploads.retry_failed')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Link to="/uploads/mine" className={s.more}>
|
||||||
|
{t('uploads.go_mine')}
|
||||||
|
</Link>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button onClick={() => uploader.clear()}>{t('uploads.more')}</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<FormError>{st.error ? errorText(st.error) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean; onRemove: () => void }) {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
const showProgress = item.stage === 'hashing' || item.stage === 'uploading';
|
||||||
|
let note: { text: string; cls?: string } | null = null;
|
||||||
|
switch (item.stage) {
|
||||||
|
case 'duplicate':
|
||||||
|
note = { text: t('uploads.dup_found'), cls: u.sub };
|
||||||
|
break;
|
||||||
|
case 'too_large':
|
||||||
|
note = { text: tr('errors:uploads.too_large'), cls: u.bad };
|
||||||
|
break;
|
||||||
|
case 'rejected':
|
||||||
|
note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
note = { text: t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
|
||||||
|
break;
|
||||||
|
case 'ready':
|
||||||
|
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={u.file}>
|
||||||
|
<div className={u.fileName}>
|
||||||
|
<span className={u.path} title={item.relPath}>
|
||||||
|
{item.relPath}
|
||||||
|
</span>
|
||||||
|
<span className={u.sub}>
|
||||||
|
{t(`uploads.stage.${item.stage}`)}
|
||||||
|
{showProgress && ` · ${Math.round(item.progress * 100)}%`}
|
||||||
|
</span>
|
||||||
|
{showProgress && (
|
||||||
|
<div className={u.progress}>
|
||||||
|
<span style={{ width: `${item.progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{note && <span className={note.cls}>{note.text}</span>}
|
||||||
|
</div>
|
||||||
|
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
|
||||||
|
{locked ? (
|
||||||
|
<span />
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
|
||||||
|
<Icon src={closeIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { formatBytes } from '../../shared/lib/format';
|
||||||
|
import { cx } from '../../shared/ui/ui';
|
||||||
|
import s from '../page.module.css';
|
||||||
|
import u from './uploads.module.css';
|
||||||
|
|
||||||
|
export type UploadStatus = components['schemas']['UploadStatus'];
|
||||||
|
export type UploadFile = components['schemas']['UploadFile'];
|
||||||
|
|
||||||
|
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
|
||||||
|
uploading: 'info',
|
||||||
|
processing: 'info',
|
||||||
|
pending_review: 'warn',
|
||||||
|
approved: 'info',
|
||||||
|
importing: 'info',
|
||||||
|
imported: 'ok',
|
||||||
|
import_failed: 'bad',
|
||||||
|
duplicate: 'muted',
|
||||||
|
invalid: 'bad',
|
||||||
|
rejected: 'bad',
|
||||||
|
withdrawn: 'muted',
|
||||||
|
expired: 'muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
|
||||||
|
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
|
||||||
|
|
||||||
|
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadQuota() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['upload-quota'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
|
||||||
|
export function QuotaMeter() {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const quota = useUploadQuota();
|
||||||
|
const id = useId();
|
||||||
|
if (!quota.data) return null;
|
||||||
|
const q = quota.data;
|
||||||
|
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||||
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
|
return (
|
||||||
|
<div className={u.quota}>
|
||||||
|
<span className={u.quotaText} id={id}>
|
||||||
|
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||||
|
{q.per_user_limit_bytes !== null &&
|
||||||
|
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||||
|
</span>
|
||||||
|
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
|
||||||
|
export function useReasonText(): (reason: string | null) => string | null {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
return (reason) => {
|
||||||
|
if (!reason) return null;
|
||||||
|
const code = reason.split(':')[0]!.trim();
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
if (i18n.exists(key)) {
|
||||||
|
const rest = reason.slice(code.length + 1).trim();
|
||||||
|
return rest ? `${tr(key)}: ${rest}` : tr(key);
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
.drop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 2px dashed var(--line2);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropActive {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--surface3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--violet));
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quotaText {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone_ok {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone_warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone_bad {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone_info {
|
||||||
|
color: var(--accent2);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone_muted {
|
||||||
|
color: var(--text3);
|
||||||
|
background: var(--surface3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchHead {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface2);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchHead b {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--text);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--elev);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.queueRow {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackCell {
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Player, type PlayerState } from './player';
|
||||||
|
|
||||||
|
const PlayerContext = createContext<Player | null>(null);
|
||||||
|
|
||||||
|
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
|
||||||
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [player] = useState(() => new Player(session));
|
||||||
|
player.onReaction = () => void queryClient.invalidateQueries();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
player.start();
|
||||||
|
void player.restore().catch(() => undefined);
|
||||||
|
const onHide = () => player.onHide();
|
||||||
|
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
|
||||||
|
window.addEventListener('pagehide', onHide);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
const onKey = (e: KeyboardEvent) => handleHotkey(player, e);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onHide);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
player.dispose();
|
||||||
|
};
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
return <PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пробел — пауза, ←/→ — ±10 с, Shift+←/→ — трек, L — лайк (docs/web.md). */
|
||||||
|
export function handleHotkey(player: Player, e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const s = player.getState();
|
||||||
|
if (!s.track) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
if (target?.closest('button, a')) return; // пробел на кнопке нажимает её
|
||||||
|
e.preventDefault();
|
||||||
|
player.toggle();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.prev();
|
||||||
|
else player.seek(s.positionMs - 10_000);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) player.next();
|
||||||
|
else player.seek(s.positionMs + 10_000);
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
case 'L':
|
||||||
|
case 'д':
|
||||||
|
case 'Д':
|
||||||
|
e.preventDefault();
|
||||||
|
void player.react(s.track, s.track.reaction === 'like' ? null : 'like').catch(() => undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer(): Player {
|
||||||
|
const p = useContext(PlayerContext);
|
||||||
|
if (!p) throw new Error('usePlayer outside PlayerProvider');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerState(): PlayerState {
|
||||||
|
const p = usePlayer();
|
||||||
|
return useSyncExternalStore(p.subscribe, p.getState);
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import {
|
||||||
|
EventBuffer,
|
||||||
|
PlayAttempt,
|
||||||
|
PlayQueue,
|
||||||
|
unwrap,
|
||||||
|
type AuthSession,
|
||||||
|
type PlayContext,
|
||||||
|
type RepeatMode,
|
||||||
|
type Track,
|
||||||
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
|
export interface WaveInfo {
|
||||||
|
sessionId: string;
|
||||||
|
activity: string | null;
|
||||||
|
mood: string | null;
|
||||||
|
/** Рекомендатель недоступен — играет запасная волна. */
|
||||||
|
fallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||||
|
|
||||||
|
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
|
||||||
|
const WAVE_REFILL_AT = 2;
|
||||||
|
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
|
||||||
|
const EARLY_SKIP_MS = 30_000;
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
wave: WaveInfo | null;
|
||||||
|
track: Track | null;
|
||||||
|
queue: Track[];
|
||||||
|
index: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
playing: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
positionMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
volume: number;
|
||||||
|
muted: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_KEY = 'pipu.player';
|
||||||
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
const FLUSH_EVERY_MS = 30_000;
|
||||||
|
const PRELOAD_BEFORE_MS = 20_000;
|
||||||
|
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||||
|
const MAX_SAVED_QUEUE = 500;
|
||||||
|
|
||||||
|
interface Saved {
|
||||||
|
queue: string[];
|
||||||
|
index: number;
|
||||||
|
positionMs: number;
|
||||||
|
context: PlayContext | null;
|
||||||
|
volume: number;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: RepeatMode;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceId(): string {
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem('pipu.device');
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem('pipu.device', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
|
||||||
|
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||||
|
*/
|
||||||
|
export class Player {
|
||||||
|
private readonly audio: HTMLAudioElement;
|
||||||
|
private readonly queue = new PlayQueue();
|
||||||
|
private readonly events: EventBuffer;
|
||||||
|
private attempt: PlayAttempt | null = null;
|
||||||
|
private context: PlayContext | null = null;
|
||||||
|
private state: PlayerState;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
private readonly device = deviceId();
|
||||||
|
private lastServerSave = 0;
|
||||||
|
private preloaded: string | null = null;
|
||||||
|
private refreshedFor: string | null = null;
|
||||||
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
|
private waveLoading = false;
|
||||||
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly session: AuthSession,
|
||||||
|
audio?: HTMLAudioElement,
|
||||||
|
) {
|
||||||
|
this.audio = audio ?? new Audio();
|
||||||
|
this.audio.preload = 'auto';
|
||||||
|
this.events = new EventBuffer({
|
||||||
|
send: async (events) => {
|
||||||
|
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.state = {
|
||||||
|
wave: null,
|
||||||
|
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
|
||||||
|
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
|
||||||
|
};
|
||||||
|
this.bindAudio();
|
||||||
|
this.bindMediaSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
|
start() {
|
||||||
|
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- подписка (useSyncExternalStore) ---
|
||||||
|
|
||||||
|
subscribe = (fn: () => void) => {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private update(patch: Partial<PlayerState>) {
|
||||||
|
this.state = { ...this.state, ...patch };
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncQueue() {
|
||||||
|
this.update({
|
||||||
|
track: this.queue.current ?? null,
|
||||||
|
queue: this.queue.tracks,
|
||||||
|
index: this.queue.index,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
context: this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- управление ---
|
||||||
|
|
||||||
|
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||||
|
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
|
const target = tracks[index];
|
||||||
|
if (!target || !playable.length) return;
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.leaveWave();
|
||||||
|
this.context = context;
|
||||||
|
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- «Моя волна» ---
|
||||||
|
|
||||||
|
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||||
|
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.leaveWave();
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||||
|
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
|
if (!tracks.length) {
|
||||||
|
this.update({ error: 'wave.empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
|
||||||
|
this.queue.shuffle = false;
|
||||||
|
this.queue.repeat = 'off';
|
||||||
|
this.queue.set(tracks, 0);
|
||||||
|
this.update({ wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
|
||||||
|
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||||
|
*/
|
||||||
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave) return this.startWave(activity, mood);
|
||||||
|
this.update({ wave: { ...wave, activity, mood } });
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
|
params: { path: { id: wave.sessionId } },
|
||||||
|
body: { activity, mood },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
|
this.queue.dropUpcoming();
|
||||||
|
this.queue.append(fresh);
|
||||||
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
|
if (fresh.length && this.queue.next()) {
|
||||||
|
this.finishAttempt('skip', true);
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.syncQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWave() {
|
||||||
|
this.leaveWave();
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
private leaveWave() {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave) return;
|
||||||
|
this.waveFeedback = [];
|
||||||
|
this.update({ wave: null });
|
||||||
|
void this.session.client
|
||||||
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||||
|
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Следующая пачка волны с накопленными реакциями. refresh — сначала выбросить ещё не начатые треки:
|
||||||
|
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
|
||||||
|
*/
|
||||||
|
private async refillWave(refresh = false) {
|
||||||
|
const wave = this.state.wave;
|
||||||
|
if (!wave || this.waveLoading) return;
|
||||||
|
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
|
||||||
|
this.waveLoading = true;
|
||||||
|
const feedback = this.waveFeedback;
|
||||||
|
this.waveFeedback = [];
|
||||||
|
try {
|
||||||
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||||
|
params: { path: { id: wave.sessionId } },
|
||||||
|
body: { feedback },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
|
||||||
|
if (refresh) this.queue.dropUpcoming();
|
||||||
|
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
||||||
|
this.update({ wave: { ...this.state.wave, fallback: res.fallback } });
|
||||||
|
this.syncQueue();
|
||||||
|
} catch {
|
||||||
|
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
|
||||||
|
} finally {
|
||||||
|
this.waveLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.state.playing) this.pause();
|
||||||
|
else this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
if (!this.audio.src) {
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||||
|
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.next()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||||
|
prev() {
|
||||||
|
if (this.audio.currentTime > 3) {
|
||||||
|
this.seek(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
if (this.queue.prev()) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jump(index: number) {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.queue.jump(index);
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ms: number) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||||
|
const from = Math.round(this.audio.currentTime * 1000);
|
||||||
|
this.attempt?.seek(to);
|
||||||
|
this.audio.currentTime = to / 1000;
|
||||||
|
this.update({ positionMs: to });
|
||||||
|
this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to });
|
||||||
|
this.saveSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number) {
|
||||||
|
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||||
|
this.audio.muted = false;
|
||||||
|
this.update({ volume: this.audio.volume, muted: false });
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
this.audio.muted = !this.audio.muted;
|
||||||
|
this.update({ muted: this.audio.muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
this.queue.setShuffle(!this.queue.shuffle);
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleRepeat() {
|
||||||
|
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||||
|
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||||
|
this.syncQueue();
|
||||||
|
this.saveLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */
|
||||||
|
async react(track: Track, reaction: 'like' | 'dislike' | null) {
|
||||||
|
const prev = track.reaction;
|
||||||
|
const apply = (r: Track['reaction']) => {
|
||||||
|
for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r;
|
||||||
|
this.syncQueue();
|
||||||
|
};
|
||||||
|
apply(reaction);
|
||||||
|
const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike');
|
||||||
|
this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs });
|
||||||
|
try {
|
||||||
|
const params = { params: { path: { id: track.id } } };
|
||||||
|
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
|
||||||
|
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
|
||||||
|
} catch (e) {
|
||||||
|
apply(prev);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
this.onReaction?.();
|
||||||
|
if (reaction) {
|
||||||
|
this.noteWave(track.id, reaction);
|
||||||
|
void this.refillWave(true);
|
||||||
|
}
|
||||||
|
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- загрузка трека ---
|
||||||
|
|
||||||
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track?.stream_url) return;
|
||||||
|
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
|
this.audio.src = track.stream_url;
|
||||||
|
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||||
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
|
this.preloaded = null;
|
||||||
|
this.events.push({
|
||||||
|
type: 'play_start',
|
||||||
|
track_id: track.id,
|
||||||
|
position_ms: startMs,
|
||||||
|
context_type: this.context?.type,
|
||||||
|
context_id: this.context?.id,
|
||||||
|
});
|
||||||
|
this.updateMediaMetadata(track);
|
||||||
|
this.saveServer();
|
||||||
|
void this.refillWave();
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
await this.audio.play();
|
||||||
|
} catch {
|
||||||
|
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||||
|
this.update({ playing: false, loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||||
|
/** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
|
||||||
|
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
|
||||||
|
const a = this.attempt;
|
||||||
|
if (!a) return;
|
||||||
|
this.attempt = null;
|
||||||
|
const pos = Math.round(this.audio.currentTime * 1000);
|
||||||
|
if (neutral) {
|
||||||
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||||
|
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||||
|
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events.flush();
|
||||||
|
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
|
||||||
|
else {
|
||||||
|
const early = a.playedMs < EARLY_SKIP_MS;
|
||||||
|
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
|
||||||
|
if (early) void this.refillWave(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindAudio() {
|
||||||
|
const a = this.audio;
|
||||||
|
a.addEventListener('playing', () => {
|
||||||
|
this.update({ playing: true, loading: false });
|
||||||
|
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
|
||||||
|
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('pause', () => {
|
||||||
|
if (a.ended) return;
|
||||||
|
this.update({ playing: false });
|
||||||
|
const t = this.queue.current;
|
||||||
|
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
|
this.saveServer();
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||||
|
a.addEventListener('timeupdate', () => {
|
||||||
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
|
this.attempt?.tick(ms);
|
||||||
|
this.update({ positionMs: ms });
|
||||||
|
this.updatePositionState();
|
||||||
|
this.maybePreload(ms);
|
||||||
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
|
this.saveLocal();
|
||||||
|
});
|
||||||
|
a.addEventListener('ended', () => {
|
||||||
|
this.finishAttempt('play_end');
|
||||||
|
if (this.queue.next(true)) {
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.update({ playing: false, positionMs: 0 });
|
||||||
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('error', () => void this.onError());
|
||||||
|
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||||
|
private async onError() {
|
||||||
|
const track = this.queue.current;
|
||||||
|
if (!track) return;
|
||||||
|
if (this.refreshedFor !== track.id) {
|
||||||
|
this.refreshedFor = track.id;
|
||||||
|
try {
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
|
const fresh = res.items[0];
|
||||||
|
if (fresh?.stream_url) {
|
||||||
|
Object.assign(track, fresh);
|
||||||
|
void this.load(true, this.state.positionMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// упадём в пропуск ниже
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.update({ error: 'player.error', loading: false, playing: false });
|
||||||
|
this.attempt = null;
|
||||||
|
if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */
|
||||||
|
private maybePreload(ms: number) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
const next = this.queue.peekNext();
|
||||||
|
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||||
|
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||||
|
this.preloaded = next.id;
|
||||||
|
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindMediaSession() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
if (!ms) return;
|
||||||
|
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
|
||||||
|
try {
|
||||||
|
ms.setActionHandler(action, fn);
|
||||||
|
} catch {
|
||||||
|
// действие не поддерживается браузером
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set('play', () => this.play());
|
||||||
|
set('pause', () => this.pause());
|
||||||
|
set('nexttrack', () => this.next());
|
||||||
|
set('previoustrack', () => this.prev());
|
||||||
|
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
|
||||||
|
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMediaMetadata(track: Track) {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
|
||||||
|
const cover = track.album.cover_url;
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
artwork: cover
|
||||||
|
? [
|
||||||
|
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
|
||||||
|
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePositionState() {
|
||||||
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
|
const d = this.audio.duration;
|
||||||
|
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
|
||||||
|
try {
|
||||||
|
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
|
||||||
|
} catch {
|
||||||
|
// некорректная позиция во время смены трека
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- сохранение состояния ---
|
||||||
|
|
||||||
|
private snapshot(): Saved {
|
||||||
|
const ids = this.queue.tracks.map((t) => t.id);
|
||||||
|
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
||||||
|
return {
|
||||||
|
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
||||||
|
index: this.queue.index - start,
|
||||||
|
positionMs: this.state.positionMs,
|
||||||
|
context: this.context,
|
||||||
|
volume: this.state.volume,
|
||||||
|
shuffle: this.queue.shuffle,
|
||||||
|
repeat: this.queue.repeat,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveLocal() {
|
||||||
|
if (!this.queue.current) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
|
||||||
|
} catch {
|
||||||
|
// недоступное хранилище не мешает воспроизведению
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private saveSoon() {
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
||||||
|
saveServer(keepalive = false) {
|
||||||
|
const cur = this.queue.current;
|
||||||
|
if (!cur) return;
|
||||||
|
this.lastServerSave = Date.now();
|
||||||
|
this.saveLocal();
|
||||||
|
const s = this.snapshot();
|
||||||
|
void this.session.client
|
||||||
|
.PUT('/api/v1/me/playback-state', {
|
||||||
|
body: {
|
||||||
|
track_id: cur.id,
|
||||||
|
position_ms: s.positionMs,
|
||||||
|
queue: s.queue,
|
||||||
|
queue_index: s.index,
|
||||||
|
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
||||||
|
shuffle: s.shuffle,
|
||||||
|
repeat_mode: s.repeat,
|
||||||
|
volume: s.volume,
|
||||||
|
device_id: this.device,
|
||||||
|
},
|
||||||
|
keepalive,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уход со страницы: дослать события и состояние. */
|
||||||
|
onHide() {
|
||||||
|
this.saveServer(true);
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
|
||||||
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
|
*/
|
||||||
|
async restore() {
|
||||||
|
let local: Saved | null = null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
|
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||||
|
} catch {
|
||||||
|
local = null;
|
||||||
|
}
|
||||||
|
let saved = local;
|
||||||
|
try {
|
||||||
|
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (
|
||||||
|
server.track_id &&
|
||||||
|
server.updated_at &&
|
||||||
|
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
||||||
|
) {
|
||||||
|
saved = {
|
||||||
|
queue: server.queue,
|
||||||
|
index: server.queue_index,
|
||||||
|
positionMs: server.position_ms,
|
||||||
|
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
||||||
|
volume: server.volume,
|
||||||
|
shuffle: server.shuffle,
|
||||||
|
repeat: server.repeat_mode,
|
||||||
|
savedAt: server.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// офлайн — остаёмся с локальной копией
|
||||||
|
}
|
||||||
|
if (!saved?.queue.length) return;
|
||||||
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
|
const wanted = saved.queue[saved.index];
|
||||||
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||||
|
this.context = saved.context;
|
||||||
|
this.queue.repeat = saved.repeat;
|
||||||
|
this.queue.set(tracks, idx);
|
||||||
|
if (saved.shuffle) this.queue.setShuffle(true);
|
||||||
|
this.audio.volume = saved.volume;
|
||||||
|
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
||||||
|
this.syncQueue();
|
||||||
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.finishAttempt('skip');
|
||||||
|
this.audio.pause();
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
for (const t of this.timers) clearInterval(t);
|
||||||
|
this.timers = [];
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
/** Текст ошибки для пользователя: перевод `errors:<code>` с параметрами, иначе общий текст. */
|
||||||
|
export function useErrorText(): (err: unknown) => string {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const tr = t as unknown as Translate;
|
||||||
|
return (err) => {
|
||||||
|
const code = err instanceof ApiError ? err.code : 'common.internal';
|
||||||
|
const params = err instanceof ApiError ? err.params : {};
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
return i18n.exists(key) ? tr(key, params) : tr('errors:common.internal');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибка конкретного поля (если сервер её указал) — для подписи под полем. */
|
||||||
|
export function useFieldError(): (err: unknown, field: string) => string | undefined {
|
||||||
|
const text = useErrorText();
|
||||||
|
return (err, field) => {
|
||||||
|
if (!(err instanceof ApiError)) return undefined;
|
||||||
|
return err.fieldCode(field) ? text(err) : undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/** Дата и время в локали интерфейса: «20 окт. 2026 г., 14:05». */
|
||||||
|
export function formatDateTime(iso: string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['year', 365 * 24 * 3600],
|
||||||
|
['month', 30 * 24 * 3600],
|
||||||
|
['week', 7 * 24 * 3600],
|
||||||
|
['day', 24 * 3600],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Относительное время: «12 минут назад», «вчера», «сейчас». */
|
||||||
|
export function formatRelative(iso: string, locale: string, now: Date = new Date()): string {
|
||||||
|
const diff = (new Date(iso).getTime() - now.getTime()) / 1000;
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||||
|
for (const [unit, sec] of UNITS) {
|
||||||
|
if (Math.abs(diff) >= sec) return rtf.format(Math.round(diff / sec), unit);
|
||||||
|
}
|
||||||
|
return rtf.format(0, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Длительность трека: 215000 → «3:35», 3725000 → «1:02:05». */
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = String(total % 60).padStart(2, '0');
|
||||||
|
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Размер файла в локали интерфейса: 734003 → «717 КБ», 2.5e9 → «2,3 ГБ». */
|
||||||
|
export function formatBytes(bytes: number, locale: string): string {
|
||||||
|
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as const;
|
||||||
|
let v = Math.max(0, bytes);
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat(locale, { style: 'unit', unit: units[i], unitDisplay: 'short', maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 }).format(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type Role = components['schemas']['Role'];
|
||||||
|
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
|
||||||
|
* какие действия показывать. Решает всё равно сервер.
|
||||||
|
*/
|
||||||
|
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
|
||||||
|
const self = actor.id === target.id;
|
||||||
|
if (self && action !== 'reset_password') return false;
|
||||||
|
if (actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Роли, которые actor может выдать при создании. */
|
||||||
|
export function assignableRoles(actor: Role): Role[] {
|
||||||
|
if (actor === 'admin') return ['user', 'moderator', 'admin'];
|
||||||
|
if (actor === 'moderator') return ['user'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Иконки Material Symbols Rounded (Apache 2.0). Импортируются по одной — в сборку попадают только используемые.
|
||||||
|
export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
||||||
|
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||||
|
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
||||||
|
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
||||||
|
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
||||||
|
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
||||||
|
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
||||||
|
export { default as homeFillIcon } from '@material-symbols/svg-400/rounded/home-fill.svg';
|
||||||
|
export { default as lockResetIcon } from '@material-symbols/svg-400/rounded/lock_reset.svg';
|
||||||
|
export { default as logoutIcon } from '@material-symbols/svg-400/rounded/logout.svg';
|
||||||
|
export { default as moreIcon } from '@material-symbols/svg-400/rounded/more_horiz.svg';
|
||||||
|
export { default as musicIcon } from '@material-symbols/svg-400/rounded/music_note.svg';
|
||||||
|
export { default as playIcon } from '@material-symbols/svg-400/rounded/play_arrow-fill.svg';
|
||||||
|
export { default as auditIcon } from '@material-symbols/svg-400/rounded/receipt_long.svg';
|
||||||
|
export { default as auditFillIcon } from '@material-symbols/svg-400/rounded/receipt_long-fill.svg';
|
||||||
|
export { default as searchIcon } from '@material-symbols/svg-400/rounded/search.svg';
|
||||||
|
export { default as settingsIcon } from '@material-symbols/svg-400/rounded/settings.svg';
|
||||||
|
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
|
||||||
|
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
|
||||||
|
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
|
||||||
|
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
|
||||||
|
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||||
|
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||||
|
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||||
|
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||||
|
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||||
|
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||||
|
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||||
|
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||||
|
export { default as libraryIcon } from '@material-symbols/svg-400/rounded/monitoring.svg';
|
||||||
|
export { default as libraryFillIcon } from '@material-symbols/svg-400/rounded/monitoring-fill.svg';
|
||||||
|
export { default as personIcon } from '@material-symbols/svg-400/rounded/person.svg';
|
||||||
|
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
|
||||||
|
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
|
||||||
|
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
|
||||||
|
export { default as pauseIcon } from '@material-symbols/svg-400/rounded/pause-fill.svg';
|
||||||
|
export { default as nextIcon } from '@material-symbols/svg-400/rounded/skip_next-fill.svg';
|
||||||
|
export { default as prevIcon } from '@material-symbols/svg-400/rounded/skip_previous-fill.svg';
|
||||||
|
export { default as likeIcon } from '@material-symbols/svg-400/rounded/favorite.svg';
|
||||||
|
export { default as likeFillIcon } from '@material-symbols/svg-400/rounded/favorite-fill.svg';
|
||||||
|
export { default as dislikeIcon } from '@material-symbols/svg-400/rounded/heart_broken.svg';
|
||||||
|
export { default as dislikeFillIcon } from '@material-symbols/svg-400/rounded/heart_broken-fill.svg';
|
||||||
|
export { default as volumeIcon } from '@material-symbols/svg-400/rounded/volume_up.svg';
|
||||||
|
export { default as volumeOffIcon } from '@material-symbols/svg-400/rounded/volume_off.svg';
|
||||||
|
export { default as shuffleIcon } from '@material-symbols/svg-400/rounded/shuffle.svg';
|
||||||
|
export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.svg';
|
||||||
|
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
|
||||||
|
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
|
||||||
|
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
|
||||||
|
export { default as waveBoltIcon } from '@material-symbols/svg-400/rounded/bolt.svg';
|
||||||
|
export { default as waveBedtimeIcon } from '@material-symbols/svg-400/rounded/bedtime.svg';
|
||||||
|
export { default as waveCarIcon } from '@material-symbols/svg-400/rounded/directions_car.svg';
|
||||||
|
export { default as waveRunIcon } from '@material-symbols/svg-400/rounded/directions_run.svg';
|
||||||
|
export { default as waveFitnessIcon } from '@material-symbols/svg-400/rounded/fitness_center.svg';
|
||||||
|
export { default as waveHeadphonesIcon } from '@material-symbols/svg-400/rounded/headphones.svg';
|
||||||
|
export { default as waveRainyIcon } from '@material-symbols/svg-400/rounded/rainy.svg';
|
||||||
|
export { default as waveHappyIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg';
|
||||||
|
export { default as waveSpaIcon } from '@material-symbols/svg-400/rounded/spa.svg';
|
||||||
|
export { default as waveSunIcon } from '@material-symbols/svg-400/rounded/wb_sunny.svg';
|
||||||
|
export { default as waveWeekendIcon } from '@material-symbols/svg-400/rounded/weekend.svg';
|
||||||
|
export { default as uploadIcon } from '@material-symbols/svg-400/rounded/cloud_upload.svg';
|
||||||
|
export { default as uploadFillIcon } from '@material-symbols/svg-400/rounded/cloud_upload-fill.svg';
|
||||||
|
export { default as myUploadsIcon } from '@material-symbols/svg-400/rounded/library_add.svg';
|
||||||
|
export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg';
|
||||||
|
export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg';
|
||||||
|
export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg';
|
||||||
|
export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_eq.svg';
|
||||||
|
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
|
||||||
|
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
|
||||||
|
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
||||||
|
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoDot {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
||||||
|
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[aria-busy='true'] {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-fill2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover:not(:disabled),
|
||||||
|
.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap:has(.reveal) .input {
|
||||||
|
padding-right: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button[aria-pressed='true'] {
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--overlay);
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
width: 440px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogHead h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogFoot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 24px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover,
|
||||||
|
.iconButton[aria-expanded='true'] {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuWrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 40;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--elev);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: pipuIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuStart {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover,
|
||||||
|
.menuItem:focus-visible {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuDanger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--violet));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
|
||||||
|
import s from './ui.module.css';
|
||||||
|
|
||||||
|
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
/** Иконка-маска: цвет берётся из currentColor. */
|
||||||
|
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cx(s.icon, className)}
|
||||||
|
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({ size = 28 }: { size?: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
|
||||||
|
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
|
||||||
|
{t('app.name')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
icon?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...rest}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
className={cx(s.button, s[variant], className)}
|
||||||
|
>
|
||||||
|
{icon && <Icon src={icon} size={18} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
|
||||||
|
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const id = useId();
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
const isPassword = type === 'password';
|
||||||
|
const note = error ?? hint;
|
||||||
|
return (
|
||||||
|
<div className={cx(s.field, className)}>
|
||||||
|
<label htmlFor={id} className={s.label}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className={s.inputWrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={isPassword && shown ? 'text' : type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={note ? `${id}-note` : undefined}
|
||||||
|
className={cx(s.input, mono && s.mono, error && s.invalid)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.reveal}
|
||||||
|
onClick={() => setShown((v) => !v)}
|
||||||
|
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
|
||||||
|
>
|
||||||
|
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{note && (
|
||||||
|
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегментированный переключатель. */
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={s.segmented} role="group" aria-label={label}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormError({ children }: { children?: ReactNode }) {
|
||||||
|
if (!children) return null;
|
||||||
|
return (
|
||||||
|
<div className={s.formError} role="alert">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
|
||||||
|
export function Dialog({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.activeElement as HTMLElement | null;
|
||||||
|
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
||||||
|
(first ?? ref.current)?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
prev?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
return (
|
||||||
|
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||||
|
<div className={s.dialogHead}>
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
||||||
|
<Icon src={closeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.dialogBody}>{children}</div>
|
||||||
|
{footer && <div className={s.dialogFoot}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
|
||||||
|
|
||||||
|
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
|
||||||
|
export function Menu({
|
||||||
|
label,
|
||||||
|
trigger,
|
||||||
|
items,
|
||||||
|
header,
|
||||||
|
align = 'end',
|
||||||
|
triggerClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
trigger: ReactNode;
|
||||||
|
items: MenuItem[];
|
||||||
|
header?: ReactNode;
|
||||||
|
align?: 'start' | 'end';
|
||||||
|
triggerClassName?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
const onMenuKey = (e: ReactKeyboardEvent) => {
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||||
|
e.preventDefault();
|
||||||
|
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
||||||
|
const i = els.indexOf(document.activeElement as HTMLElement);
|
||||||
|
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
|
||||||
|
els[next]?.focus();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={s.menuWrap} ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={triggerClassName ?? s.iconButton}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||||
|
{header}
|
||||||
|
{items.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.label}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={cx(s.menuItem, it.danger && s.menuDanger)}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
it.onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.icon && <Icon src={it.icon} size={18} />}
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||||
|
const initials = name
|
||||||
|
.split(/[\s._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cx };
|
||||||
+47
-104
@@ -1,6 +1,8 @@
|
|||||||
@import '@pipu/design-tokens/tokens.css';
|
@import '@pipu/design-tokens/tokens.css';
|
||||||
@import '@fontsource/onest/400.css';
|
@import '@fontsource/onest/400.css';
|
||||||
|
@import '@fontsource/onest/500.css';
|
||||||
@import '@fontsource/onest/600.css';
|
@import '@fontsource/onest/600.css';
|
||||||
|
@import '@fontsource/onest/700.css';
|
||||||
@import '@fontsource/unbounded/600.css';
|
@import '@fontsource/unbounded/600.css';
|
||||||
@import '@fontsource/jetbrains-mono/400.css';
|
@import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
|
||||||
@@ -23,6 +25,11 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] body {
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -34,118 +41,54 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
|
||||||
min-height: 100%;
|
button,
|
||||||
display: flex;
|
[role='button'],
|
||||||
flex-direction: column;
|
[role='tab'],
|
||||||
|
[role='option'],
|
||||||
|
[role='menuitem'],
|
||||||
|
[role='listitem'],
|
||||||
|
nav a,
|
||||||
|
label {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top {
|
:focus-visible {
|
||||||
display: flex;
|
outline: 2px solid var(--accent);
|
||||||
align-items: center;
|
outline-offset: 2px;
|
||||||
justify-content: space-between;
|
|
||||||
height: 60px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
@keyframes pipuIn {
|
||||||
display: flex;
|
from {
|
||||||
align-items: center;
|
opacity: 0;
|
||||||
gap: 10px;
|
transform: translateY(6px);
|
||||||
font-family: var(--font-display);
|
}
|
||||||
font-weight: 600;
|
|
||||||
font-size: 19px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-dot {
|
/* Выезжающие справа панели (очередь, docs/design.md#4). */
|
||||||
width: 28px;
|
@keyframes pipuSlideIn {
|
||||||
height: 28px;
|
from {
|
||||||
border-radius: 50%;
|
opacity: 0;
|
||||||
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
|
transform: translateX(24px);
|
||||||
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.langs {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
display: flex;
|
*,
|
||||||
gap: 2px;
|
*::before,
|
||||||
padding: 3px;
|
*::after {
|
||||||
border-radius: 10px;
|
animation-duration: 0.01ms !important;
|
||||||
background: var(--glass);
|
transition-duration: 0.01ms !important;
|
||||||
border: 1px solid var(--line);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.langs button {
|
/* Эквалайзер текущего трека (docs/design.md#2). */
|
||||||
height: 30px;
|
@keyframes pipuEq {
|
||||||
padding: 0 12px;
|
from {
|
||||||
border-radius: 8px;
|
transform: scaleY(0.25);
|
||||||
font-size: 13px;
|
}
|
||||||
font-weight: 600;
|
to {
|
||||||
color: var(--text2);
|
transform: scaleY(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.langs button[aria-pressed='true'] {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: 400px;
|
|
||||||
max-width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 36px;
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background: var(--glass);
|
|
||||||
border: 1px solid var(--line2);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text2);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='ok'] {
|
|
||||||
background: var(--ok-soft);
|
|
||||||
color: var(--ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='error'] {
|
|
||||||
background: var(--danger-soft);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status[data-state='pending'] {
|
|
||||||
background: var(--surface2);
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
type Me = components['schemas']['Me'];
|
||||||
|
type AdminUser = components['schemas']['AdminUser'];
|
||||||
|
type Role = components['schemas']['Role'];
|
||||||
|
|
||||||
|
export interface FakeUser extends AdminUser {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
|
||||||
|
});
|
||||||
|
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const WAVE_ID = '00000000-0000-4000-8000-0000000000ee';
|
||||||
|
|
||||||
|
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
|
||||||
|
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
|
||||||
|
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
|
||||||
|
id: `00000000-0000-4000-8000-00000000010${i}`,
|
||||||
|
title,
|
||||||
|
duration_ms: 200000,
|
||||||
|
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
|
||||||
|
album: albumRef,
|
||||||
|
genres: [],
|
||||||
|
disc_no: 1,
|
||||||
|
track_no: i + 1,
|
||||||
|
available: true,
|
||||||
|
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
|
||||||
|
reaction: null as 'like' | 'dislike' | null,
|
||||||
|
features: { bpm: 120, energy: 0.6, loudness_lufs: -12 },
|
||||||
|
}));
|
||||||
|
const albumSummary = {
|
||||||
|
id: '00000000-0000-4000-8000-0000000000bb',
|
||||||
|
title: 'Группа крови',
|
||||||
|
year: 1988,
|
||||||
|
cover_url: null,
|
||||||
|
cover_colors: [],
|
||||||
|
artist: kino,
|
||||||
|
track_count: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
|
||||||
|
seq++;
|
||||||
|
return {
|
||||||
|
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
|
||||||
|
username,
|
||||||
|
display_name: null,
|
||||||
|
role,
|
||||||
|
locale: 'ru',
|
||||||
|
is_blocked: false,
|
||||||
|
must_change_password: false,
|
||||||
|
must_change_credentials: false,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
last_login_at: null,
|
||||||
|
password: `${username}-password`,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
|
||||||
|
* Проверки прав упрощены — точная матрица покрыта тестами Go.
|
||||||
|
*/
|
||||||
|
export function fakeApi(initial: FakeUser[]) {
|
||||||
|
const users = new Map(initial.map((u) => [u.id, { ...u }]));
|
||||||
|
const prefs = new Map<string, components['schemas']['UiPrefs']>();
|
||||||
|
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
||||||
|
let token = 0;
|
||||||
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
|
||||||
|
const me = (u: FakeUser): Me => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
display_name: u.display_name,
|
||||||
|
role: u.role,
|
||||||
|
locale: u.locale,
|
||||||
|
ui_prefs: prefs.get(u.id) ?? {},
|
||||||
|
must_change_password: u.must_change_password,
|
||||||
|
must_change_credentials: u.must_change_credentials,
|
||||||
|
created_at: u.created_at,
|
||||||
|
});
|
||||||
|
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
|
||||||
|
const admin = (u: FakeUser): AdminUser => {
|
||||||
|
const { password, ...rest } = u;
|
||||||
|
void password;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const req = input as Request;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const text = await req.text();
|
||||||
|
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
|
||||||
|
calls.push({ method: req.method, path, body });
|
||||||
|
const route = `${req.method} ${path}`;
|
||||||
|
const user = current ? users.get(current) : undefined;
|
||||||
|
|
||||||
|
switch (route) {
|
||||||
|
case 'GET /api/v1/server-info':
|
||||||
|
return json({
|
||||||
|
name: 'pipu-music',
|
||||||
|
version: '0.2.0',
|
||||||
|
api_version: 'v1',
|
||||||
|
supported_locales: ['ru', 'en'],
|
||||||
|
min_android_version_code: 0,
|
||||||
|
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||||
|
});
|
||||||
|
case 'POST /api/v1/auth/login': {
|
||||||
|
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||||
|
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||||
|
current = u.id;
|
||||||
|
return tokens(u);
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/auth/refresh':
|
||||||
|
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||||
|
|
||||||
|
switch (route) {
|
||||||
|
case 'POST /api/v1/auth/logout':
|
||||||
|
case 'POST /api/v1/auth/logout-all':
|
||||||
|
current = null;
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
case 'GET /api/v1/me':
|
||||||
|
return json(me(user));
|
||||||
|
case 'PATCH /api/v1/me': {
|
||||||
|
if (typeof body?.locale === 'string') user.locale = body.locale;
|
||||||
|
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
|
||||||
|
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
|
||||||
|
return json(me(user));
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/me/bootstrap-credentials': {
|
||||||
|
const pw = String(body?.new_password ?? '');
|
||||||
|
if (pw.length < 12)
|
||||||
|
return problem(422, 'validation.password_too_short', {
|
||||||
|
params: { min: 12 },
|
||||||
|
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
|
||||||
|
});
|
||||||
|
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
|
||||||
|
return tokens(user);
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/me/password':
|
||||||
|
Object.assign(user, { password: body?.new_password, must_change_password: false });
|
||||||
|
return tokens(user);
|
||||||
|
case 'GET /api/v1/me/sessions':
|
||||||
|
return json({ items: [] });
|
||||||
|
case 'GET /api/v1/admin/users': {
|
||||||
|
if (user.role === 'user') return problem(403, 'auth.forbidden');
|
||||||
|
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||||
|
const role = url.searchParams.get('role');
|
||||||
|
const items = [...users.values()]
|
||||||
|
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username))
|
||||||
|
.map(admin);
|
||||||
|
return json({ items, next_cursor: null });
|
||||||
|
}
|
||||||
|
case 'POST /api/v1/admin/users': {
|
||||||
|
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
|
||||||
|
const u = makeUser(String(body?.username), body?.role as Role, {
|
||||||
|
display_name: (body?.display_name as string) ?? null,
|
||||||
|
must_change_password: true,
|
||||||
|
password: 'Temp-Pass-123',
|
||||||
|
});
|
||||||
|
users.set(u.id, u);
|
||||||
|
return json({ user: admin(u), temporary_password: u.password }, 201);
|
||||||
|
}
|
||||||
|
case 'GET /api/v1/admin/audit':
|
||||||
|
return json({ items: [], next_cursor: null });
|
||||||
|
case 'GET /api/v1/search/suggest':
|
||||||
|
case 'GET /api/v1/search': {
|
||||||
|
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
||||||
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
const hit = q === 'кино' || q === 'rbyj';
|
||||||
|
return json({
|
||||||
|
artists: hit ? [kino] : [],
|
||||||
|
albums: hit ? [albumSummary] : [],
|
||||||
|
tracks: [],
|
||||||
|
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
|
||||||
|
corrected_query: q === 'rbyj' ? 'кино' : null,
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case `GET /api/v1/albums/${albumSummary.id}`:
|
||||||
|
return json({
|
||||||
|
...albumRef,
|
||||||
|
artist: kino,
|
||||||
|
duration_ms: 400000,
|
||||||
|
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
|
||||||
|
});
|
||||||
|
case 'GET /api/v1/wave/options':
|
||||||
|
return json({
|
||||||
|
activities: [{ key: 'running', icon: 'directions_run' }, { key: 'focus', icon: 'headphones' }],
|
||||||
|
moods: [{ key: 'calm', icon: 'spa' }, { key: 'happy', icon: 'sentiment_satisfied' }],
|
||||||
|
});
|
||||||
|
case 'POST /api/v1/wave/sessions':
|
||||||
|
return json(
|
||||||
|
{ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: albumTracks, fallback: false },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
case `PATCH /api/v1/wave/sessions/${WAVE_ID}`:
|
||||||
|
case `POST /api/v1/wave/sessions/${WAVE_ID}/next`:
|
||||||
|
return json({ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: [], fallback: false });
|
||||||
|
case `DELETE /api/v1/wave/sessions/${WAVE_ID}`:
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
case 'POST /api/v1/events':
|
||||||
|
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
|
||||||
|
case 'GET /api/v1/me/playback-state':
|
||||||
|
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
|
||||||
|
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
|
||||||
|
case 'PUT /api/v1/me/playback-state':
|
||||||
|
return json({ ...body, updated_at: new Date().toISOString() });
|
||||||
|
case `GET /api/v1/artists/${kino.id}`:
|
||||||
|
return json({ ...kino, album_count: 1, track_count: 0 });
|
||||||
|
case `GET /api/v1/artists/${kino.id}/albums`:
|
||||||
|
return json({ items: [albumSummary] });
|
||||||
|
}
|
||||||
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
if (reactionPath) {
|
||||||
|
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||||
|
else reactions.delete(reactionPath[1]!);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
return problem(404, 'common.not_found');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер.
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: false });
|
||||||
|
this.dispatchEvent(new Event('playing'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: true });
|
||||||
|
this.dispatchEvent(new Event('pause'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
|
||||||
|
|
||||||
|
// Canvas в jsdom нет: кнопка волны без контекста просто не рисует (как в старых браузерах).
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { configurable: true, value: () => null });
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
.ambient {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: var(--amb-op);
|
||||||
|
animation: pipuHue 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient span {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(30px);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a {
|
||||||
|
left: -22vmax;
|
||||||
|
top: -28vmax;
|
||||||
|
width: 70vmax;
|
||||||
|
height: 70vmax;
|
||||||
|
background: radial-gradient(circle, rgba(47, 128, 255, 0.75) 0%, transparent 62%);
|
||||||
|
animation: pipuDrift1 16s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b {
|
||||||
|
right: -20vmax;
|
||||||
|
top: 8vh;
|
||||||
|
width: 60vmax;
|
||||||
|
height: 60vmax;
|
||||||
|
background: radial-gradient(circle, rgba(124, 92, 255, 0.6) 0%, transparent 62%);
|
||||||
|
animation: pipuDrift2 21s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c {
|
||||||
|
left: 22vw;
|
||||||
|
bottom: -32vmax;
|
||||||
|
width: 55vmax;
|
||||||
|
height: 55vmax;
|
||||||
|
background: radial-gradient(circle, var(--amb-c3, rgba(53, 211, 255, 0.6)) 0%, transparent 62%);
|
||||||
|
animation: pipuDrift3 26s ease-in-out infinite alternate;
|
||||||
|
transition: background 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuDrift1 {
|
||||||
|
0% {
|
||||||
|
transform: translate(-6%, -4%) scale(1);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
transform: translate(45%, 28%) scale(1.2);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate(15%, 60%) scale(0.9);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(60%, -5%) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuDrift2 {
|
||||||
|
0% {
|
||||||
|
transform: translate(4%, 0) scale(1.05);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translate(-50%, 40%) scale(0.9);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translate(-20%, -25%) scale(1.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-60%, 15%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuDrift3 {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) scale(0.95);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translate(-40%, -45%) scale(1.15);
|
||||||
|
}
|
||||||
|
65% {
|
||||||
|
transform: translate(45%, -25%) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(10%, -55%) scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pipuHue {
|
||||||
|
0% {
|
||||||
|
filter: hue-rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: hue-rotate(-35deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: hue-rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ambient,
|
||||||
|
.ambient span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import s from './Ambient.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
|
||||||
|
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
|
||||||
|
*/
|
||||||
|
export function Ambient({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<div className={s.ambient} aria-hidden>
|
||||||
|
<span className={s.a} />
|
||||||
|
<span className={s.b} />
|
||||||
|
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
.shell {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
grid-template-rows: 60px 1fr 88px;
|
||||||
|
grid-template-areas:
|
||||||
|
'top top'
|
||||||
|
'side content'
|
||||||
|
'player player';
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell > :where(:not([aria-hidden])) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
|
||||||
|
.island {
|
||||||
|
grid-template-rows: 60px 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
'top top'
|
||||||
|
'side content';
|
||||||
|
}
|
||||||
|
|
||||||
|
.island .content {
|
||||||
|
padding-bottom: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
|
||||||
|
.top {
|
||||||
|
grid-area: top;
|
||||||
|
z-index: 30;
|
||||||
|
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--bar-a);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoLink {
|
||||||
|
width: 196px;
|
||||||
|
flex: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.logoLink {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topCenter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileButton {
|
||||||
|
border-radius: 50%;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileButton[aria-expanded='true'] {
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileHead {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 4px 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileHead > :last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
grid-area: side;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
margin: 18px 12px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem[data-status='active'] {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navIcon {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navCount {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBadge {
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem[data-status='active'] .navIcon {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
grid-area: content;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 36px;
|
||||||
|
animation: pipuIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel,
|
||||||
|
.navCount,
|
||||||
|
.navBadge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 60px 1fr 72px 64px;
|
||||||
|
grid-template-areas:
|
||||||
|
'top'
|
||||||
|
'content'
|
||||||
|
'player'
|
||||||
|
'side';
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSection {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
|
import {
|
||||||
|
auditFillIcon,
|
||||||
|
auditIcon,
|
||||||
|
groupFillIcon,
|
||||||
|
groupIcon,
|
||||||
|
homeFillIcon,
|
||||||
|
homeIcon,
|
||||||
|
libraryFillIcon,
|
||||||
|
libraryIcon,
|
||||||
|
logoutIcon,
|
||||||
|
historyFillIcon,
|
||||||
|
historyIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
dislikeFillIcon,
|
||||||
|
dislikeIcon,
|
||||||
|
settingsIcon,
|
||||||
|
androidIcon,
|
||||||
|
storageFillIcon,
|
||||||
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||||
|
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||||
|
import { LanguageSwitch } from './LanguageSwitch';
|
||||||
|
import { PlayerProvider, usePlayerState } from '../player/context';
|
||||||
|
import { Ambient } from './Ambient';
|
||||||
|
import { PlayerBar } from './PlayerBar';
|
||||||
|
import { SearchBox } from './SearchBox';
|
||||||
|
import s from './AppShell.module.css';
|
||||||
|
|
||||||
|
type NavPath =
|
||||||
|
| '/'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/admin/audit'
|
||||||
|
| '/admin/sources'
|
||||||
|
| '/admin/library'
|
||||||
|
| '/admin/uploads'
|
||||||
|
| '/uploads/new'
|
||||||
|
| '/uploads/mine'
|
||||||
|
| '/admin/settings'
|
||||||
|
| '/collection/history'
|
||||||
|
| '/collection/likes'
|
||||||
|
| '/collection/dislikes';
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
to,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
activeIcon,
|
||||||
|
count,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
to: NavPath;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
activeIcon: string;
|
||||||
|
count?: number; // счётчик справа (лайки, дизлайки)
|
||||||
|
badge?: number; // яркий бейдж (ждут решения)
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
||||||
|
<span className={s.navLabel}>{label}</span>
|
||||||
|
{badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||||
|
{!badge && count ? <span className={s.navCount}>{count}</span> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||||
|
export function AppShell() {
|
||||||
|
return useAuthState().status === 'signed_in' ? (
|
||||||
|
<PlayerProvider>
|
||||||
|
<Shell />
|
||||||
|
</PlayerProvider>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useUser();
|
||||||
|
const session = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const name = user.display_name ?? user.username;
|
||||||
|
const staff = user.role !== 'user';
|
||||||
|
const island = user.ui_prefs.web_player === 'island';
|
||||||
|
const searchAt = user.ui_prefs.search_position ?? 'center';
|
||||||
|
const search = <SearchBox align={searchAt} />;
|
||||||
|
const counts = useCollectionCounts();
|
||||||
|
const pending = usePendingUploads(user.role === 'admin');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(s.shell, island && s.island)}>
|
||||||
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||||
|
<header className={s.top}>
|
||||||
|
<div className={s.topLeft}>
|
||||||
|
<Link to="/" className={s.logoLink}>
|
||||||
|
<Logo />
|
||||||
|
</Link>
|
||||||
|
{searchAt === 'left' && search}
|
||||||
|
</div>
|
||||||
|
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
|
||||||
|
<div className={s.topRight}>
|
||||||
|
{searchAt === 'right' && search}
|
||||||
|
<Menu
|
||||||
|
label={t('profile.menu')}
|
||||||
|
triggerClassName={s.profileButton}
|
||||||
|
trigger={<Avatar name={name} />}
|
||||||
|
header={
|
||||||
|
<div className={s.profileHead}>
|
||||||
|
<Avatar name={name} size={40} />
|
||||||
|
<div>
|
||||||
|
<div className={s.profileName}>{name}</div>
|
||||||
|
<div className={s.profileMeta}>
|
||||||
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitch />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
items={[
|
||||||
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
|
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||||
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className={s.side} aria-label={t('nav.main')}>
|
||||||
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
|
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||||
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||||
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||||
|
<NavLink
|
||||||
|
to="/collection/dislikes"
|
||||||
|
label={t('nav.dislikes')}
|
||||||
|
icon={dislikeIcon}
|
||||||
|
activeIcon={dislikeFillIcon}
|
||||||
|
count={counts.dislikes}
|
||||||
|
/>
|
||||||
|
{staff && (
|
||||||
|
<>
|
||||||
|
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||||
|
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||||
|
)}
|
||||||
|
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||||
|
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||||
|
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/uploads"
|
||||||
|
label={t('nav.upload_queue')}
|
||||||
|
icon={inboxIcon}
|
||||||
|
activeIcon={inboxFillIcon}
|
||||||
|
badge={pending}
|
||||||
|
/>
|
||||||
|
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||||
|
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className={s.content}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||||
|
function ShellAmbient() {
|
||||||
|
const track = usePlayerState().track;
|
||||||
|
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
|
||||||
|
function usePendingUploads(enabled: boolean) {
|
||||||
|
const session = useSession();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ['admin-uploads-pending'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
|
||||||
|
).items.length,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
return q.data;
|
||||||
|
}
|
||||||
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user