Compare commits
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
|
||||
name: ci / api
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'api/openapi.yaml'
|
||||
- '.gitea/workflows/ci-api.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'api/openapi.yaml'
|
||||
- '.gitea/workflows/ci-api.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: services/api } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||
- run: go vet ./...
|
||||
- run: go test -race -coverprofile=coverage.out ./...
|
||||
- run: go build ./cmd/...
|
||||
@@ -0,0 +1,59 @@
|
||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
|
||||
name: ci / contract
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'api/openapi.yaml'
|
||||
- 'services/api/internal/api/gen/**'
|
||||
- 'services/api/go.mod'
|
||||
- 'services/api/go.sum'
|
||||
- 'packages/api-client/**'
|
||||
- 'packages/design-tokens/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-contract.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'api/openapi.yaml'
|
||||
- 'services/api/internal/api/gen/**'
|
||||
- 'services/api/go.mod'
|
||||
- 'services/api/go.sum'
|
||||
- 'packages/api-client/**'
|
||||
- 'packages/design-tokens/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-contract.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
name: contract (сгенерированный код совпадает с openapi.yaml)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
|
||||
run: |
|
||||
(cd services/api && go generate ./...)
|
||||
pnpm generate
|
||||
- name: Проверка, что всё закоммичено
|
||||
# status, а не diff: diff не видит новые неотслеживаемые файлы
|
||||
run: |
|
||||
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
|
||||
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
|
||||
@@ -0,0 +1,51 @@
|
||||
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
|
||||
name: ci / mobile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/mobile/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-mobile.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/mobile/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-mobile.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mobile:
|
||||
name: mobile (Metro-бандл Android)
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: apps/mobile } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# npx expo-doctor качает пакет в кэш npm; Metro хранит трансформации в /tmp/metro-cache
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm/_npx
|
||||
/tmp/metro-cache
|
||||
key: mobile-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
|
||||
restore-keys: mobile-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||
- run: npx expo-doctor
|
||||
- run: npx expo export --platform android --output-dir dist
|
||||
@@ -0,0 +1,31 @@
|
||||
# Python: recommender и analyzer (docs/ci-cd.md).
|
||||
name: ci / recommender
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'services/recommender/**'
|
||||
- '.gitea/workflows/ci-recommender.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'services/recommender/**'
|
||||
- '.gitea/workflows/ci-recommender.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
recommender:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: services/recommender } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
||||
- run: uv sync --frozen
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv run mypy pipu_rec
|
||||
- run: uv run pytest -q
|
||||
@@ -0,0 +1,53 @@
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
|
||||
name: ci / ts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-ts.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/ci-ts.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ts:
|
||||
name: ts (packages + web)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "22", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Локальный кэш turbo: typecheck/test/build пропускаются для пакетов, чьи входы не менялись
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo/cache
|
||||
key: turbo-ts-${{ github.sha }}
|
||||
restore-keys: turbo-ts-
|
||||
- run: pnpm i18n:check
|
||||
- run: pnpm test:scripts
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter @pipu/web build
|
||||
@@ -1,111 +0,0 @@
|
||||
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
name: contract (сгенерированный код совпадает с openapi.yaml)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
|
||||
run: |
|
||||
(cd services/api && go generate ./...)
|
||||
pnpm generate
|
||||
- name: Проверка, что всё закоммичено
|
||||
# status, а не diff: diff не видит новые неотслеживаемые файлы
|
||||
run: |
|
||||
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
|
||||
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
|
||||
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: services/api } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
||||
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||
- run: go vet ./...
|
||||
- run: go test -race -coverprofile=coverage.out ./...
|
||||
- run: go build ./cmd/...
|
||||
|
||||
recommender:
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: services/recommender } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
|
||||
- run: uv sync --frozen
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
- run: uv run mypy pipu_rec
|
||||
- run: uv run pytest -q
|
||||
|
||||
ts:
|
||||
name: ts (packages + web)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm i18n:check
|
||||
- run: pnpm test:scripts
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter @pipu/web build
|
||||
|
||||
docker:
|
||||
name: docker (образы собираются — их сервер собирает сам при обновлении)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- { name: api, dockerfile: services/api/Dockerfile, target: api }
|
||||
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
|
||||
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
|
||||
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
target: ${{ matrix.target }}
|
||||
push: false
|
||||
build-args: VERSION=ci
|
||||
|
||||
mobile:
|
||||
name: mobile (Metro-бандл Android)
|
||||
runs-on: ubuntu-latest
|
||||
defaults: { run: { working-directory: apps/mobile } }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: npx expo-doctor
|
||||
- run: npx expo export --platform android --output-dir dist
|
||||
@@ -0,0 +1,61 @@
|
||||
# Сборка всех Docker-образов без push: сервер собирает их сам при обновлении, и здесь ловятся поломки
|
||||
# Dockerfile и зависимостей. Только при изменении Dockerfile, deploy/ и манифестов зависимостей.
|
||||
name: ci / docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'deploy/**'
|
||||
- 'services/api/go.mod'
|
||||
- 'services/api/go.sum'
|
||||
- 'services/recommender/pyproject.toml'
|
||||
- 'services/recommender/uv.lock'
|
||||
- 'apps/web/package.json'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/docker.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'deploy/**'
|
||||
- 'services/api/go.mod'
|
||||
- 'services/api/go.sum'
|
||||
- 'services/recommender/pyproject.toml'
|
||||
- 'services/recommender/uv.lock'
|
||||
- 'apps/web/package.json'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'turbo.json'
|
||||
- 'tsconfig.base.json'
|
||||
- '.gitea/workflows/docker.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: docker (образы собираются — их сервер собирает сам при обновлении)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
|
||||
- name: Сборка образов
|
||||
env: { DOCKER_BUILDKIT: "1" }
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build() { echo "::group::$1"; docker build --build-arg VERSION=ci -t "pipu-ci/$1" "${@:2}" .; echo "::endgroup::"; }
|
||||
build api -f services/api/Dockerfile --target api
|
||||
build storage-agent -f services/api/Dockerfile --target storage-agent
|
||||
build recommender -f services/recommender/Dockerfile
|
||||
build web -f apps/web/Dockerfile
|
||||
@@ -1,8 +1,12 @@
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release.
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
|
||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
|
||||
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
|
||||
# Переменные (Settings → Actions → Variables):
|
||||
# PUBLIC_URL (необязательно) — внешний адрес Gitea для ссылок в тексте релиза; по умолчанию https://gitea.hsrv.site
|
||||
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
|
||||
name: release
|
||||
|
||||
@@ -11,11 +15,12 @@ on:
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# Тесты здесь не повторяются: тот же коммит уже проверен CI в main (ci-*.yml). Ставьте тег после зелёного CI.
|
||||
release:
|
||||
name: release (версия и заметки)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.PIPU_VERSION }}
|
||||
@@ -23,35 +28,54 @@ jobs:
|
||||
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with: { version: "0.10.2" }
|
||||
- uses: pnpm/action-setup@v4
|
||||
# вся история и теги: заметки строятся по коммитам с прошлого релиза
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Go
|
||||
working-directory: services/api
|
||||
run: go vet ./... && go test ./...
|
||||
- name: Python
|
||||
working-directory: services/recommender
|
||||
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
|
||||
- name: TypeScript
|
||||
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
|
||||
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||
- id: v
|
||||
name: Версия из тега
|
||||
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
|
||||
- name: Gitea Release
|
||||
env:
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
|
||||
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
|
||||
run: node scripts/release.mjs create
|
||||
|
||||
android-release:
|
||||
name: android + release
|
||||
needs: verify
|
||||
android:
|
||||
name: android (APK в релиз)
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
|
||||
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
container:
|
||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||
volumes:
|
||||
- /opt/ci-cache/gradle:/root/.gradle
|
||||
- /opt/ci-cache/android:/root/.android
|
||||
env:
|
||||
PIPU_VERSION: ${{ needs.verify.outputs.version }}
|
||||
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
|
||||
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||||
# для scripts/release.mjs
|
||||
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.release.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
|
||||
- name: Статус в релизе — собирается
|
||||
run: node scripts/release.mjs android building
|
||||
- uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "17" }
|
||||
- uses: android-actions/setup-android@v3
|
||||
@@ -64,24 +88,45 @@ jobs:
|
||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Свободное место
|
||||
run: df -h / /tmp
|
||||
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
|
||||
- name: Ресурсы раннера и кэши
|
||||
run: |
|
||||
df -h / /tmp; free -m
|
||||
du -sh ~/.gradle ~/.android 2>/dev/null || true
|
||||
|
||||
- name: Сборка APK
|
||||
working-directory: apps/mobile
|
||||
env:
|
||||
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
||||
CI: "1"
|
||||
NODE_ENV: production
|
||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
|
||||
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
|
||||
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
|
||||
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
|
||||
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
|
||||
run: |
|
||||
npx expo prebuild --platform android --clean --no-install
|
||||
cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
|
||||
cd android
|
||||
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
||||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||
./gradlew --stop
|
||||
|
||||
- name: Свободное место после сбоя
|
||||
- name: Диагностика сбоя (диск, память, OOM)
|
||||
if: failure()
|
||||
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||
run: |
|
||||
df -h / /tmp; free -m || true
|
||||
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
|
||||
|
||||
- name: Подпись APK
|
||||
env:
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
PRERELEASE: ${{ needs.release.outputs.prerelease }}
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
@@ -96,9 +141,11 @@ jobs:
|
||||
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
||||
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
||||
rm release.keystore
|
||||
echo "SIGNING=release" >> "$GITHUB_ENV"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
||||
cp "$src" "$out"
|
||||
echo "SIGNING=debug" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
||||
exit 1
|
||||
@@ -107,41 +154,14 @@ jobs:
|
||||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||||
echo "APK=$out" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Changelog
|
||||
env:
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
run: |
|
||||
# для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||||
exclude=()
|
||||
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
|
||||
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
||||
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
||||
{
|
||||
echo "## Pipu Music ${PIPU_VERSION}"
|
||||
echo
|
||||
for t in feat fix perf refactor docs ci chore; do
|
||||
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
|
||||
[ -n "$lines" ] || continue
|
||||
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
|
||||
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
|
||||
done
|
||||
} > CHANGELOG_RELEASE.md
|
||||
cat CHANGELOG_RELEASE.md
|
||||
- name: APK в релиз
|
||||
run: node scripts/release.mjs android success "$APK"
|
||||
|
||||
- name: Gitea Release
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
|
||||
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
|
||||
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
|
||||
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$API/releases" | jq -r .id)"
|
||||
for f in "$APK" "$APK.sha256"; do
|
||||
curl -fsS -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
|
||||
echo "uploaded $(basename "$f")"
|
||||
done
|
||||
- name: Статус в релизе — сборка не удалась
|
||||
if: failure()
|
||||
run: node scripts/release.mjs android failure
|
||||
|
||||
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: cancelled()
|
||||
run: node scripts/release.mjs android cancelled
|
||||
@@ -34,3 +34,4 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.cache/
|
||||
@@ -20,7 +20,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
|
||||
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
|
||||
| `.gitea/workflows` | CI по областям (`ci-*.yml`, `docker.yml`) и релиз по тегу (`release.yml`) |
|
||||
|
||||
## Разработка
|
||||
|
||||
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
|
||||
API (из `services/api`):
|
||||
|
||||
```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`):
|
||||
@@ -65,6 +65,6 @@ pnpm generate && (cd services/api && go generate ./...)
|
||||
|
||||
## Релиз
|
||||
|
||||
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → подписанный APK → Gitea Release.
|
||||
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
|
||||
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
|
||||
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
|
||||
+1957
-1
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,21 @@
|
||||
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
|
||||
const path = require('node:path');
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
module.exports = getDefaultConfig(__dirname);
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
|
||||
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
|
||||
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
|
||||
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
|
||||
// Gradle собирает нативные модули прямо в node_modules/<пакет>/android/{build,.cxx,.gradle},
|
||||
// а корневой node_modules тоже в watchFolders.
|
||||
const nativeBuildOutputs = /[\\/]android[\\/](build|\.cxx|\.gradle)[\\/].*/;
|
||||
|
||||
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
|
||||
|
||||
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
|
||||
if (process.env.CI) config.maxWorkers = 2;
|
||||
|
||||
module.exports = config;
|
||||
+10
-1
@@ -5,9 +5,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
RUN npm install -g pnpm@11.9.0
|
||||
WORKDIR /repo
|
||||
COPY . .
|
||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/mobile/package.json apps/mobile/
|
||||
COPY packages/api-client/package.json packages/api-client/
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/design-tokens/package.json packages/design-tokens/
|
||||
COPY packages/i18n/package.json packages/i18n/
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --filter @pipu/web...
|
||||
COPY . .
|
||||
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
|
||||
RUN pnpm --filter @pipu/web build
|
||||
|
||||
|
||||
@@ -14,18 +14,22 @@
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
"@fontsource/onest": "^5.2.6",
|
||||
"@fontsource/unbounded": "^5.2.6",
|
||||
"@material-symbols/svg-400": "^0.31.9",
|
||||
"@pipu/api-client": "workspace:*",
|
||||
"@pipu/core": "workspace:*",
|
||||
"@pipu/design-tokens": "workspace:*",
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
|
||||
+275
-29
@@ -1,51 +1,297 @@
|
||||
import { createApiClient } from '@pipu/api-client';
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
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 { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const info = {
|
||||
name: 'pipu-music',
|
||||
version: '0.1.0',
|
||||
api_version: 'v1',
|
||||
supported_locales: ['ru', 'en'],
|
||||
min_android_version_code: 100,
|
||||
};
|
||||
|
||||
async function renderApp(fetchImpl: typeof fetch) {
|
||||
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||
const api = fakeApi(users);
|
||||
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(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<App client={client} />
|
||||
</QueryClientProvider>
|
||||
<App session={session} router={router} queryClient={queryClient} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
return { api, router, session, i18n, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('shows the server version when the server is online', async () => {
|
||||
await renderApp(async () => Response.json(info));
|
||||
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
|
||||
describe('sign-in', () => {
|
||||
it('sends a guest to the login page and back after signing in', async () => {
|
||||
const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
|
||||
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 () => {
|
||||
await renderApp(async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
it('shows the bootstrap hint on first start', async () => {
|
||||
await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
|
||||
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.findByText('Привет, owner!');
|
||||
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 () => {
|
||||
await renderApp(async () => Response.json(info));
|
||||
await screen.findByText('Сервер 0.1.0 на связи');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
|
||||
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
|
||||
it('forces a temporary password to be replaced', async () => {
|
||||
const bob = makeUser('bob', 'user', { must_change_password: true });
|
||||
const { user } = await renderApp([bob], '/', { signedInAs: bob });
|
||||
await screen.findByRole('heading', { name: 'Задайте новый пароль' });
|
||||
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.findByText('Привет, bob!');
|
||||
});
|
||||
|
||||
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.findByText('Привет, alice!');
|
||||
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="row"]')!;
|
||||
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('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 { checkServer } from '@pipu/core';
|
||||
import { SUPPORTED_LOCALES } from '@pipu/i18n';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthSession } from '@pipu/core';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
const defaultClient = createApiClient();
|
||||
import type { createAppRouter } from './app/router';
|
||||
import { SessionProvider } from './app/session';
|
||||
|
||||
export function App({ client = defaultClient }: { client?: ApiClient }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
|
||||
export function createQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
|
||||
}
|
||||
|
||||
export function App({
|
||||
session,
|
||||
router,
|
||||
queryClient,
|
||||
}: {
|
||||
session: AuthSession;
|
||||
router: ReturnType<typeof createAppRouter>;
|
||||
queryClient?: QueryClient;
|
||||
}) {
|
||||
const [client] = useState(() => queryClient ?? createQueryClient());
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="top">
|
||||
<div className="logo">
|
||||
<span className="logo-dot" aria-hidden />
|
||||
{t('app.name')}
|
||||
</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>
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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 { 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 { 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 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,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
historyRoute,
|
||||
likesRoute,
|
||||
dislikesRoute,
|
||||
artistRoute,
|
||||
albumRoute,
|
||||
usersRoute,
|
||||
auditRoute,
|
||||
sourcesRoute,
|
||||
patternsRoute,
|
||||
libraryRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
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,222 @@
|
||||
.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: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,203 @@
|
||||
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 } 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,
|
||||
}: {
|
||||
tracks: Track[];
|
||||
showAlbum?: boolean;
|
||||
numbered?: boolean;
|
||||
context: PlayContext;
|
||||
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
|
||||
rowMeta?: (t: Track, i: number) => string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const current = usePlayerState().track;
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div className={s.tracks} role="table" 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="row"
|
||||
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();
|
||||
}}
|
||||
>
|
||||
<span className={s.num}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={s.trackMain}>
|
||||
<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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+36
-5
@@ -1,12 +1,14 @@
|
||||
import './styles.css';
|
||||
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n, pickLocale } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
|
||||
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]));
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on('languageChanged', (l) => {
|
||||
document.documentElement.lang = l;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_KEY, l);
|
||||
} 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(
|
||||
<StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<App session={session} router={router} />
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
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 { formatDuration } from '../shared/lib/format';
|
||||
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 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;
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{colors && (
|
||||
<span
|
||||
className={c.heroBg}
|
||||
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, Cover } from '../entities/catalog';
|
||||
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 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,
|
||||
});
|
||||
|
||||
if (artist.isError) return <NotFound error={artist.error} />;
|
||||
const a = artist.data;
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{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,175 @@
|
||||
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 { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { playIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, FormError } 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'];
|
||||
|
||||
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<h1>{t('collection.title')}</h1>
|
||||
</div>
|
||||
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
|
||||
{TABS.map((k) => (
|
||||
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
|
||||
{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>{g.title}</h2>
|
||||
<TrackList
|
||||
tracks={g.items.map((i) => i.track)}
|
||||
context={{ type: 'history' }}
|
||||
rowMeta={(_, 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" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||
{t('collection.play_all')}
|
||||
</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} />}
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
{t('collection.more')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUser } from '../app/session';
|
||||
import { playIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import s from './home.module.css';
|
||||
|
||||
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
return (
|
||||
<div className={s.home}>
|
||||
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
|
||||
<div className={s.orb} aria-disabled="true">
|
||||
<Icon src={playIcon} size={44} />
|
||||
<span className={s.orbLabel}>{t('home.wave')}</span>
|
||||
</div>
|
||||
<p className={s.soon}>{t('home.wave_soon')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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}` : 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,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,230 @@
|
||||
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']>;
|
||||
|
||||
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 setTheme = async (value: Theme) => {
|
||||
// Оптимистично: тема применяется сразу, сервер подтверждает.
|
||||
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
|
||||
try {
|
||||
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
|
||||
} catch {
|
||||
session.setUser(user);
|
||||
}
|
||||
};
|
||||
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('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,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,94 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
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 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 && 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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';
|
||||
|
||||
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} />}
|
||||
</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,44 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
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');
|
||||
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}>
|
||||
{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-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,352 @@
|
||||
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} />
|
||||
</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>
|
||||
)}
|
||||
<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,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,301 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.home {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.orb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
max-width: 70vw;
|
||||
max-height: 70vw;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
|
||||
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
|
||||
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
|
||||
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
|
||||
opacity: 0.85;
|
||||
animation: breathe 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orbLabel {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.soon {
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -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,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,535 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
unwrap,
|
||||
type AuthSession,
|
||||
type PlayContext,
|
||||
type RepeatMode,
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface PlayerState {
|
||||
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>[] = [];
|
||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||
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 = {
|
||||
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.context = context;
|
||||
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
|
||||
this.syncQueue();
|
||||
void this.load(true);
|
||||
}
|
||||
|
||||
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 === '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();
|
||||
if (autoplay) {
|
||||
try {
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
this.update({ playing: false, loading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||
private finishAttempt(type: 'play_end' | 'skip') {
|
||||
const a = this.attempt;
|
||||
if (!a) return;
|
||||
this.attempt = null;
|
||||
const pos = Math.round(this.audio.currentTime * 1000);
|
||||
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();
|
||||
}
|
||||
|
||||
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,32 @@
|
||||
/** Дата и время в локали интерфейса: «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}`;
|
||||
}
|
||||
@@ -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,48 @@
|
||||
// Иконки 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 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';
|
||||
@@ -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);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.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);
|
||||
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 };
|
||||
+22
-110
@@ -1,6 +1,8 @@
|
||||
@import '@pipu/design-tokens/tokens.css';
|
||||
@import '@fontsource/onest/400.css';
|
||||
@import '@fontsource/onest/500.css';
|
||||
@import '@fontsource/onest/600.css';
|
||||
@import '@fontsource/onest/700.css';
|
||||
@import '@fontsource/unbounded/600.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
|
||||
@@ -23,6 +25,11 @@ body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] body {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -34,118 +41,23 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@keyframes pipuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
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);
|
||||
}
|
||||
|
||||
.langs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.langs button {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.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);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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 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,
|
||||
}));
|
||||
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 '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,19 @@
|
||||
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() {} });
|
||||
@@ -0,0 +1,182 @@
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 60px 1fr 88px;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
'side content'
|
||||
'player player';
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top {
|
||||
grid-area: top;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.topRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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,132 @@
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
import {
|
||||
auditFillIcon,
|
||||
auditIcon,
|
||||
groupFillIcon,
|
||||
groupIcon,
|
||||
homeFillIcon,
|
||||
homeIcon,
|
||||
libraryFillIcon,
|
||||
libraryIcon,
|
||||
logoutIcon,
|
||||
historyFillIcon,
|
||||
historyIcon,
|
||||
likeFillIcon,
|
||||
likeIcon,
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
storageFillIcon,
|
||||
storageIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider } from '../player/context';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
|
||||
type NavPath =
|
||||
| '/'
|
||||
| '/admin/users'
|
||||
| '/admin/audit'
|
||||
| '/admin/sources'
|
||||
| '/admin/library'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
|
||||
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||
return (
|
||||
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</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';
|
||||
|
||||
return (
|
||||
<div className={s.shell}>
|
||||
<header className={s.top}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<SearchBox />
|
||||
<div className={s.topRight}>
|
||||
<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.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} />
|
||||
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
|
||||
{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} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<main className={s.content}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<PlayerBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession } from '../app/session';
|
||||
import { Segmented } from '../shared/ui/ui';
|
||||
|
||||
/** Переключатель языка. Для вошедшего пользователя язык сохраняется в профиле (PATCH /me). */
|
||||
export function LanguageSwitch() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const auth = useAuthState();
|
||||
|
||||
const change = async (l: Locale) => {
|
||||
document.documentElement.lang = l;
|
||||
await i18n.changeLanguage(l);
|
||||
if (auth.status !== 'signed_in') return;
|
||||
try {
|
||||
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||
} catch {
|
||||
// Язык уже переключён локально; сохранится при следующей смене.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Segmented
|
||||
label={t('language.label')}
|
||||
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 change(l)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
.bar {
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bar-a);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--text3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artists {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.accent {
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #05070d;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
|
||||
.play:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.range {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.range:hover::-webkit-slider-thumb,
|
||||
.range:focus-visible::-webkit-slider-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.range::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.volume {
|
||||
flex: 0 1 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.progress,
|
||||
.right,
|
||||
.controls > :first-child,
|
||||
.controls > :last-child,
|
||||
.now > button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
likeFillIcon,
|
||||
likeIcon,
|
||||
musicIcon,
|
||||
nextIcon,
|
||||
pauseIcon,
|
||||
playIcon,
|
||||
prevIcon,
|
||||
repeatIcon,
|
||||
repeatOneIcon,
|
||||
shuffleIcon,
|
||||
volumeIcon,
|
||||
volumeOffIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './PlayerBar.module.css';
|
||||
|
||||
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
||||
export function PlayerBar() {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
|
||||
if (!track) {
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<span className={s.empty}>
|
||||
<span className={s.placeholder}>
|
||||
<Icon src={musicIcon} size={22} />
|
||||
</span>
|
||||
{t('player.empty')}
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
const react = (r: 'like' | 'dislike') =>
|
||||
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
|
||||
return (
|
||||
<footer className={s.bar} aria-label={t('player.label')}>
|
||||
<div className={s.now}>
|
||||
<Cover album={track.album} size={52} />
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
|
||||
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
|
||||
aria-pressed={track.reaction === 'dislike'}
|
||||
onClick={() => react('dislike')}
|
||||
>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, track.reaction === 'like' && s.accent)}
|
||||
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={track.reaction === 'like'}
|
||||
onClick={() => react('like')}
|
||||
>
|
||||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={s.center}>
|
||||
<div className={s.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={st.shuffle}
|
||||
onClick={() => player.toggleShuffle()}
|
||||
>
|
||||
<Icon src={shuffleIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={s.play}
|
||||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
aria-busy={st.loading || undefined}
|
||||
onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={s.range}
|
||||
min={0}
|
||||
max={st.durationMs || track.duration_ms}
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
aria-valuetext={formatDuration(st.positionMs)}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
</div>
|
||||
{st.error && (
|
||||
<span className={s.error} role="status">
|
||||
{t(st.error as 'player.error')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.icon}
|
||||
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
|
||||
onClick={() => player.toggleMute()}
|
||||
>
|
||||
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className={cx(s.range, s.volume)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={st.muted ? 0 : st.volume}
|
||||
aria-label={t('player.volume')}
|
||||
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import s from '../pages/page.module.css';
|
||||
|
||||
/** Непредвиденная ошибка рендера страницы: короткое сообщение и перезагрузка. */
|
||||
export function RouteError() {
|
||||
const { t } = useTranslation(['common', 'errors']);
|
||||
return (
|
||||
<div className={s.empty} role="alert">
|
||||
<p>{t('errors:common.internal')}</p>
|
||||
<Button onClick={() => window.location.reload()}>{t('route_error.reload')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
.box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 40px 0 42px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line2);
|
||||
color: var(--text3);
|
||||
font: 12px var(--font-mono);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input:focus ~ .kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 45;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--elev);
|
||||
border: 1px solid var(--line2);
|
||||
box-shadow: var(--shadow);
|
||||
animation: pipuIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.corrected {
|
||||
margin: 4px 10px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: 8px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionTitle {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionSub {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nothing {
|
||||
padding: 16px 10px;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.all {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
margin-top: 6px;
|
||||
border-radius: 10px;
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.all:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.kbd {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './SearchBox.module.css';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
type Item = {
|
||||
key: string;
|
||||
group: 'artists' | 'albums' | 'tracks';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||
round?: boolean;
|
||||
go: () => void;
|
||||
};
|
||||
|
||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
||||
export function SearchBox() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [q]);
|
||||
|
||||
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
|
||||
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const suggest = useQuery({
|
||||
queryKey: ['suggest', debounced],
|
||||
enabled: debounced !== '',
|
||||
placeholderData: keepPreviousData,
|
||||
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
|
||||
queryFn: async ({ signal }) =>
|
||||
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const data = debounced ? suggest.data : undefined;
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setActive(-1);
|
||||
};
|
||||
const goSearch = () => {
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
void navigate({ to: '/search', search: { q: query } });
|
||||
};
|
||||
|
||||
const items: Item[] = [];
|
||||
if (data) {
|
||||
for (const a of data.artists)
|
||||
items.push({
|
||||
key: `ar-${a.id}`,
|
||||
group: 'artists',
|
||||
title: a.name,
|
||||
subtitle: t('search.artist_kind'),
|
||||
cover: { id: a.id },
|
||||
round: true,
|
||||
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
|
||||
});
|
||||
for (const al of data.albums)
|
||||
items.push({
|
||||
key: `al-${al.id}`,
|
||||
group: 'albums',
|
||||
title: al.title,
|
||||
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
|
||||
cover: al,
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
|
||||
});
|
||||
for (const tr of data.tracks)
|
||||
items.push({
|
||||
key: `tr-${tr.id}`,
|
||||
group: 'tracks',
|
||||
title: tr.title,
|
||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||
cover: tr.album,
|
||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||
});
|
||||
}
|
||||
|
||||
const choose = (i: number) => {
|
||||
const it = items[i];
|
||||
if (!it) return goSearch();
|
||||
close();
|
||||
setQ('');
|
||||
inputRef.current?.blur();
|
||||
it.go();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setActive((i) => Math.min(items.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => Math.max(-1, i - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
choose(active);
|
||||
} else if (e.key === 'Escape') {
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && debounced !== '' && data !== undefined;
|
||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
return (
|
||||
<div className={s.box}>
|
||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
className={s.input}
|
||||
value={q}
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.label')}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
setActive(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(close, 120)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
/
|
||||
</kbd>
|
||||
{showList && (
|
||||
<div className={s.popover}>
|
||||
{data.corrected_query && (
|
||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||
)}
|
||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||
{groups.map((g) => {
|
||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<div key={g} role="group" aria-label={t(`search.${g}`)}>
|
||||
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
|
||||
{groupItems.map(({ it, i }) => (
|
||||
<div
|
||||
key={it.key}
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={cx(s.option, i === active && s.active)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => choose(i)}
|
||||
>
|
||||
<Cover album={it.cover} size={36} round={it.round} />
|
||||
<span className={s.optionText}>
|
||||
<span className={s.optionTitle}>{it.title}</span>
|
||||
<span className={s.optionSub}>{it.subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={s.all}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={goSearch}
|
||||
>
|
||||
{t('search.show_all')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,9 @@
|
||||
# PIPU_VERSION проставляет update.sh по git-тегу — вручную не менять
|
||||
PIPU_VERSION=dev
|
||||
PIPU_DATA=/srv/pipu
|
||||
# Папка с музыкой на локальном диске сервера (источники type=local), например смонтированный диск.
|
||||
# Пусто — $PIPU_DATA/library-host. NFS-хранилища сюда не относятся: их подключает админка.
|
||||
LIBRARY_HOST=
|
||||
|
||||
# --- Домен и HTTPS (docs/install-guide.md) ---
|
||||
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
|
||||
@@ -17,6 +20,13 @@ HTTP_PORT=80
|
||||
POSTGRES_PASSWORD=
|
||||
MEILI_MASTER_KEY=
|
||||
INTERNAL_TOKEN=
|
||||
# Ключ подписи access-токенов (Ed25519, base64 от 32 случайных байт). install.sh и update.sh
|
||||
# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить
|
||||
# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены.
|
||||
JWT_PRIVATE_KEY=
|
||||
# Подпись ссылок на обложки и аудио (base64 от 32 байт); создают install.sh / update.sh.
|
||||
STREAM_SIGNING_KEY=
|
||||
#JWT_PREVIOUS_KEYS=
|
||||
|
||||
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
|
||||
#BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
|
||||
@@ -45,6 +45,12 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MEILI_URL: http://meilisearch:7700
|
||||
MEILI_MASTER_KEY: dev-master-key-change-me
|
||||
INTERNAL_TOKEN: dev-internal-token
|
||||
COOKIE_SECURE: "false"
|
||||
X_ACCEL: "false"
|
||||
CACHE_DIR: /tmp/pipu-cache
|
||||
LOG_LEVEL: debug
|
||||
ports: ["127.0.0.1:8080:8080"]
|
||||
depends_on:
|
||||
|
||||
@@ -17,6 +17,15 @@ x-library-ro: &library-ro
|
||||
read_only: true
|
||||
bind: { propagation: rslave }
|
||||
|
||||
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||
# Всем сервисам, включая storage-agent, — только чтение: импорт загрузок ещё не реализован,
|
||||
# запись откроем вместе с ним (docs/library-and-storage.md).
|
||||
x-library-host-ro: &library-host-ro
|
||||
type: bind
|
||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||
target: /library-host
|
||||
read_only: true
|
||||
|
||||
x-restart: &restart
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -31,6 +40,7 @@ services:
|
||||
ports: ["${HTTP_PORT:-80}:80"]
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||
depends_on: [api]
|
||||
@@ -48,6 +58,7 @@ services:
|
||||
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
||||
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
||||
@@ -75,6 +86,7 @@ services:
|
||||
source: ${PIPU_DATA:-/srv/pipu}/library
|
||||
target: /library
|
||||
bind: { propagation: rshared }
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||
|
||||
recommender:
|
||||
@@ -102,6 +114,7 @@ services:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/models:/models
|
||||
deploy: { resources: { limits: { cpus: "4" } } }
|
||||
depends_on:
|
||||
|
||||
Regular → Executable
+30
-1
@@ -17,6 +17,16 @@ mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,mei
|
||||
chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads}
|
||||
chown 10001:10001 "$DATA/models"
|
||||
|
||||
# Папка с локальной музыкой: sudo LIBRARY_HOST=/mnt/music ./install.sh (или LIBRARY_HOST в .env)
|
||||
LIBRARY_HOST="${LIBRARY_HOST:-$( [ -f .env ] && sed -n 's/^LIBRARY_HOST=//p' .env || true )}"
|
||||
if [ -n "$LIBRARY_HOST" ]; then
|
||||
if [ ! -d "$LIBRARY_HOST" ]; then
|
||||
echo "LIBRARY_HOST=$LIBRARY_HOST: каталога нет — смонтируйте диск с музыкой (docs/install-guide.md, шаг 4)" >&2
|
||||
exit 1
|
||||
fi
|
||||
findmnt -n --target "$LIBRARY_HOST" -o TARGET,SOURCE | sed 's/^/→ музыка: /'
|
||||
fi
|
||||
|
||||
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
|
||||
# каталог должен быть отдельной точкой монтирования с shared propagation.
|
||||
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
|
||||
@@ -50,10 +60,29 @@ secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
|
||||
if [ ! -f .env ]; then
|
||||
echo "→ .env из .env.example"
|
||||
cp .env.example .env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|; s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||
chmod 600 .env
|
||||
chown "$OWNER" .env
|
||||
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
||||
fi
|
||||
|
||||
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||
# в том числе в .env, сделанном до их появления.
|
||||
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
||||
if ! grep -q "^$key=." .env; then
|
||||
sed -i "/^$key=\$/d" .env
|
||||
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
|
||||
echo "→ в .env добавлен $key"
|
||||
fi
|
||||
done
|
||||
|
||||
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||
if [ -n "$LIBRARY_HOST" ]; then
|
||||
if grep -q '^LIBRARY_HOST=' .env; then
|
||||
sed -i "s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||
else
|
||||
echo "LIBRARY_HOST=$LIBRARY_HOST" >> .env
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||
@@ -48,6 +48,7 @@ server {
|
||||
|
||||
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
|
||||
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
|
||||
location /_internal/library-host/ { internal; alias /library-host/; sendfile on; aio threads; }
|
||||
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
|
||||
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
|
||||
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
|
||||
|
||||
Regular → Executable
+12
-1
@@ -16,6 +16,16 @@ cd "$PIPU_DEPLOY_DIR"
|
||||
|
||||
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
||||
|
||||
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||
# в том числе в .env, сделанном до их появления.
|
||||
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
||||
if ! grep -q "^$key=." .env; then
|
||||
sed -i "/^$key=\$/d" .env
|
||||
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
|
||||
echo "→ в .env добавлен $key"
|
||||
fi
|
||||
done
|
||||
|
||||
REF="${1:-}"
|
||||
git fetch --tags --prune --quiet origin
|
||||
|
||||
@@ -62,7 +72,8 @@ docker image prune -f >/dev/null
|
||||
|
||||
echo "→ ждём готовности api"
|
||||
for _ in $(seq 1 60); do
|
||||
if docker compose exec -T nginx wget -qO- http://localhost/readyz >/dev/null 2>&1; then
|
||||
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
|
||||
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
echo "pipu $VERSION запущен"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -114,7 +114,7 @@ pipu-music/
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright
|
||||
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
|
||||
├── .gitea/workflows/ # ci.yml, release.yml
|
||||
├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
|
||||
├── docs/
|
||||
├── turbo.json pnpm-workspace.yaml package.json
|
||||
└── README.md
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
|
||||
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
|
||||
встроенному списку 10k самых частых паролей.
|
||||
встроенному списку частых паролей (`internal/auth/common_passwords.txt`; пока ~150 записей, цель — 10k).
|
||||
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
|
||||
у пользователя ставится `must_change_password = true`.
|
||||
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
|
||||
@@ -73,10 +73,16 @@
|
||||
| Android | в памяти | `expo-secure-store` |
|
||||
|
||||
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
|
||||
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи.
|
||||
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи
|
||||
(`401 auth.refresh_reused`). Исключение — первые 10 с после ротации: так бывает, когда две вкладки
|
||||
обновляют токен одновременно; ответ `401 auth.refresh_race` без отзыва, клиент повторяет запрос
|
||||
с уже новой cookie.
|
||||
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
|
||||
(в Redis-кэше), middleware сравнивает с `ver` в access-токене.
|
||||
- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей.
|
||||
- Ключи подписи: `JWT_PRIVATE_KEY` (base64 seed Ed25519) из env; `kid` = первые 8 байт SHA-256 публичного ключа,
|
||||
прежние ключи для проверки — `JWT_PREVIOUS_KEYS`.
|
||||
- Смена пароля и учётных данных отзывает все сессии и сразу выдаёт новую пару текущему устройству —
|
||||
пользователь остаётся в системе, остальные устройства разлогиниваются.
|
||||
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
|
||||
поэтому CSRF к ним неприменим.
|
||||
|
||||
|
||||
+86
-32
@@ -17,28 +17,43 @@ C++-сборка React Native). APK собирается только под `ar
|
||||
(тесты — `pnpm test:scripts`).
|
||||
- Changelog — `git-cliff` из Conventional Commits.
|
||||
|
||||
## `ci.yml` — на каждый push и PR
|
||||
## CI — на каждый push в `main` и PR
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
pull_request:
|
||||
Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
|
||||
что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
|
||||
|
||||
jobs:
|
||||
changes: # фильтр путей: api / recommender / web / mobile / packages / docs
|
||||
contract: # spectral lint openapi.yaml; регенерация Go + TS клиентов; git diff --exit-code
|
||||
api: # go vet, golangci-lint, go test -race ./... (testcontainers), покрытие
|
||||
recommender: # uv sync; ruff; mypy --strict; pytest (вкл. оффлайн-оценку качества)
|
||||
ts: # pnpm install --frozen-lockfile; turbo lint typecheck test; i18n:check
|
||||
web-build: # vite build, проверка размера бандла
|
||||
docker: # сборка всех Docker-образов без push (сервер собирает их сам — ADR-013)
|
||||
mobile-check: # tsc, jest, expo prebuild + ./gradlew assembleDebug (только если менялся mobile/)
|
||||
e2e: # needs: api, web-build; docker compose -f deploy/compose.test.yaml up; playwright test
|
||||
```
|
||||
| Workflow | Job | Что делает | Когда |
|
||||
|---|---|---|---|
|
||||
| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
|
||||
| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
|
||||
| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
|
||||
| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
|
||||
| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
|
||||
| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
|
||||
|
||||
- Кэши: Go modules / build cache, pnpm store, uv cache, Gradle, сгенерированные тестовые аудио.
|
||||
- Ветка `main` защищена: обязательны `contract`, `api`, `recommender`, `ts`, `e2e`.
|
||||
- Ночью (`schedule`) — Maestro E2E на эмуляторе и полный прогон без фильтров путей.
|
||||
Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
|
||||
Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
|
||||
ветку отменяет устаревший прогон.
|
||||
|
||||
`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
|
||||
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
|
||||
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
|
||||
|
||||
### Кэши
|
||||
|
||||
| Что | Где кэшируется | Чем |
|
||||
|---|---|---|
|
||||
| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
|
||||
| Go-модули и build cache | `api`, `contract` | `setup-go` |
|
||||
| uv | `recommender` | `setup-uv` `enable-cache` |
|
||||
| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
|
||||
| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
|
||||
| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
|
||||
| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
|
||||
| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
|
||||
|
||||
Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
|
||||
`Cache action is only supported on GHES…`, и всё скачивается заново.
|
||||
|
||||
## `release.yml` — по тегу
|
||||
|
||||
@@ -48,30 +63,69 @@ on:
|
||||
tags: ['v*.*.*']
|
||||
|
||||
jobs:
|
||||
verify: # все проверки ci.yml (reusable workflow)
|
||||
|
||||
android-release: # needs: verify
|
||||
# setup-java 17, Android SDK, pnpm install
|
||||
# VERSION / VERSION_CODE из тега → app.config.ts
|
||||
# expo prebuild --platform android --clean
|
||||
# ./gradlew assembleRelease (signingConfig из секретов)
|
||||
# apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256
|
||||
|
||||
# changelog из Conventional Commits между тегами
|
||||
# создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу
|
||||
# загрузить ассеты: APK и .sha256
|
||||
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
|
||||
android: # needs: release — собирает APK и докладывает его в релиз
|
||||
```
|
||||
|
||||
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
|
||||
после зелёного CI** — релиз сам этого не проверяет.
|
||||
|
||||
Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
|
||||
|
||||
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
|
||||
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
|
||||
- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
|
||||
«Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
|
||||
финального, а не от последнего `-rc`; ссылка на полный diff;
|
||||
- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
|
||||
|
||||
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`,
|
||||
затем `release.mjs android success <apk>` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
|
||||
`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
|
||||
Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
|
||||
|
||||
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
|
||||
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
|
||||
|
||||
## Настройка раннера
|
||||
|
||||
Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
|
||||
cache:
|
||||
enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
|
||||
host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
|
||||
port: 8088 # фиксированный порт; открыть его для docker-сетей
|
||||
container:
|
||||
valid_volumes:
|
||||
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
|
||||
```
|
||||
|
||||
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
|
||||
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
|
||||
|
||||
Обслуживание хоста раннера (cron, раз в сутки):
|
||||
|
||||
```
|
||||
0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
|
||||
```
|
||||
|
||||
Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
|
||||
следующий релиз скачает всё заново.
|
||||
|
||||
## Секреты
|
||||
|
||||
| Секрет | Назначение |
|
||||
|---|---|
|
||||
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
|
||||
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
|
||||
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
|
||||
|
||||
Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
|
||||
в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
|
||||
адресу, и ссылки из ответа API ведут туда же.
|
||||
|
||||
## Деплой
|
||||
|
||||
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
|
||||
|
||||
+4
-2
@@ -38,6 +38,7 @@ CREATE TABLE refresh_tokens (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
family_id uuid NOT NULL, -- цепочка ротаций одного входа
|
||||
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
||||
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
||||
device_name text, -- "Chrome / Windows", "Pixel 8"
|
||||
client_type text NOT NULL, -- 'web' | 'android'
|
||||
@@ -196,7 +197,7 @@ CREATE TABLE albums (
|
||||
id uuid PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
title_norm text NOT NULL,
|
||||
album_artist_id uuid REFERENCES artists(id),
|
||||
album_artist_id uuid NOT NULL REFERENCES artists(id), -- без тегов — исполнитель трека или «Unknown artist»
|
||||
year int,
|
||||
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
|
||||
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
|
||||
@@ -207,7 +208,8 @@ CREATE TABLE albums (
|
||||
CREATE TABLE tracks (
|
||||
id uuid PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
album_id uuid REFERENCES albums(id),
|
||||
title_norm text NOT NULL, -- ключ поиска дублей: (album_id, title_norm, duration ±2 с)
|
||||
album_id uuid NOT NULL REFERENCES albums(id),
|
||||
disc_no int, track_no int,
|
||||
year int,
|
||||
duration_ms int NOT NULL,
|
||||
|
||||
+9
-2
@@ -15,7 +15,7 @@
|
||||
```
|
||||
/srv/pipu/
|
||||
├── library/ # точки монтирования источников (storage-agent, rshared)
|
||||
├── library-host/ # опционально: заранее смонтированные папки для type=local
|
||||
├── library-host/ # папка для type=local по умолчанию; обычно вместо неё LIBRARY_HOST=/mnt/music
|
||||
├── cache/
|
||||
│ ├── transcode/
|
||||
│ └── covers/
|
||||
@@ -29,6 +29,10 @@
|
||||
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
||||
```
|
||||
|
||||
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
|
||||
каталог хоста монтируется во все сервисы, включая `storage-agent`, только на чтение (запись появится вместе
|
||||
с импортом одобренных загрузок), внутри контейнеров он виден как `/library-host`.
|
||||
|
||||
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
||||
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
||||
|
||||
@@ -63,7 +67,10 @@
|
||||
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | |
|
||||
| `MEILI_URL`, `MEILI_MASTER_KEY` | | |
|
||||
| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` |
|
||||
| `JWT_PRIVATE_KEY` | base64 от 32 байт (seed Ed25519) | Подпись access-токенов. Создают `install.sh` / `update.sh`, если его нет; пусто — случайный ключ до перезапуска |
|
||||
| `JWT_PREVIOUS_KEYS` | пусто | Прежние ключи через запятую — токены, подписанные ими, ещё принимаются (ротация) |
|
||||
| `ACCESS_TOKEN_TTL` | `15m` | Срок access-токена |
|
||||
| `COOKIE_SECURE` | `true` | Флаг `Secure` у refresh-cookie; `false` — только для разработки по http не на localhost |
|
||||
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
|
||||
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
|
||||
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
|
||||
|
||||
+76
-17
@@ -6,8 +6,9 @@
|
||||
|
||||
В примерах:
|
||||
- домен — `music.example.com`, замените на свой;
|
||||
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`;
|
||||
- каталог установки — `/opt/pipu-music`.
|
||||
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`, клонируется в `/opt/pipu-music`;
|
||||
- диск с музыкой смонтирован в `/mnt/music`;
|
||||
- данные сервиса (БД, кэши, сертификаты) — в `/srv/pipu`.
|
||||
|
||||
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
|
||||
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
|
||||
@@ -22,11 +23,11 @@
|
||||
|
||||
| Что | Требование |
|
||||
|---|---|
|
||||
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на диске (система, образы, кэш сборки, данные) |
|
||||
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на системном диске (система, образы, кэш сборки, данные) |
|
||||
| Доступ | root или пользователь с `sudo` |
|
||||
| Домен | Поддомен, которым вы управляете, например `music.example.com` |
|
||||
| Сеть | Входящие **80/tcp** и **443/tcp** (+ **443/udp** для HTTP/3) из интернета на сервер |
|
||||
| Музыка | NFS-сервер с библиотекой (подключается позже из админки) |
|
||||
| Музыка | Отдельный диск с библиотекой, подключённый к серверу (шаг 4). NFS-хранилища можно добавить позже из админки |
|
||||
| Интернет на сервере | Нужен при сборке: скачиваются базовые образы и зависимости Go / npm / Python |
|
||||
| Gitea | Доступ к репозиторию на чтение (если он приватный — токен или SSH-ключ) |
|
||||
|
||||
@@ -50,7 +51,7 @@ dig +short music.example.com
|
||||
```
|
||||
|
||||
> Сертификат Let's Encrypt выдаётся, только когда домен уже указывает на сервер и порт 80 доступен из интернета.
|
||||
> Не запускайте шаг 6, пока эта проверка не проходит.
|
||||
> Не запускайте шаг 7, пока эта проверка не проходит.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +87,51 @@ sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp
|
||||
|
||||
---
|
||||
|
||||
## 4. Клонирование репозитория
|
||||
## 4. Диск с музыкой
|
||||
|
||||
Музыка лежит на отдельном диске, смонтированном в `/mnt/music`. Если диск уже смонтирован — проверьте его
|
||||
командой из конца шага и переходите к шагу 5.
|
||||
|
||||
Найдите диск и его UUID (раздел с музыкой — например, `/dev/sdb1`):
|
||||
|
||||
```bash
|
||||
lsblk -f
|
||||
```
|
||||
|
||||
Создайте точку монтирования:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/music
|
||||
```
|
||||
|
||||
Добавьте в `/etc/fstab` строку (подставьте свой UUID и файловую систему из вывода `lsblk -f`):
|
||||
|
||||
```ini
|
||||
UUID=1234abcd-0000-0000-0000-000000000000 /mnt/music ext4 defaults,nofail,noatime 0 2
|
||||
```
|
||||
|
||||
`nofail` нужен, чтобы сервер загружался, даже если диск не подключён. Смонтируйте и проверьте:
|
||||
|
||||
```bash
|
||||
sudo mount -a && findmnt /mnt/music && ls /mnt/music | head
|
||||
```
|
||||
|
||||
Сервисы Pipu работают в контейнерах не от root, поэтому музыка должна быть доступна на чтение всем:
|
||||
файлы — `644`, каталоги — `755`. Если раньше права были закрытыми:
|
||||
|
||||
```bash
|
||||
sudo chmod -R a+rX /mnt/music
|
||||
```
|
||||
|
||||
> Для дисков с NTFS / exFAT права задаются опциями монтирования, а не `chmod`: добавьте в fstab
|
||||
> `uid=0,gid=0,umask=022` вместо `defaults`.
|
||||
|
||||
Pipu только читает этот диск. Записывать в него будет лишь импорт одобренных треков из очереди на добавление,
|
||||
и только если в админке разрешить это для хранилища ([uploads.md](uploads.md)).
|
||||
|
||||
---
|
||||
|
||||
## 5. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/pipu-music && sudo chown "$USER" /opt/pipu-music
|
||||
@@ -104,16 +149,18 @@ git clone https://gitea.hsrv.site/mrleo1nid/pipu-music.git /opt/pipu-music && cd
|
||||
|
||||
---
|
||||
|
||||
## 5. Подготовка сервера
|
||||
## 6. Подготовка сервера
|
||||
|
||||
```bash
|
||||
sudo ./install.sh
|
||||
sudo LIBRARY_HOST=/mnt/music ./install.sh
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
- создаёт каталоги данных в `/srv/pipu` (БД, кэши, загрузки, сертификаты);
|
||||
- делает `/srv/pipu/library` точкой монтирования с shared propagation, чтобы NFS-хранилища, которые потом
|
||||
подключит админ, были видны всем контейнерам, и ставит systemd-юнит, повторяющий это после перезагрузки;
|
||||
- проверяет, что `/mnt/music` существует, и записывает его в `.env` как `LIBRARY_HOST` — эту папку получат
|
||||
контейнеры `api`, `nginx` и `analyzer` (внутри они видят её как `/library-host`);
|
||||
- создаёт `.env` со случайными паролями БД, ключом поиска и межсервисным токеном.
|
||||
|
||||
Заполните в `/opt/pipu-music/deploy/.env`:
|
||||
@@ -124,6 +171,14 @@ DOMAIN=music.example.com
|
||||
ACME_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
Проверьте, что там же стоит путь к музыке (его вписал `install.sh`):
|
||||
|
||||
```ini
|
||||
LIBRARY_HOST=/mnt/music
|
||||
```
|
||||
|
||||
Если диск смонтирован в другое место — поправьте путь здесь и перезапустите сервисы (`./update.sh`).
|
||||
|
||||
`ACME_EMAIL` — адрес, на который Let's Encrypt пишет, если с сертификатом что-то не так.
|
||||
Остальные значения менять не нужно; `PIPU_VERSION` проставляет `update.sh`.
|
||||
|
||||
@@ -131,7 +186,7 @@ ACME_EMAIL=you@example.com
|
||||
|
||||
---
|
||||
|
||||
## 6. Сборка и запуск
|
||||
## 7. Сборка и запуск
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
@@ -161,7 +216,7 @@ curl -s https://music.example.com/readyz
|
||||
|
||||
---
|
||||
|
||||
## 7. Веб-клиент
|
||||
## 8. Веб-клиент
|
||||
|
||||
Откройте `https://music.example.com` в браузере.
|
||||
|
||||
@@ -176,12 +231,14 @@ curl -s https://music.example.com/readyz
|
||||
Сразу после входа приложение попросит сменить логин и пароль администратора
|
||||
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
|
||||
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
|
||||
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → адрес NFS-сервера и путь экспорта
|
||||
→ «Проверить» → шаблоны путей → «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
||||
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
|
||||
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
|
||||
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
||||
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
|
||||
|
||||
---
|
||||
|
||||
## 8. Android-приложение
|
||||
## 9. Android-приложение
|
||||
|
||||
1. На телефоне откройте страницу релизов
|
||||
`https://gitea.hsrv.site/mrleo1nid/pipu-music/releases` и скачайте `pipu-music-<версия>.apk`.
|
||||
@@ -201,7 +258,7 @@ curl -s https://music.example.com/readyz
|
||||
|
||||
---
|
||||
|
||||
## 9. Вариант: свой reverse proxy вместо Caddy
|
||||
## 10. Вариант: свой reverse proxy вместо Caddy
|
||||
|
||||
Если на сервере уже есть Traefik, Nginx Proxy Manager или свой nginx с сертификатами:
|
||||
|
||||
@@ -238,14 +295,14 @@ curl -s https://music.example.com/readyz
|
||||
}
|
||||
```
|
||||
|
||||
3. Запуск и проверки — как в шагах 6–8.
|
||||
3. Запуск и проверки — как в шагах 7–9.
|
||||
|
||||
Если прокси работает на другой машине, укажите в `HTTP_PORT` внутренний адрес сервера (`192.168.1.10:8081`) и
|
||||
закройте этот порт от интернета файрволом.
|
||||
|
||||
---
|
||||
|
||||
## 10. Обновление, бэкапы, удаление
|
||||
## 11. Обновление, бэкапы, удаление
|
||||
|
||||
Обновление до последнего релиза:
|
||||
|
||||
@@ -271,7 +328,7 @@ cd /opt/pipu-music/deploy && docker compose down
|
||||
|
||||
---
|
||||
|
||||
## 11. Если что-то не работает
|
||||
## 12. Если что-то не работает
|
||||
|
||||
| Симптом | Что проверить |
|
||||
|---|---|
|
||||
@@ -282,6 +339,8 @@ cd /opt/pipu-music/deploy && docker compose down
|
||||
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
||||
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
||||
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
||||
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
|
||||
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
|
||||
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
||||
| Android: «Сервер не отвечает» | Тот же адрес открывается в браузере телефона; в адресе нет опечатки; телефон в сети с доступом к серверу |
|
||||
| Android: «Эта версия приложения слишком старая» | Установите APK из последнего релиза |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Тип | Как подключается |
|
||||
|---|---|
|
||||
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
|
||||
| `local` | Папка, уже смонтированная на хосте в `/srv/pipu/library-host` (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev. |
|
||||
| `local` | Папка на диске сервера, заранее смонтированная на хосте (например, диск `/mnt/music` через fstab) и указанная в `LIBRARY_HOST`. В контейнерах видна как `/library-host`; админ выбирает подпапку. |
|
||||
|
||||
### Монтирование (storage-agent)
|
||||
|
||||
|
||||
+3
-3
@@ -10,10 +10,10 @@
|
||||
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
|
||||
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
||||
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку |
|
||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) |
|
||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
|
||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
|
||||
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
|
||||
| Формы | React Hook Form + Zod |
|
||||
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
|
||||
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
||||
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"generate": "turbo run generate",
|
||||
"i18n:check": "pnpm --filter @pipu/i18n check",
|
||||
"version:info": "node scripts/version.mjs",
|
||||
"test:scripts": "node --test scripts/version.test.mjs"
|
||||
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "2.5.4",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
|
||||
|
||||
const user = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
username: 'alice',
|
||||
display_name: null,
|
||||
role: 'user' as const,
|
||||
locale: 'ru',
|
||||
ui_prefs: {},
|
||||
must_change_password: false,
|
||||
must_change_credentials: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const tokens = (access: string, refresh?: string): AuthTokens => ({
|
||||
access_token: access,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
refresh_token: refresh,
|
||||
user,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
type Handler = (req: Request) => Response | Promise<Response>;
|
||||
|
||||
/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */
|
||||
function server(routes: Record<string, Handler>) {
|
||||
const log: { route: string; auth: string | null; body: string }[] = [];
|
||||
const fetchImpl = async (input: RequestInfo | URL) => {
|
||||
const req = input as Request;
|
||||
const route = `${req.method} ${new URL(req.url).pathname}`;
|
||||
log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() });
|
||||
const h = routes[route];
|
||||
if (!h) return problem(404, 'common.not_found');
|
||||
return h(req);
|
||||
};
|
||||
return { fetch: fetchImpl as typeof fetch, log };
|
||||
}
|
||||
|
||||
const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({
|
||||
value: initial,
|
||||
async get() {
|
||||
return this.value;
|
||||
},
|
||||
async set(v) {
|
||||
this.value = v;
|
||||
},
|
||||
async clear() {
|
||||
this.value = null;
|
||||
},
|
||||
});
|
||||
|
||||
describe('AuthSession', () => {
|
||||
it('logs in and sends the bearer token', async () => {
|
||||
const s = server({
|
||||
'POST /api/v1/auth/login': () => json(tokens('A1')),
|
||||
'GET /api/v1/me': (req) =>
|
||||
req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' });
|
||||
const states: string[] = [];
|
||||
auth.subscribe((st) => states.push(st.status));
|
||||
|
||||
await auth.login('alice', 'secret');
|
||||
expect(JSON.parse(s.log[0]!.body)).toEqual({
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
client_type: 'web',
|
||||
device_name: 'Test',
|
||||
});
|
||||
const me = await auth.client.GET('/api/v1/me');
|
||||
expect(me.data?.username).toBe('alice');
|
||||
expect(states).toEqual(['signed_in']);
|
||||
});
|
||||
|
||||
it('turns problem+json into ApiError with field errors', async () => {
|
||||
const s = server({
|
||||
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
const err = await auth.login('alice', 'bad').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as ApiError).code).toBe('auth.invalid_credentials');
|
||||
expect(auth.state.status).toBe('unknown');
|
||||
|
||||
const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [
|
||||
{ field: 'new_password', code: 'validation.password_too_short' },
|
||||
]);
|
||||
expect(e2.fieldCode('new_password')).toBe('validation.password_too_short');
|
||||
expect(e2.fieldCode('other')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes once for parallel expired requests and retries them', async () => {
|
||||
let refreshes = 0;
|
||||
const s = server({
|
||||
'POST /api/v1/auth/login': () => json(tokens('old')),
|
||||
'POST /api/v1/auth/refresh': async () => {
|
||||
refreshes++;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return json(tokens('new'));
|
||||
},
|
||||
'GET /api/v1/me': (req) =>
|
||||
req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
await auth.login('alice', 'secret');
|
||||
|
||||
const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me')));
|
||||
expect(results.every((r) => r.data?.username === 'alice')).toBe(true);
|
||||
expect(refreshes).toBe(1);
|
||||
});
|
||||
|
||||
it('does not refresh on other 401 codes and signs out a blocked user', async () => {
|
||||
const s = server({
|
||||
'POST /api/v1/auth/login': () => json(tokens('A')),
|
||||
'GET /api/v1/me': () => problem(401, 'auth.user_blocked'),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
await auth.login('alice', 'secret');
|
||||
const res = await auth.client.GET('/api/v1/me');
|
||||
expect(res.response.status).toBe(401);
|
||||
expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false);
|
||||
expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' });
|
||||
});
|
||||
|
||||
it('signs out when refresh is rejected', async () => {
|
||||
const s = server({
|
||||
'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' });
|
||||
});
|
||||
|
||||
it('retries a refresh race once', async () => {
|
||||
let n = 0;
|
||||
const s = server({
|
||||
'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
expect((await auth.restore()).status).toBe('signed_in');
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the session on network errors during refresh', async () => {
|
||||
const auth = new AuthSession({
|
||||
baseUrl: 'http://x',
|
||||
fetch: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
clientType: 'web',
|
||||
});
|
||||
expect(await auth.refresh()).toBe(false);
|
||||
expect(auth.state.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('android keeps the refresh token in the store', async () => {
|
||||
const store = memoryStore('R0');
|
||||
const s = server({
|
||||
'POST /api/v1/auth/refresh': async (req) => {
|
||||
const body = (await req.json()) as { refresh_token: string };
|
||||
return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid');
|
||||
},
|
||||
'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }),
|
||||
});
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store });
|
||||
expect((await auth.restore()).status).toBe('signed_in');
|
||||
expect(store.value).toBe('R1');
|
||||
|
||||
await auth.logout();
|
||||
expect(store.value).toBeNull();
|
||||
expect(auth.state.status).toBe('signed_out');
|
||||
expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' });
|
||||
expect(await auth.refresh()).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a refresh store for android', () => {
|
||||
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
|
||||
});
|
||||
|
||||
it('notifies subscribers about profile updates', async () => {
|
||||
const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) });
|
||||
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
|
||||
const fn = vi.fn();
|
||||
auth.subscribe(fn);
|
||||
await auth.login('alice', 'x');
|
||||
auth.setUser({ ...user, display_name: 'Alice' });
|
||||
expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
|
||||
|
||||
export type AuthUser = components['schemas']['Me'];
|
||||
export type AuthTokens = components['schemas']['AuthTokens'];
|
||||
export type ClientType = components['schemas']['ClientType'];
|
||||
|
||||
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly params: Record<string, unknown>;
|
||||
readonly fields: { field: string; code: string }[];
|
||||
|
||||
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
|
||||
super(code);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.params = params;
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
/** Код ошибки конкретного поля формы, если сервер его указал. */
|
||||
fieldCode(field: string): string | undefined {
|
||||
return this.fields.find((f) => f.field === field)?.code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
|
||||
export function toApiError(error: unknown, response?: Response): ApiError {
|
||||
if (error instanceof ApiError) return error;
|
||||
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
|
||||
if (typeof p.code === 'string') {
|
||||
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
|
||||
}
|
||||
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
|
||||
return new ApiError(0, 'common.network');
|
||||
}
|
||||
|
||||
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
|
||||
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
|
||||
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
|
||||
export interface RefreshStore {
|
||||
get(): Promise<string | null>;
|
||||
set(token: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'unknown' }
|
||||
| { status: 'signed_out'; reason?: string }
|
||||
| { status: 'signed_in'; user: AuthUser };
|
||||
|
||||
export interface AuthSessionOptions {
|
||||
/** Адрес сервера; пусто — тот же origin (веб). */
|
||||
baseUrl?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
clientType: ClientType;
|
||||
deviceName?: string;
|
||||
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
|
||||
refreshStore?: RefreshStore;
|
||||
}
|
||||
|
||||
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
|
||||
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
|
||||
|
||||
const RACE_RETRY_MS = 300;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
|
||||
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
|
||||
* параллельные запросы (single-flight), после чего повторяет их.
|
||||
*/
|
||||
export class AuthSession {
|
||||
readonly client: ApiClient;
|
||||
private readonly raw: ApiClient;
|
||||
private readonly opts: AuthSessionOptions;
|
||||
private readonly baseFetch: typeof globalThis.fetch;
|
||||
private access: string | null = null;
|
||||
private refreshing: Promise<boolean> | null = null;
|
||||
private current: AuthState = { status: 'unknown' };
|
||||
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||
|
||||
constructor(opts: AuthSessionOptions) {
|
||||
if (opts.clientType === 'android' && !opts.refreshStore) {
|
||||
throw new Error('AuthSession: android client needs a refreshStore');
|
||||
}
|
||||
this.opts = opts;
|
||||
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
|
||||
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
|
||||
}
|
||||
|
||||
get state(): AuthState {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Подписка на смену состояния; возвращает функцию отписки. */
|
||||
subscribe(fn: (s: AuthState) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private setState(s: AuthState) {
|
||||
this.current = s;
|
||||
for (const fn of this.listeners) fn(s);
|
||||
}
|
||||
|
||||
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
|
||||
async restore(): Promise<AuthState> {
|
||||
if (!(await this.refresh())) {
|
||||
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<AuthUser> {
|
||||
const res = await this.raw.POST('/api/v1/auth/login', {
|
||||
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
|
||||
});
|
||||
return this.apply(unwrap(res));
|
||||
}
|
||||
|
||||
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
|
||||
async apply(tokens: AuthTokens): Promise<AuthUser> {
|
||||
this.access = tokens.access_token;
|
||||
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
|
||||
this.setState({ status: 'signed_in', user: tokens.user });
|
||||
return tokens.user;
|
||||
}
|
||||
|
||||
/** Обновляет профиль в состоянии (после PATCH /me). */
|
||||
setUser(user: AuthUser) {
|
||||
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
|
||||
}
|
||||
|
||||
async logout(all = false): Promise<void> {
|
||||
try {
|
||||
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
|
||||
} finally {
|
||||
await this.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
private async signOut(reason?: string) {
|
||||
this.access = null;
|
||||
await this.opts.refreshStore?.clear();
|
||||
this.setState({ status: 'signed_out', reason });
|
||||
}
|
||||
|
||||
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
|
||||
refresh(): Promise<boolean> {
|
||||
this.refreshing ??= this.doRefresh().finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
private async doRefresh(attempt = 0): Promise<boolean> {
|
||||
let body: { refresh_token?: string } | undefined;
|
||||
if (this.opts.refreshStore) {
|
||||
const token = await this.opts.refreshStore.get();
|
||||
if (!token) return false;
|
||||
body = { refresh_token: token };
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await this.raw.POST('/api/v1/auth/refresh', { body });
|
||||
} catch {
|
||||
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
|
||||
}
|
||||
if (res.data) {
|
||||
await this.apply(res.data);
|
||||
return true;
|
||||
}
|
||||
const err = toApiError(res.error, res.response);
|
||||
if (err.code === 'auth.refresh_race' && attempt === 0) {
|
||||
// Другая вкладка только что обновила cookie — повторяем уже с новой.
|
||||
await sleep(RACE_RETRY_MS);
|
||||
return this.doRefresh(attempt + 1);
|
||||
}
|
||||
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async authFetch(request: Request): Promise<Response> {
|
||||
const retry = request.clone();
|
||||
const sent = this.access;
|
||||
const res = await this.baseFetch(this.withAuth(request));
|
||||
if (res.status !== 401) return res;
|
||||
|
||||
const code = await res
|
||||
.clone()
|
||||
.json()
|
||||
.then((p: { code?: string }) => p.code)
|
||||
.catch(() => undefined);
|
||||
if (!code || !RETRYABLE.has(code)) {
|
||||
if (code === 'auth.user_blocked') await this.signOut(code);
|
||||
return res;
|
||||
}
|
||||
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
|
||||
const fresh = this.access !== null && this.access !== sent;
|
||||
if (!fresh && !(await this.refresh())) return res;
|
||||
return this.baseFetch(this.withAuth(retry));
|
||||
}
|
||||
|
||||
private withAuth(request: Request): Request {
|
||||
if (!this.access) return request;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('Authorization', `Bearer ${this.access}`);
|
||||
return new Request(request, { headers });
|
||||
}
|
||||
}
|
||||
@@ -1 +1,24 @@
|
||||
export {
|
||||
ApiError,
|
||||
AuthSession,
|
||||
toApiError,
|
||||
unwrap,
|
||||
type AuthSessionOptions,
|
||||
type AuthState,
|
||||
type AuthTokens,
|
||||
type AuthUser,
|
||||
type ClientType,
|
||||
type RefreshStore,
|
||||
} from './auth';
|
||||
export {
|
||||
EventBuffer,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
uuidv7,
|
||||
type EventSink,
|
||||
type PlayContext,
|
||||
type PlayEvent,
|
||||
type RepeatMode,
|
||||
type Track,
|
||||
} from './player';
|
||||
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
|
||||
|
||||
const track = (id: string, duration_ms = 200_000): Track => ({
|
||||
id,
|
||||
title: id,
|
||||
duration_ms,
|
||||
artists: [],
|
||||
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [],
|
||||
disc_no: null,
|
||||
track_no: null,
|
||||
available: true,
|
||||
stream_url: `/s/${id}`,
|
||||
reaction: null,
|
||||
});
|
||||
|
||||
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
|
||||
|
||||
describe('PlayQueue', () => {
|
||||
it('walks forward and back, stops at the end', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b'), track('c')], 1);
|
||||
expect(q.current?.id).toBe('b');
|
||||
expect(q.next()?.id).toBe('c');
|
||||
expect(q.peekNext()).toBeNull();
|
||||
expect(q.next()).toBeNull();
|
||||
expect(q.prev()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('repeats one track only when it ends by itself', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b')]);
|
||||
q.repeat = 'one';
|
||||
expect(q.next(true)?.id).toBe('a');
|
||||
expect(q.next()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps around with repeat all', () => {
|
||||
const q = new PlayQueue();
|
||||
q.set([track('a'), track('b')], 1);
|
||||
q.repeat = 'all';
|
||||
expect(q.peekNext()?.id).toBe('a');
|
||||
expect(q.next()?.id).toBe('a');
|
||||
expect(q.prev()?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('shuffles keeping the current track first and restores order', () => {
|
||||
let seed = 0.3;
|
||||
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
|
||||
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
|
||||
q.setShuffle(true);
|
||||
expect(q.current?.id).toBe('c');
|
||||
expect(ids(q)[0]).toBe('c');
|
||||
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
q.next();
|
||||
const cur = q.current!.id;
|
||||
q.setShuffle(false);
|
||||
expect(q.current?.id).toBe(cur);
|
||||
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlayAttempt', () => {
|
||||
it('counts only time actually played', () => {
|
||||
const a = new PlayAttempt(track('a'));
|
||||
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
|
||||
expect(a.playedMs).toBe(10_000);
|
||||
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
|
||||
a.tick(60_250);
|
||||
expect(a.playedMs).toBe(10_250);
|
||||
a.seek(5000);
|
||||
a.tick(5250);
|
||||
expect(a.playedMs).toBe(10_500);
|
||||
});
|
||||
|
||||
it('treats the last seconds as finished', () => {
|
||||
const a = new PlayAttempt(track('a', 200_000));
|
||||
expect(a.outcome(196_000)).toBe('play_end');
|
||||
expect(a.outcome(120_000)).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventBuffer', () => {
|
||||
it('sends in batches and retries with backoff', async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
|
||||
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
|
||||
buf.push({ type: 'play_start', track_id: 't1' });
|
||||
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
|
||||
|
||||
expect(await buf.flush()).toBe(false);
|
||||
expect(buf.pending).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(buf.pending).toHaveLength(0);
|
||||
const sent = send.mock.calls[1]![0];
|
||||
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(sent[0].id).not.toBe(sent[1].id);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uuidv7', () => {
|
||||
it('encodes time and version', () => {
|
||||
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
|
||||
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
|
||||
export type Track = components['schemas']['Track'];
|
||||
export type PlayEvent = components['schemas']['PlayEvent'];
|
||||
export type RepeatMode = 'off' | 'one' | 'all';
|
||||
export type PlayContext = { type: NonNullable<PlayEvent['context_type']>; id?: string; label?: string };
|
||||
|
||||
/** UUIDv7: время в первых 48 битах — сервер дедуплицирует события по id, сортировка по времени. */
|
||||
export function uuidv7(now = Date.now(), rnd: (n: number) => Uint8Array = randomBytes): string {
|
||||
const b = rnd(16);
|
||||
const ts = BigInt(now);
|
||||
for (let i = 0; i < 6; i++) b[i] = Number((ts >> BigInt(8 * (5 - i))) & 0xffn);
|
||||
b[6] = (b[6]! & 0x0f) | 0x70;
|
||||
b[8] = (b[8]! & 0x3f) | 0x80;
|
||||
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очередь воспроизведения. Перемешивание хранится порядком индексов: текущий трек остаётся текущим,
|
||||
* а выключение возвращает исходный порядок.
|
||||
*/
|
||||
export class PlayQueue {
|
||||
tracks: Track[] = [];
|
||||
/** Позиция в order. */
|
||||
pos = 0;
|
||||
order: number[] = [];
|
||||
shuffle = false;
|
||||
repeat: RepeatMode = 'off';
|
||||
|
||||
constructor(private readonly random: () => number = Math.random) {}
|
||||
|
||||
get current(): Track | undefined {
|
||||
return this.tracks[this.order[this.pos] ?? -1];
|
||||
}
|
||||
|
||||
/** Индекс текущего трека в исходном списке. */
|
||||
get index(): number {
|
||||
return this.order[this.pos] ?? 0;
|
||||
}
|
||||
|
||||
set(tracks: Track[], index = 0) {
|
||||
this.tracks = tracks;
|
||||
this.order = tracks.map((_, i) => i);
|
||||
this.pos = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));
|
||||
if (this.shuffle) this.reshuffle();
|
||||
}
|
||||
|
||||
setShuffle(on: boolean) {
|
||||
const cur = this.index;
|
||||
this.shuffle = on;
|
||||
this.order = this.tracks.map((_, i) => i);
|
||||
this.pos = cur;
|
||||
if (on) this.reshuffle();
|
||||
}
|
||||
|
||||
private reshuffle() {
|
||||
const cur = this.order[this.pos] ?? 0;
|
||||
const rest = this.order.filter((i) => i !== cur);
|
||||
for (let i = rest.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(this.random() * (i + 1));
|
||||
[rest[i], rest[j]] = [rest[j]!, rest[i]!];
|
||||
}
|
||||
this.order = this.tracks.length ? [cur, ...rest] : [];
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
/** Следующий трек. auto — трек доиграл сам (repeat one повторяет его). null — очередь кончилась. */
|
||||
next(auto = false): Track | null {
|
||||
if (!this.tracks.length) return null;
|
||||
if (auto && this.repeat === 'one') return this.current ?? null;
|
||||
if (this.pos + 1 < this.order.length) {
|
||||
this.pos++;
|
||||
return this.current ?? null;
|
||||
}
|
||||
if (this.repeat === 'all') {
|
||||
this.pos = 0;
|
||||
return this.current ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Предыдущий трек (с начала очереди при repeat all — последний). */
|
||||
prev(): Track | null {
|
||||
if (!this.tracks.length) return null;
|
||||
if (this.pos > 0) this.pos--;
|
||||
else if (this.repeat === 'all') this.pos = this.order.length - 1;
|
||||
return this.current ?? null;
|
||||
}
|
||||
|
||||
/** Что сыграет после текущего — для предзагрузки. */
|
||||
peekNext(): Track | null {
|
||||
if (this.repeat === 'one') return this.current ?? null;
|
||||
const i = this.pos + 1 < this.order.length ? this.pos + 1 : this.repeat === 'all' ? 0 : -1;
|
||||
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
|
||||
}
|
||||
|
||||
jump(index: number) {
|
||||
const p = this.order.indexOf(index);
|
||||
if (p >= 0) this.pos = p;
|
||||
}
|
||||
}
|
||||
|
||||
/** Скачок позиции больше этого — перемотка, а не воспроизведение. */
|
||||
const SEEK_GAP_MS = 2000;
|
||||
/** Осталось меньше этого — трек доигран (play_end, а не skip). */
|
||||
const END_SLACK_MS = 5000;
|
||||
|
||||
/**
|
||||
* Учёт попытки прослушивания одного трека: played_ms — реально прослушанное время,
|
||||
* перемотки вперёд не засчитываются.
|
||||
*/
|
||||
export class PlayAttempt {
|
||||
playedMs = 0;
|
||||
private last: number;
|
||||
|
||||
constructor(
|
||||
readonly track: Track,
|
||||
startMs = 0,
|
||||
) {
|
||||
this.last = startMs;
|
||||
}
|
||||
|
||||
/** Новая позиция воспроизведения (timeupdate). Возвращает true, если это была перемотка. */
|
||||
tick(positionMs: number): boolean {
|
||||
const delta = positionMs - this.last;
|
||||
this.last = positionMs;
|
||||
if (delta > 0 && delta <= SEEK_GAP_MS) {
|
||||
this.playedMs += delta;
|
||||
return false;
|
||||
}
|
||||
return delta !== 0;
|
||||
}
|
||||
|
||||
/** Явная перемотка: позиция меняется без засчитывания. */
|
||||
seek(positionMs: number) {
|
||||
this.last = positionMs;
|
||||
}
|
||||
|
||||
/** Тип завершения попытки по позиции. */
|
||||
outcome(positionMs: number): 'play_end' | 'skip' {
|
||||
return this.track.duration_ms - positionMs <= END_SLACK_MS ? 'play_end' : 'skip';
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventSink {
|
||||
send(events: PlayEvent[]): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон),
|
||||
* при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id.
|
||||
*/
|
||||
export class EventBuffer {
|
||||
private queue: PlayEvent[] = [];
|
||||
private sending = false;
|
||||
private retryMs = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly sink: EventSink,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly maxRetryMs = 5 * 60_000,
|
||||
) {}
|
||||
|
||||
get pending(): readonly PlayEvent[] {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
||||
const t = this.now();
|
||||
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
||||
if (this.queue.length >= 500) void this.flush();
|
||||
}
|
||||
|
||||
async flush(): Promise<boolean> {
|
||||
if (this.sending || this.queue.length === 0) return true;
|
||||
this.sending = true;
|
||||
const batch = this.queue.slice(0, 500);
|
||||
try {
|
||||
await this.sink.send(batch);
|
||||
this.queue = this.queue.slice(batch.length);
|
||||
this.retryMs = 0;
|
||||
return true;
|
||||
} catch {
|
||||
this.retryMs = Math.min(this.retryMs ? this.retryMs * 2 : 2000, this.maxRetryMs);
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.flush(), this.retryMs);
|
||||
return false;
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"subtitle": "Create users and moderators, manage roles, reset passwords, block accounts",
|
||||
"create": "Create user",
|
||||
"search": "Name or username",
|
||||
"all": "All",
|
||||
"col_user": "User",
|
||||
"col_role": "Role",
|
||||
"col_status": "Status",
|
||||
"col_last_login": "Last seen",
|
||||
"status_active": "Active",
|
||||
"status_blocked": "Blocked",
|
||||
"status_must_change": "Must change password",
|
||||
"never": "Never signed in",
|
||||
"empty": "Nobody found",
|
||||
"more": "Show more",
|
||||
"actions": "Actions for {{username}}",
|
||||
"reset_password": "Reset password",
|
||||
"block": "Block",
|
||||
"unblock": "Unblock",
|
||||
"make_role": "Make {{role}}",
|
||||
"delete": "Delete",
|
||||
"delete_confirm": "Delete {{username}}? Their history and likes will be lost.",
|
||||
"block_confirm": "Block {{username}}? All their sessions will end.",
|
||||
"reset_confirm": "Reset {{username}}'s password? All their sessions will end.",
|
||||
"confirm": "Confirm",
|
||||
"you": "you"
|
||||
},
|
||||
"create": {
|
||||
"title": "New user",
|
||||
"username": "Username",
|
||||
"username_hint": "Latin letters, digits, \".\", \"_\", \"-\"",
|
||||
"display_name": "Name",
|
||||
"role": "Role",
|
||||
"password_note": "The server will generate a password and show it once.",
|
||||
"submit": "Create"
|
||||
},
|
||||
"temp_password": {
|
||||
"title": "Temporary password",
|
||||
"text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.",
|
||||
"done": "Done"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
"subtitle": "Actions of administrators and moderators",
|
||||
"col_when": "When",
|
||||
"col_who": "Who",
|
||||
"col_action": "Action",
|
||||
"col_details": "Details",
|
||||
"system": "System",
|
||||
"empty": "No entries yet",
|
||||
"more": "Show more",
|
||||
"actions": {
|
||||
"user.bootstrap_create": "Initial administrator created",
|
||||
"user.bootstrap_credentials": "Administrator credentials set",
|
||||
"user.create": "User created",
|
||||
"user.rename": "Name changed",
|
||||
"user.role_change": "Role changed",
|
||||
"user.block": "User blocked",
|
||||
"user.unblock": "User unblocked",
|
||||
"user.password_reset": "Password reset",
|
||||
"user.delete": "User deleted"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"title": "Storage",
|
||||
"subtitle": "Where the server takes music from: a folder on the server or NFS",
|
||||
"add": "Add storage",
|
||||
"empty": "No storage yet. Connect a music folder to get started.",
|
||||
"type_local": "Server folder",
|
||||
"type_nfs": "NFS",
|
||||
"status_pending": "Connecting",
|
||||
"status_mounted": "Connected",
|
||||
"status_error": "Error",
|
||||
"status_disabled": "Disabled",
|
||||
"last_scan": "Last scan: {{date}}",
|
||||
"never_scanned": "Not scanned yet",
|
||||
"scan": "Scan",
|
||||
"full_scan": "Full scan",
|
||||
"test": "Test",
|
||||
"patterns": "Path patterns",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
|
||||
"scanning": "Scanning: {{processed}} of {{total}}",
|
||||
"scan_walking": "Walking folders…",
|
||||
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
|
||||
"scan_failed": "Scan stopped: {{error}}",
|
||||
"test_ok": "Reachable, audio files found: {{count}}",
|
||||
"test_fail": "Not reachable: {{message}}",
|
||||
"form_title_new": "New storage",
|
||||
"form_title_edit": "Storage",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"folder": "Folder",
|
||||
"folder_root": "Whole folder",
|
||||
"folder_hint": "A subfolder of LIBRARY_HOST on the server",
|
||||
"up": "Up",
|
||||
"nfs_server": "Server",
|
||||
"nfs_server_hint": "Host name or IP, e.g. nas.lan",
|
||||
"nfs_export": "Export",
|
||||
"nfs_export_hint": "Path on the server, e.g. /export/music",
|
||||
"nfs_options": "Mount options",
|
||||
"nfs_options_hint": "Read-only is added automatically",
|
||||
"tag_priority": "Prefer",
|
||||
"tags_first": "File tags",
|
||||
"path_first": "Path patterns",
|
||||
"interval": "Auto scan",
|
||||
"interval_off": "Manual",
|
||||
"interval_hours": "Every {{count}} h",
|
||||
"save": "Save"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Path patterns: {{name}}",
|
||||
"intro": "RE2 regular expressions with named groups are matched against the file path relative to the storage root. The first match wins.",
|
||||
"groups": "Groups: artist, albumartist, album, year, disc, track, title, genre; skip ignores the file.",
|
||||
"add": "Add pattern",
|
||||
"regex": "Pattern {{n}}",
|
||||
"enabled": "Enabled",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"remove": "Remove",
|
||||
"preview": "Preview",
|
||||
"save": "Save",
|
||||
"saved": "Saved. Run a full scan to apply it to files already found.",
|
||||
"col_path": "Path",
|
||||
"col_pattern": "Pattern",
|
||||
"col_result": "Result",
|
||||
"no_match": "no match",
|
||||
"skipped": "skipped",
|
||||
"unmatched": "No pattern matched: {{count}} of {{total}}",
|
||||
"from_tags": "from tags",
|
||||
"error_at": "Error in pattern {{n}}: {{reason}}",
|
||||
"examples": "Examples"
|
||||
},
|
||||
"library": {
|
||||
"title": "Library",
|
||||
"subtitle": "Catalog and scan status",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"tracks": "Tracks",
|
||||
"files": "Files",
|
||||
"missing": "Missing files",
|
||||
"errors": "Unreadable",
|
||||
"sources": "Storage",
|
||||
"error_files": "Files with errors",
|
||||
"reindex": "Rebuild search index",
|
||||
"reindex_started": "Rebuild started"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Your music from your own server",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log."
|
||||
},
|
||||
"setup": {
|
||||
"admin_badge": "First administrator sign-in",
|
||||
"admin_title": "Change the username and password",
|
||||
"admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.",
|
||||
"password_badge": "Password reset",
|
||||
"password_title": "Set a new password",
|
||||
"password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.",
|
||||
"new_username": "New username",
|
||||
"new_password": "New password",
|
||||
"repeat_password": "Repeat the password",
|
||||
"min_length": "At least {{min}} characters",
|
||||
"submit": "Save and continue",
|
||||
"logout": "Sign out"
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,143 @@
|
||||
"label": "Language",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"main": "Navigation",
|
||||
"home": "Home",
|
||||
"admin": "Admin",
|
||||
"users": "Users",
|
||||
"audit": "Audit log",
|
||||
"library": "Library",
|
||||
"sources": "Storage",
|
||||
"collection": "Collection",
|
||||
"history": "History",
|
||||
"likes": "Likes",
|
||||
"dislikes": "Dislikes"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Profile menu",
|
||||
"settings": "Settings",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
"moderator": "Moderator",
|
||||
"user": "User"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Hi, {{name}}!",
|
||||
"wave": "My Wave",
|
||||
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||
},
|
||||
"player": {
|
||||
"empty": "Nothing is playing",
|
||||
"label": "Player",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"next": "Next",
|
||||
"prev": "Previous",
|
||||
"like": "Like",
|
||||
"unlike": "Remove from likes",
|
||||
"dislike": "Dislike",
|
||||
"undislike": "Remove dislike",
|
||||
"volume": "Volume",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"shuffle": "Shuffle",
|
||||
"repeat_off": "Repeat off",
|
||||
"repeat_all": "Repeat queue",
|
||||
"repeat_one": "Repeat track",
|
||||
"progress": "Position",
|
||||
"unavailable": "Track is unavailable — skipping",
|
||||
"error": "Could not play the track"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"account": "Account",
|
||||
"display_name": "Display name",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"appearance": "Appearance",
|
||||
"theme": "Theme",
|
||||
"theme_dark": "Dark",
|
||||
"theme_light": "Light",
|
||||
"theme_system": "System",
|
||||
"password": "Password",
|
||||
"current_password": "Current password",
|
||||
"new_password": "New password",
|
||||
"repeat_password": "Repeat the password",
|
||||
"change_password": "Change password",
|
||||
"password_changed": "Password changed. Other devices have been signed out.",
|
||||
"sessions": "Devices",
|
||||
"session_current": "This device",
|
||||
"session_last_used": "Active: {{date}}",
|
||||
"session_end": "Sign out",
|
||||
"logout_all": "Sign out everywhere"
|
||||
},
|
||||
"form": {
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password",
|
||||
"passwords_mismatch": "Passwords don't match",
|
||||
"required": "Fill in this field",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"route_error": {
|
||||
"reload": "Reload the page"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Artists, albums, tracks",
|
||||
"label": "Search",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"tracks": "Tracks",
|
||||
"all": "All",
|
||||
"nothing": "Nothing found",
|
||||
"corrected": "Showing results for “{{query}}”",
|
||||
"show_all": "All results",
|
||||
"title": "Search: {{query}}",
|
||||
"more": "Show more",
|
||||
"artist_kind": "Artist",
|
||||
"hint": "Press / to search"
|
||||
},
|
||||
"catalog": {
|
||||
"albums": "Albums",
|
||||
"appears_on": "Appears on",
|
||||
"album_count_one": "{{count}} album",
|
||||
"album_count_other": "{{count}} albums",
|
||||
"album_count_few": "{{count}} albums",
|
||||
"album_count_many": "{{count}} albums",
|
||||
"track_count_one": "{{count}} track",
|
||||
"track_count_other": "{{count}} tracks",
|
||||
"track_count_few": "{{count}} tracks",
|
||||
"track_count_many": "{{count}} tracks",
|
||||
"unavailable": "Unavailable: storage is offline or the file is gone",
|
||||
"not_found": "Nothing here — it may have been removed by the last scan",
|
||||
"disc": "Disc {{n}}",
|
||||
"col_title": "Title",
|
||||
"col_album": "Album",
|
||||
"col_duration": "Time",
|
||||
"back_home": "Back home",
|
||||
"artist": "Artist",
|
||||
"album": "Album"
|
||||
},
|
||||
"collection": {
|
||||
"title": "Collection",
|
||||
"history": "History",
|
||||
"likes": "Likes",
|
||||
"dislikes": "Dislikes",
|
||||
"clear_history": "Clear history",
|
||||
"clear_confirm": "Clear your listening history? Recommendations will still take it into account.",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"empty_history": "What you listen to will show up here",
|
||||
"empty_likes": "Tap the heart on tracks — they will gather here",
|
||||
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
|
||||
"play_all": "Play all",
|
||||
"more": "Show more"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"common": {
|
||||
"internal": "Something went wrong on the server. Please try again",
|
||||
"bad_request": "Invalid request",
|
||||
"not_found": "Not found",
|
||||
"method_not_allowed": "This action is not supported",
|
||||
"network": "Cannot reach the server. Check your network"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Wrong username or password",
|
||||
"too_many_attempts": "Too many sign-in attempts. Please wait a bit",
|
||||
"user_blocked": "This account is blocked. Contact your administrator",
|
||||
"unauthorized": "Sign in to continue",
|
||||
"token_expired": "Your session has expired, please sign in again",
|
||||
"token_invalid": "Your session is invalid, please sign in again",
|
||||
"token_revoked": "Your session has ended, please sign in again",
|
||||
"refresh_invalid": "Your session has ended, please sign in again",
|
||||
"refresh_reused": "Your session was ended for security reasons, please sign in again",
|
||||
"refresh_race": "Your session is being refreshed, please retry",
|
||||
"credentials_change_required": "Change the administrator username and password first",
|
||||
"password_change_required": "Change your temporary password first",
|
||||
"current_password_required": "Enter your current password",
|
||||
"current_password_invalid": "The current password is wrong",
|
||||
"bootstrap_not_required": "The administrator credentials have already been changed",
|
||||
"session_not_found": "Session not found",
|
||||
"forbidden": "You don't have permission to do this"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "The password must be at least {{min}} characters",
|
||||
"password_too_long": "The password must be at most {{max}} characters",
|
||||
"password_same_as_username": "The password must not match the username",
|
||||
"password_too_common": "This password is too common — choose another one",
|
||||
"password_unchanged": "The new password must differ from the current one",
|
||||
"username_invalid": "Username: {{min}} to {{max}} characters — Latin letters, digits, \".\", \"_\", \"-\"",
|
||||
"username_taken": "This username is already taken",
|
||||
"locale_unsupported": "This language is not supported",
|
||||
"display_name_too_long": "The name must be at most {{max}} characters",
|
||||
"ui_prefs_invalid": "Invalid setting value",
|
||||
"client_type_invalid": "Unknown app type",
|
||||
"role_invalid": "Unknown role",
|
||||
"cursor_invalid": "The list is outdated, please reload",
|
||||
"pattern_invalid": "Pattern error: {{reason}}",
|
||||
"nfs_invalid": "Invalid value",
|
||||
"local_path_invalid": "Folder not found",
|
||||
"name_invalid": "The name must be 1 to {{max}} characters",
|
||||
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
|
||||
"source_type_invalid": "Unknown storage type",
|
||||
"query_empty": "Enter a query"
|
||||
},
|
||||
"users": {
|
||||
"not_found": "User not found",
|
||||
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
|
||||
},
|
||||
"library": {
|
||||
"source_not_found": "Storage not found",
|
||||
"scan_running": "A scan is already running",
|
||||
"source_offline": "The storage is not connected"
|
||||
},
|
||||
"catalog": {
|
||||
"not_found": "Not found"
|
||||
},
|
||||
"search": {
|
||||
"unavailable": "Search is temporarily unavailable"
|
||||
},
|
||||
"playback": {
|
||||
"link_invalid": "The link has expired — reload the page",
|
||||
"track_unavailable": "Track is unavailable",
|
||||
"transcode_failed": "Could not prepare the track"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
"subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка",
|
||||
"create": "Создать пользователя",
|
||||
"search": "Имя или логин",
|
||||
"all": "Все",
|
||||
"col_user": "Пользователь",
|
||||
"col_role": "Роль",
|
||||
"col_status": "Статус",
|
||||
"col_last_login": "Был в сети",
|
||||
"status_active": "Активен",
|
||||
"status_blocked": "Заблокирован",
|
||||
"status_must_change": "Ждёт смены пароля",
|
||||
"never": "Ещё не входил",
|
||||
"empty": "Никого не нашли",
|
||||
"more": "Показать ещё",
|
||||
"actions": "Действия с {{username}}",
|
||||
"reset_password": "Сбросить пароль",
|
||||
"block": "Заблокировать",
|
||||
"unblock": "Разблокировать",
|
||||
"make_role": "Сделать: {{role}}",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.",
|
||||
"block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.",
|
||||
"reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.",
|
||||
"confirm": "Подтвердить",
|
||||
"you": "вы"
|
||||
},
|
||||
"create": {
|
||||
"title": "Новый пользователь",
|
||||
"username": "Логин",
|
||||
"username_hint": "Латинские буквы, цифры, «.», «_», «-»",
|
||||
"display_name": "Имя",
|
||||
"role": "Роль",
|
||||
"password_note": "Пароль сгенерирует сервер и покажет один раз.",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"temp_password": {
|
||||
"title": "Временный пароль",
|
||||
"text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.",
|
||||
"done": "Готово"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Журнал",
|
||||
"subtitle": "Действия администраторов и модераторов",
|
||||
"col_when": "Когда",
|
||||
"col_who": "Кто",
|
||||
"col_action": "Действие",
|
||||
"col_details": "Подробности",
|
||||
"system": "Система",
|
||||
"empty": "Записей пока нет",
|
||||
"more": "Показать ещё",
|
||||
"actions": {
|
||||
"user.bootstrap_create": "Создан стартовый администратор",
|
||||
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
||||
"user.create": "Создан пользователь",
|
||||
"user.rename": "Изменено имя",
|
||||
"user.role_change": "Изменена роль",
|
||||
"user.block": "Пользователь заблокирован",
|
||||
"user.unblock": "Пользователь разблокирован",
|
||||
"user.password_reset": "Сброшен пароль",
|
||||
"user.delete": "Пользователь удалён"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"title": "Хранилища",
|
||||
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
|
||||
"add": "Подключить хранилище",
|
||||
"empty": "Хранилищ пока нет. Подключите папку с музыкой, чтобы начать.",
|
||||
"type_local": "Папка на сервере",
|
||||
"type_nfs": "NFS",
|
||||
"status_pending": "Подключается",
|
||||
"status_mounted": "Подключено",
|
||||
"status_error": "Ошибка",
|
||||
"status_disabled": "Отключено",
|
||||
"last_scan": "Последний скан: {{date}}",
|
||||
"never_scanned": "Ещё не сканировалось",
|
||||
"scan": "Сканировать",
|
||||
"full_scan": "Полный скан",
|
||||
"test": "Проверить",
|
||||
"patterns": "Шаблоны путей",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"disable": "Отключить",
|
||||
"enable": "Подключить",
|
||||
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
||||
"scanning": "Сканирование: {{processed}} из {{total}}",
|
||||
"scan_walking": "Обход папок…",
|
||||
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
|
||||
"scan_failed": "Скан прервался: {{error}}",
|
||||
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
|
||||
"test_fail": "Нет доступа: {{message}}",
|
||||
"form_title_new": "Новое хранилище",
|
||||
"form_title_edit": "Хранилище",
|
||||
"name": "Название",
|
||||
"type": "Тип",
|
||||
"folder": "Папка",
|
||||
"folder_root": "Весь каталог",
|
||||
"folder_hint": "Подпапка каталога LIBRARY_HOST на сервере",
|
||||
"up": "Вверх",
|
||||
"nfs_server": "Сервер",
|
||||
"nfs_server_hint": "Имя или IP, например nas.lan",
|
||||
"nfs_export": "Экспорт",
|
||||
"nfs_export_hint": "Путь на сервере, например /export/music",
|
||||
"nfs_options": "Опции монтирования",
|
||||
"nfs_options_hint": "Только чтение добавляется автоматически",
|
||||
"tag_priority": "Что важнее",
|
||||
"tags_first": "Теги файлов",
|
||||
"path_first": "Шаблоны путей",
|
||||
"interval": "Автоскан",
|
||||
"interval_off": "Вручную",
|
||||
"interval_hours": "Каждые {{count}} ч",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Шаблоны путей: {{name}}",
|
||||
"intro": "Регулярные выражения RE2 с именованными группами применяются к пути файла относительно корня хранилища. Побеждает первое совпавшее.",
|
||||
"groups": "Группы: artist, albumartist, album, year, disc, track, title, genre; skip — игнорировать файл.",
|
||||
"add": "Добавить шаблон",
|
||||
"regex": "Шаблон {{n}}",
|
||||
"enabled": "Включён",
|
||||
"up": "Выше",
|
||||
"down": "Ниже",
|
||||
"remove": "Удалить",
|
||||
"preview": "Предпросмотр",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено. Запустите полный скан, чтобы применить к уже найденным файлам.",
|
||||
"col_path": "Путь",
|
||||
"col_pattern": "Шаблон",
|
||||
"col_result": "Результат",
|
||||
"no_match": "не совпал",
|
||||
"skipped": "пропускается",
|
||||
"unmatched": "Не совпал ни один шаблон: {{count}} из {{total}}",
|
||||
"from_tags": "из тегов",
|
||||
"error_at": "Ошибка в шаблоне {{n}}: {{reason}}",
|
||||
"examples": "Примеры"
|
||||
},
|
||||
"library": {
|
||||
"title": "Библиотека",
|
||||
"subtitle": "Состояние каталога и сканирования",
|
||||
"artists": "Исполнителей",
|
||||
"albums": "Альбомов",
|
||||
"tracks": "Треков",
|
||||
"files": "Файлов",
|
||||
"missing": "Пропавших файлов",
|
||||
"errors": "Не прочитано",
|
||||
"sources": "Хранилища",
|
||||
"error_files": "Файлы с ошибками",
|
||||
"reindex": "Переиндексировать поиск",
|
||||
"reindex_started": "Переиндексация запущена"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Вход",
|
||||
"subtitle": "Ваша музыка с вашего сервера",
|
||||
"username": "Логин",
|
||||
"password": "Пароль",
|
||||
"submit": "Войти",
|
||||
"bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api."
|
||||
},
|
||||
"setup": {
|
||||
"admin_badge": "Первый вход администратора",
|
||||
"admin_title": "Смените логин и пароль",
|
||||
"admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.",
|
||||
"password_badge": "Пароль сброшен",
|
||||
"password_title": "Задайте новый пароль",
|
||||
"password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.",
|
||||
"new_username": "Новый логин",
|
||||
"new_password": "Новый пароль",
|
||||
"repeat_password": "Повторите пароль",
|
||||
"min_length": "Не короче {{min}} символов",
|
||||
"submit": "Сохранить и продолжить",
|
||||
"logout": "Выйти"
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,143 @@
|
||||
"label": "Язык",
|
||||
"ru": "Русский",
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"main": "Навигация",
|
||||
"home": "Главная",
|
||||
"admin": "Админка",
|
||||
"users": "Пользователи",
|
||||
"audit": "Журнал",
|
||||
"library": "Библиотека",
|
||||
"sources": "Хранилища",
|
||||
"collection": "Коллекция",
|
||||
"history": "История",
|
||||
"likes": "Лайки",
|
||||
"dislikes": "Дизлайки"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Меню профиля",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Администратор",
|
||||
"moderator": "Модератор",
|
||||
"user": "Пользователь"
|
||||
},
|
||||
"home": {
|
||||
"greeting": "Привет, {{name}}!",
|
||||
"wave": "Моя волна",
|
||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||
},
|
||||
"player": {
|
||||
"empty": "Ничего не играет",
|
||||
"label": "Плеер",
|
||||
"play": "Слушать",
|
||||
"pause": "Пауза",
|
||||
"next": "Следующий",
|
||||
"prev": "Предыдущий",
|
||||
"like": "Нравится",
|
||||
"unlike": "Убрать из лайков",
|
||||
"dislike": "Не нравится",
|
||||
"undislike": "Убрать дизлайк",
|
||||
"volume": "Громкость",
|
||||
"mute": "Выключить звук",
|
||||
"unmute": "Включить звук",
|
||||
"shuffle": "Перемешать",
|
||||
"repeat_off": "Повтор выключен",
|
||||
"repeat_all": "Повторять очередь",
|
||||
"repeat_one": "Повторять трек",
|
||||
"progress": "Позиция",
|
||||
"unavailable": "Трек недоступен — пропускаем",
|
||||
"error": "Не удалось воспроизвести трек"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"account": "Аккаунт",
|
||||
"display_name": "Отображаемое имя",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"appearance": "Внешний вид",
|
||||
"theme": "Тема",
|
||||
"theme_dark": "Тёмная",
|
||||
"theme_light": "Светлая",
|
||||
"theme_system": "Как в системе",
|
||||
"password": "Пароль",
|
||||
"current_password": "Текущий пароль",
|
||||
"new_password": "Новый пароль",
|
||||
"repeat_password": "Повторите пароль",
|
||||
"change_password": "Сменить пароль",
|
||||
"password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.",
|
||||
"sessions": "Устройства",
|
||||
"session_current": "Это устройство",
|
||||
"session_last_used": "Активность: {{date}}",
|
||||
"session_end": "Завершить",
|
||||
"logout_all": "Выйти на всех устройствах"
|
||||
},
|
||||
"form": {
|
||||
"show_password": "Показать пароль",
|
||||
"hide_password": "Скрыть пароль",
|
||||
"passwords_mismatch": "Пароли не совпадают",
|
||||
"required": "Заполните поле",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано"
|
||||
},
|
||||
"loading": "Загрузка…",
|
||||
"route_error": {
|
||||
"reload": "Обновить страницу"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Исполнители, альбомы, треки",
|
||||
"label": "Поиск",
|
||||
"artists": "Исполнители",
|
||||
"albums": "Альбомы",
|
||||
"tracks": "Треки",
|
||||
"all": "Всё",
|
||||
"nothing": "Ничего не нашли",
|
||||
"corrected": "Показаны результаты для «{{query}}»",
|
||||
"show_all": "Все результаты",
|
||||
"title": "Поиск: {{query}}",
|
||||
"more": "Показать ещё",
|
||||
"artist_kind": "Исполнитель",
|
||||
"hint": "Нажмите / для поиска"
|
||||
},
|
||||
"catalog": {
|
||||
"albums": "Альбомы",
|
||||
"appears_on": "Встречается на",
|
||||
"album_count_one": "{{count}} альбом",
|
||||
"album_count_few": "{{count}} альбома",
|
||||
"album_count_many": "{{count}} альбомов",
|
||||
"album_count_other": "{{count}} альбома",
|
||||
"track_count_one": "{{count}} трек",
|
||||
"track_count_few": "{{count}} трека",
|
||||
"track_count_many": "{{count}} треков",
|
||||
"track_count_other": "{{count}} трека",
|
||||
"unavailable": "Недоступен: хранилище отключено или файл пропал",
|
||||
"not_found": "Ничего не найдено — возможно, удалено при последнем скане",
|
||||
"disc": "Диск {{n}}",
|
||||
"col_title": "Название",
|
||||
"col_album": "Альбом",
|
||||
"col_duration": "Время",
|
||||
"back_home": "На главную",
|
||||
"artist": "Исполнитель",
|
||||
"album": "Альбом"
|
||||
},
|
||||
"collection": {
|
||||
"title": "Коллекция",
|
||||
"history": "История",
|
||||
"likes": "Лайки",
|
||||
"dislikes": "Дизлайки",
|
||||
"clear_history": "Очистить историю",
|
||||
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера",
|
||||
"empty_history": "Здесь появится то, что вы слушали",
|
||||
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
|
||||
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
|
||||
"play_all": "Слушать всё",
|
||||
"more": "Показать ещё"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"common": {
|
||||
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
|
||||
"bad_request": "Некорректный запрос",
|
||||
"not_found": "Не найдено",
|
||||
"method_not_allowed": "Действие не поддерживается",
|
||||
"network": "Нет связи с сервером. Проверьте сеть"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Неверный логин или пароль",
|
||||
"too_many_attempts": "Слишком много попыток входа. Подождите немного",
|
||||
"user_blocked": "Учётная запись заблокирована. Обратитесь к администратору",
|
||||
"unauthorized": "Войдите, чтобы продолжить",
|
||||
"token_expired": "Сессия истекла, войдите снова",
|
||||
"token_invalid": "Сессия недействительна, войдите снова",
|
||||
"token_revoked": "Сессия завершена, войдите снова",
|
||||
"refresh_invalid": "Сессия завершена, войдите снова",
|
||||
"refresh_reused": "Сессия завершена из соображений безопасности, войдите снова",
|
||||
"refresh_race": "Сессия обновляется, повторите действие",
|
||||
"credentials_change_required": "Сначала смените логин и пароль администратора",
|
||||
"password_change_required": "Сначала смените временный пароль",
|
||||
"current_password_required": "Введите текущий пароль",
|
||||
"current_password_invalid": "Текущий пароль указан неверно",
|
||||
"bootstrap_not_required": "Учётные данные администратора уже изменены",
|
||||
"session_not_found": "Сессия не найдена",
|
||||
"forbidden": "Недостаточно прав для этого действия"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
||||
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
|
||||
"password_same_as_username": "Пароль не должен совпадать с логином",
|
||||
"password_too_common": "Этот пароль слишком распространён — придумайте другой",
|
||||
"password_unchanged": "Новый пароль должен отличаться от текущего",
|
||||
"username_invalid": "Логин: от {{min}} до {{max}} символов — латинские буквы, цифры, «.», «_», «-»",
|
||||
"username_taken": "Этот логин уже занят",
|
||||
"locale_unsupported": "Этот язык не поддерживается",
|
||||
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
|
||||
"ui_prefs_invalid": "Недопустимое значение настройки",
|
||||
"client_type_invalid": "Неизвестный тип приложения",
|
||||
"role_invalid": "Неизвестная роль",
|
||||
"cursor_invalid": "Список устарел, обновите страницу",
|
||||
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
|
||||
"nfs_invalid": "Недопустимое значение",
|
||||
"local_path_invalid": "Папка не найдена",
|
||||
"name_invalid": "Название — от 1 до {{max}} символов",
|
||||
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
|
||||
"source_type_invalid": "Неизвестный тип хранилища",
|
||||
"query_empty": "Введите запрос"
|
||||
},
|
||||
"users": {
|
||||
"not_found": "Пользователь не найден",
|
||||
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
|
||||
},
|
||||
"library": {
|
||||
"source_not_found": "Хранилище не найдено",
|
||||
"scan_running": "Скан уже идёт",
|
||||
"source_offline": "Хранилище не подключено"
|
||||
},
|
||||
"catalog": {
|
||||
"not_found": "Не найдено"
|
||||
},
|
||||
"search": {
|
||||
"unavailable": "Поиск временно недоступен"
|
||||
},
|
||||
"playback": {
|
||||
"link_invalid": "Ссылка устарела — обновите страницу",
|
||||
"track_unavailable": "Трек недоступен",
|
||||
"transcode_failed": "Не удалось подготовить трек"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import i18next, { type i18n as I18n } from 'i18next';
|
||||
|
||||
import enAdmin from '../locales/en/admin.json';
|
||||
import enAuth from '../locales/en/auth.json';
|
||||
import enCommon from '../locales/en/common.json';
|
||||
import enErrors from '../locales/en/errors.json';
|
||||
import ruAdmin from '../locales/ru/admin.json';
|
||||
import ruAuth from '../locales/ru/auth.json';
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruErrors from '../locales/ru/errors.json';
|
||||
|
||||
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
|
||||
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
|
||||
@@ -9,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const resources = {
|
||||
ru: { common: ruCommon },
|
||||
en: { common: enCommon },
|
||||
ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
|
||||
en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
|
||||
} as const;
|
||||
|
||||
declare module 'i18next' {
|
||||
@@ -38,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
|
||||
fallbackLng: DEFAULT_LOCALE,
|
||||
supportedLngs: [...SUPPORTED_LOCALES],
|
||||
defaultNS: 'common',
|
||||
ns: ['common'],
|
||||
ns: ['common', 'auth', 'admin', 'errors'],
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
|
||||
Generated
+163
-8
@@ -69,6 +69,9 @@ importers:
|
||||
'@fontsource/unbounded':
|
||||
specifier: ^5.2.6
|
||||
version: 5.3.0
|
||||
'@material-symbols/svg-400':
|
||||
specifier: ^0.31.9
|
||||
version: 0.31.9
|
||||
'@pipu/api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-client
|
||||
@@ -84,6 +87,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.80.7
|
||||
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.120.20
|
||||
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.3.1
|
||||
@@ -97,9 +103,15 @@ importers:
|
||||
'@testing-library/dom':
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.2
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.2)
|
||||
'@types/react':
|
||||
specifier: ^19.1.8
|
||||
version: 19.3.0
|
||||
@@ -111,13 +123,13 @@ importers:
|
||||
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
version: 26.1.0(supports-color@10.2.2)
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
|
||||
packages/api-client:
|
||||
dependencies:
|
||||
@@ -137,7 +149,7 @@ importers:
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
|
||||
packages/design-tokens: {}
|
||||
|
||||
@@ -157,6 +169,9 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@adobe/css-tools@4.5.0':
|
||||
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
@@ -985,6 +1000,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@material-symbols/svg-400@0.31.9':
|
||||
resolution: {integrity: sha512-LeQrY/qYdRdIlV6ZR+hUQDXjtkDYgP1LGnMQh/Grm6FsXz+6GR2Q+DrotEJjzDnmao/oHygHFGLnkKAR/PM1hQ==}
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
@@ -1215,6 +1233,10 @@ packages:
|
||||
'@sinonjs/fake-timers@10.3.0':
|
||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||
|
||||
'@tanstack/history@1.120.17':
|
||||
resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.104.4':
|
||||
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
||||
|
||||
@@ -1227,10 +1249,34 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-router@1.120.20':
|
||||
resolution: {integrity: sha512-+zNruUE9NsfGm9cHd22Xs7FRtBrBhDZe94pB69BEIjqjrEPZct6f5VhTV9WQ+bDZ6fRz8tUuxNFAgm/3Lm4AIg==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=18.0.0 || >=19.0.0'
|
||||
react-dom: '>=18.0.0 || >=19.0.0'
|
||||
|
||||
'@tanstack/react-store@0.7.7':
|
||||
resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-core@1.120.19':
|
||||
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/store@0.7.7':
|
||||
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
|
||||
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
|
||||
|
||||
'@testing-library/react@16.3.3':
|
||||
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1246,6 +1292,12 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@testing-library/user-event@14.6.1':
|
||||
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
@@ -1705,6 +1757,9 @@ packages:
|
||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
css.escape@1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1783,6 +1838,9 @@ packages:
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2121,6 +2179,10 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
index-to-position@1.2.0:
|
||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2487,6 +2549,10 @@ packages:
|
||||
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -2838,6 +2904,10 @@ packages:
|
||||
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
regenerate-unicode-properties@10.3.0:
|
||||
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3058,6 +3128,10 @@ packages:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3131,6 +3205,12 @@ packages:
|
||||
throat@5.0.0:
|
||||
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tiny-warning@1.0.3:
|
||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -3277,6 +3357,11 @@ packages:
|
||||
uri-js-replace@1.0.1:
|
||||
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
|
||||
|
||||
use-sync-external-store@1.7.0:
|
||||
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -3528,6 +3613,8 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.3.4': {}
|
||||
|
||||
'@adobe/css-tools@4.5.0': {}
|
||||
|
||||
'@asamuzakjp/css-color@3.2.0':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||
@@ -4590,6 +4677,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@material-symbols/svg-400@0.31.9': {}
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -4826,6 +4915,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@tanstack/history@1.120.17': {}
|
||||
|
||||
'@tanstack/query-core@5.104.4': {}
|
||||
|
||||
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
|
||||
@@ -4835,6 +4926,32 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
'@tanstack/react-router@1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.120.17
|
||||
'@tanstack/react-store': 0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
'@tanstack/router-core': 1.120.19
|
||||
jsesc: 3.1.0
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
'@tanstack/react-store@0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.7.7
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
use-sync-external-store: 1.7.0(react@19.3.1)
|
||||
|
||||
'@tanstack/router-core@1.120.19':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.120.17
|
||||
'@tanstack/store': 0.7.7
|
||||
tiny-invariant: 1.3.3
|
||||
|
||||
'@tanstack/store@0.7.7': {}
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -4846,6 +4963,15 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/jest-dom@6.9.1':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
aria-query: 5.3.0
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
|
||||
'@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.10
|
||||
@@ -4856,6 +4982,10 @@ snapshots:
|
||||
'@types/react': 19.3.0
|
||||
'@types/react-dom': 19.3.0(@types/react@19.3.0)
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.2)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.2
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
@@ -5395,6 +5525,8 @@ snapshots:
|
||||
|
||||
crypto-random-string@2.0.0: {}
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -5445,6 +5577,8 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
dependencies:
|
||||
dotenv: 16.4.7
|
||||
@@ -5753,7 +5887,7 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
http-proxy-agent@7.0.2(supports-color@10.2.2):
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -5792,6 +5926,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
index-to-position@1.2.0: {}
|
||||
|
||||
inflight@1.0.6:
|
||||
@@ -5938,13 +6074,13 @@ snapshots:
|
||||
|
||||
jsc-safe-url@0.2.4: {}
|
||||
|
||||
jsdom@26.1.0:
|
||||
jsdom@26.1.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2
|
||||
http-proxy-agent: 7.0.2(supports-color@10.2.2)
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.28
|
||||
@@ -6269,6 +6405,8 @@ snapshots:
|
||||
|
||||
mimic-fn@1.2.0: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.21
|
||||
@@ -6625,6 +6763,11 @@ snapshots:
|
||||
|
||||
react@19.3.1: {}
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
regenerate-unicode-properties@10.3.0:
|
||||
dependencies:
|
||||
regenerate: 1.5.0
|
||||
@@ -6852,6 +6995,10 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.4.0
|
||||
|
||||
strip-indent@3.0.0:
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
@@ -6931,6 +7078,10 @@ snapshots:
|
||||
|
||||
throat@5.0.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tiny-warning@1.0.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -7036,6 +7187,10 @@ snapshots:
|
||||
|
||||
uri-js-replace@1.0.1: {}
|
||||
|
||||
use-sync-external-store@1.7.0(react@19.3.1):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@7.0.3: {}
|
||||
@@ -7079,7 +7234,7 @@ snapshots:
|
||||
lightningcss: 1.27.0
|
||||
terser: 5.51.2
|
||||
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
@@ -7106,7 +7261,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
jsdom: 26.1.0
|
||||
jsdom: 26.1.0(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -5,3 +5,10 @@ packages:
|
||||
# Пакеты, которым разрешено выполнять install-скрипты
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
|
||||
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
|
||||
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
|
||||
# expo.core.ExpoModulesPackage и release-сборка APK падает. Прямая зависимость не подходит: её запрещает expo-doctor.
|
||||
publicHoistPattern:
|
||||
- expo-modules-autolinking
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
|
||||
// докладывает в него APK и переписывает блок статуса в тексте.
|
||||
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
|
||||
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
|
||||
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
|
||||
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
|
||||
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
|
||||
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
|
||||
// из ответа указывает на него же).
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SECTIONS = [
|
||||
['feat', 'Новое'],
|
||||
['fix', 'Исправления'],
|
||||
['perf', 'Производительность'],
|
||||
['refactor', 'Рефакторинг'],
|
||||
['docs', 'Документация'],
|
||||
['build', 'Сборка и CI'],
|
||||
['ci', 'Сборка и CI'],
|
||||
];
|
||||
const OTHER = 'Прочее';
|
||||
const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
||||
|
||||
export const STATUS_START = '<!-- android-status -->';
|
||||
export const STATUS_END = '<!-- /android-status -->';
|
||||
|
||||
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||||
export function groupCommits(commits) {
|
||||
const breaking = [];
|
||||
const byTitle = new Map();
|
||||
for (const { sha, subject } of commits) {
|
||||
const m = COMMIT_RE.exec(subject);
|
||||
const item = m ? { sha, scope: m[2] ?? null, text: m[4] } : { sha, scope: null, text: subject };
|
||||
if (m?.[3]) breaking.push(item);
|
||||
const title = (m && SECTIONS.find(([t]) => t === m[1])?.[1]) ?? OTHER;
|
||||
if (!byTitle.has(title)) byTitle.set(title, []);
|
||||
byTitle.get(title).push(item);
|
||||
}
|
||||
const order = [...new Set(SECTIONS.map(([, t]) => t)), OTHER];
|
||||
return { breaking, sections: order.filter((t) => byTitle.has(t)).map((t) => [t, byTitle.get(t)]) };
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} МБ`;
|
||||
|
||||
/** Блок статуса сборки Android в тексте релиза. */
|
||||
export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing }) {
|
||||
const lines =
|
||||
status === 'success'
|
||||
? [
|
||||
`✅ **Android:** APK собран — \`${apk.name}\` (${formatSize(apk.size)}), versionCode \`${versionCode}\`.`,
|
||||
`SHA-256: \`${apk.sha256}\``,
|
||||
...(signing === 'debug'
|
||||
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
|
||||
: []),
|
||||
]
|
||||
: status === 'cancelled'
|
||||
? [
|
||||
`⏹ **Android:** сборка APK отменена — [лог сборки](${runUrl}).`,
|
||||
'APK появится здесь после перезапуска сборки.',
|
||||
]
|
||||
: status === 'failure'
|
||||
? [
|
||||
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
|
||||
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
|
||||
]
|
||||
: [
|
||||
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
|
||||
'Файл появится в этом релизе автоматически, а этот текст обновится.',
|
||||
];
|
||||
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||||
}
|
||||
|
||||
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||
export function replaceAndroidStatus(body, block) {
|
||||
const start = body.indexOf(STATUS_START);
|
||||
const end = body.indexOf(STATUS_END);
|
||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
|
||||
}
|
||||
|
||||
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
|
||||
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
|
||||
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||||
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||||
const out = [];
|
||||
|
||||
if (prerelease) {
|
||||
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||||
}
|
||||
out.push(androidStatus, '', '## Что изменилось', '');
|
||||
if (groups.breaking.length) {
|
||||
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||||
}
|
||||
if (groups.sections.length) {
|
||||
for (const [title, items] of groups.sections) out.push(`### ${title}`, '', ...items.map(item), '');
|
||||
} else {
|
||||
out.push('Изменений с прошлой версии нет.', '');
|
||||
}
|
||||
out.push(
|
||||
prevTag
|
||||
? `Все изменения: [${prevTag}...${tag}](${repoUrl}/compare/${prevTag}...${tag})`
|
||||
: `Все изменения: [история ${tag}](${repoUrl}/commits/tag/${tag})`,
|
||||
'',
|
||||
'## Обновление сервера',
|
||||
'',
|
||||
'Перед обновлением сделайте бэкап базы:',
|
||||
'',
|
||||
'```bash',
|
||||
'cd /opt/pipu-music/deploy && docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump',
|
||||
'```',
|
||||
'',
|
||||
'Обновление:',
|
||||
'',
|
||||
'```bash',
|
||||
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
|
||||
'```',
|
||||
'',
|
||||
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
|
||||
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
|
||||
'',
|
||||
'```bash',
|
||||
'curl -s https://<ваш-домен>/api/v1/server-info',
|
||||
'```',
|
||||
'',
|
||||
`Ожидается \`"version":"${version}"\`.`,
|
||||
'',
|
||||
'## Android',
|
||||
'',
|
||||
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
|
||||
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
|
||||
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||
'',
|
||||
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||||
'',
|
||||
);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// CLI
|
||||
|
||||
function env(name) {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`не задана переменная окружения ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
async function api(method, path, { json, form } = {}) {
|
||||
const res = await fetch(`${env('GITEA_API')}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${env('GITEA_TOKEN')}`,
|
||||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: json ? JSON.stringify(json) : form,
|
||||
});
|
||||
if (res.status === 404 && method === 'GET') return null;
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
|
||||
function previousTag(tag, prerelease) {
|
||||
// для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||||
const exclude = prerelease ? [] : ['--exclude', '*-rc*'];
|
||||
try {
|
||||
return git('describe', '--tags', '--abbrev=0', ...exclude, `${tag}^`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commitsBetween(prevTag, tag) {
|
||||
const out = git('log', '--no-merges', '--format=%H%x09%s', prevTag ? `${prevTag}..${tag}` : tag);
|
||||
return out
|
||||
? out.split('\n').map((l) => {
|
||||
const [sha, ...rest] = l.split('\t');
|
||||
return { sha, subject: rest.join('\t') };
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
async function context() {
|
||||
const repo = await api('GET', '');
|
||||
const publicUrl = process.env.PUBLIC_URL?.replace(/\/+$/, '');
|
||||
const repoUrl = publicUrl ? `${publicUrl}/${repo.full_name}` : repo.html_url;
|
||||
const tag = env('TAG');
|
||||
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
|
||||
return { repoUrl, tag, release, runUrl: `${repoUrl}/actions/runs/${env('GITHUB_RUN_ID')}` };
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const { repoUrl, tag, release, runUrl } = await context();
|
||||
const prerelease = env('PRERELEASE') === 'true';
|
||||
const prevTag = previousTag(tag, prerelease);
|
||||
const body = renderNotes({
|
||||
version: env('VERSION'),
|
||||
tag,
|
||||
prevTag,
|
||||
prerelease,
|
||||
repoUrl,
|
||||
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||||
});
|
||||
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||||
const saved = release
|
||||
? await api('PATCH', `/releases/${release.id}`, { json: fields })
|
||||
: await api('POST', '/releases', { json: { tag_name: tag, ...fields } });
|
||||
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
|
||||
}
|
||||
|
||||
async function upload(release, file) {
|
||||
const name = basename(file);
|
||||
for (const old of release.assets ?? []) {
|
||||
if (old.name === name) await api('DELETE', `/releases/${release.id}/assets/${old.id}`);
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([readFileSync(file)]), name);
|
||||
await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { form });
|
||||
console.log(`приложен ${name}`);
|
||||
}
|
||||
|
||||
async function android(status, apkPath) {
|
||||
const { release, runUrl } = await context();
|
||||
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||||
let apk;
|
||||
if (status === 'success') {
|
||||
if (!apkPath) throw new Error('android success: укажите путь к APK');
|
||||
const data = readFileSync(apkPath);
|
||||
apk = { name: basename(apkPath), size: data.length, sha256: createHash('sha256').update(data).digest('hex') };
|
||||
for (const f of [apkPath, `${apkPath}.sha256`]) if (existsSync(f)) await upload(release, f);
|
||||
}
|
||||
const block = renderAndroidStatus({
|
||||
status,
|
||||
runUrl,
|
||||
apk,
|
||||
versionCode: process.env.VERSION_CODE,
|
||||
signing: process.env.SIGNING,
|
||||
});
|
||||
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceAndroidStatus(release.body ?? '', block) } });
|
||||
console.log(`статус Android: ${status}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
if (cmd === 'create') return create();
|
||||
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||||
return android(args[0], args[1]);
|
||||
}
|
||||
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
main().catch((e) => {
|
||||
process.stderr.write(`${e.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
groupCommits,
|
||||
renderAndroidStatus,
|
||||
renderNotes,
|
||||
replaceAndroidStatus,
|
||||
STATUS_END,
|
||||
STATUS_START,
|
||||
} from './release.mjs';
|
||||
|
||||
const sha = (n) => String(n).repeat(40).slice(0, 40);
|
||||
const repoUrl = 'https://git.example/owner/pipu-music';
|
||||
|
||||
test('groups conventional commits by section in a fixed order', () => {
|
||||
const groups = groupCommits([
|
||||
{ sha: sha(1), subject: 'fix(api): handle empty token' },
|
||||
{ sha: sha(2), subject: 'feat: add wave' },
|
||||
{ sha: sha(3), subject: 'ci(release): build arm64 only' },
|
||||
{ sha: sha(4), subject: 'build(deploy): pin images' },
|
||||
{ sha: sha(5), subject: 'Update README' },
|
||||
{ sha: sha(6), subject: 'chore: bump deps' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
groups.sections.map(([title, items]) => [title, items.map((i) => i.text)]),
|
||||
[
|
||||
['Новое', ['add wave']],
|
||||
['Исправления', ['handle empty token']],
|
||||
['Сборка и CI', ['build arm64 only', 'pin images']],
|
||||
['Прочее', ['Update README', 'bump deps']],
|
||||
],
|
||||
);
|
||||
assert.equal(groups.sections[1][1][0].scope, 'api');
|
||||
assert.deepEqual(groups.breaking, []);
|
||||
});
|
||||
|
||||
test('marks breaking changes', () => {
|
||||
const groups = groupCommits([{ sha: sha(1), subject: 'feat(api)!: drop v0 endpoints' }]);
|
||||
assert.equal(groups.breaking.length, 1);
|
||||
assert.equal(groups.sections[0][0], 'Новое');
|
||||
});
|
||||
|
||||
test('android status blocks', () => {
|
||||
const building = renderAndroidStatus({ status: 'building', runUrl: 'https://run' });
|
||||
assert.ok(building.startsWith(STATUS_START) && building.endsWith(STATUS_END));
|
||||
assert.match(building, /собирается — \[ход сборки\]\(https:\/\/run\)/);
|
||||
|
||||
const ok = renderAndroidStatus({
|
||||
status: 'success',
|
||||
versionCode: '10299',
|
||||
signing: 'release',
|
||||
apk: { name: 'pipu-music-0.1.2.apk', size: 25 * 1024 * 1024 + 300_000, sha256: 'ab'.repeat(32) },
|
||||
});
|
||||
assert.match(ok, /`pipu-music-0\.1\.2\.apk` \(25,3 МБ\), versionCode `10299`/);
|
||||
assert.doesNotMatch(ok, /отладочным/);
|
||||
|
||||
const debug = renderAndroidStatus({ status: 'success', signing: 'debug', apk: { name: 'a.apk', size: 1, sha256: 'x' } });
|
||||
assert.match(debug, /отладочным ключом/);
|
||||
|
||||
assert.match(renderAndroidStatus({ status: 'failure', runUrl: 'https://run' }), /не удалась — \[лог сборки\]/);
|
||||
assert.match(renderAndroidStatus({ status: 'cancelled', runUrl: 'https://run' }), /отменена — \[лог сборки\]/);
|
||||
});
|
||||
|
||||
test('replaces only the status block and is idempotent', () => {
|
||||
const body = `intro\n\n${renderAndroidStatus({ status: 'building', runUrl: 'u' })}\n\n## Что изменилось\n`;
|
||||
const failed = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
|
||||
const once = replaceAndroidStatus(body, failed);
|
||||
assert.equal(replaceAndroidStatus(once, failed), once);
|
||||
assert.ok(once.startsWith('intro\n\n') && once.endsWith('\n\n## Что изменилось\n'));
|
||||
assert.doesNotMatch(once, /собирается/);
|
||||
assert.equal(once.split(STATUS_START).length, 2);
|
||||
});
|
||||
|
||||
test('adds a status block when the body has none', () => {
|
||||
const block = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
|
||||
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
||||
});
|
||||
|
||||
test('release notes', () => {
|
||||
const notes = renderNotes({
|
||||
version: '0.2.0',
|
||||
tag: 'v0.2.0',
|
||||
prevTag: 'v0.1.7',
|
||||
prerelease: false,
|
||||
repoUrl,
|
||||
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
||||
});
|
||||
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
||||
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
||||
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
||||
assert.match(notes, /"version":"0\.2\.0"/);
|
||||
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
||||
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
||||
assert.doesNotMatch(notes, /Предварительная версия/);
|
||||
});
|
||||
|
||||
test('release notes for a first prerelease without changes', () => {
|
||||
const notes = renderNotes({
|
||||
version: '0.1.0-rc.1',
|
||||
tag: 'v0.1.0-rc.1',
|
||||
prevTag: null,
|
||||
prerelease: true,
|
||||
repoUrl,
|
||||
groups: groupCommits([]),
|
||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'u' }),
|
||||
});
|
||||
assert.match(notes, /Предварительная версия/);
|
||||
assert.match(notes, /Изменений с прошлой версии нет/);
|
||||
assert.match(notes, /commits\/tag\/v0\.1\.0-rc\.1/);
|
||||
});
|
||||
@@ -14,8 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
|
||||
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
|
||||
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot AS api
|
||||
# api вызывает ffprobe (сканер) и ffmpeg (транскодинг, M3) — нужен не distroless, а alpine с ffmpeg.
|
||||
FROM alpine:3.22 AS api
|
||||
RUN apk add --no-cache ffmpeg ca-certificates tzdata
|
||||
COPY --from=build /out/api /usr/local/bin/api
|
||||
USER 65532:65532
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/api"]
|
||||
|
||||
|
||||
@@ -3,17 +3,29 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/platform/config"
|
||||
"pipu/api/internal/platform/db"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
"pipu/api/internal/platform/sign"
|
||||
"pipu/api/internal/playback"
|
||||
"pipu/api/internal/search"
|
||||
"pipu/api/internal/server"
|
||||
"pipu/api/internal/system"
|
||||
)
|
||||
@@ -56,16 +68,118 @@ func run() error {
|
||||
rdb := redis.NewClient(redisOpts)
|
||||
defer func() { _ = rdb.Close() }()
|
||||
|
||||
authSvc, err := newAuthService(cfg, pool, rdb, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authSvc.Bootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sys := system.NewHandler(system.Info{
|
||||
Version: buildinfo.Version,
|
||||
SupportedLocales: cfg.SupportedLocales,
|
||||
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
||||
BootstrapRequired: authSvc.BootstrapRequired,
|
||||
}, log, map[string]system.Checker{
|
||||
"postgres": system.CheckerFunc(pool.Ping),
|
||||
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
||||
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
|
||||
})
|
||||
|
||||
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
|
||||
libStore := library.NewStore(pool)
|
||||
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
|
||||
board := library.NewProgressBoard()
|
||||
scanner := library.NewScanner(libStore, prober, board, log)
|
||||
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
|
||||
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
|
||||
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
|
||||
indexer := search.NewIndexer(pool, meili, log)
|
||||
signer, err := sign.New(cfg.SigningKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.SigningKey == "" {
|
||||
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
|
||||
}
|
||||
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
|
||||
scanner.AfterScan = func(ctx context.Context) {
|
||||
covers.Run(ctx)
|
||||
indexer.Trigger()
|
||||
}
|
||||
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
||||
|
||||
concurrency := cfg.TranscodeConcurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = max(1, runtime.NumCPU()/2)
|
||||
}
|
||||
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
|
||||
cfg.TranscodeCacheLimitGB, log)
|
||||
go transcoder.RunCleanup(ctx)
|
||||
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
|
||||
src, err := libStore.GetSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return libSvc.Root(src)
|
||||
}
|
||||
var dto library.DTO
|
||||
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
|
||||
rootFor, cfg.XAccel, log)
|
||||
dto = library.DTO{Signer: signer, Personal: pb}
|
||||
libSvc.OnCatalogChanged = indexer.Trigger
|
||||
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
||||
go libSvc.Restore(ctx)
|
||||
go libSvc.RunSchedule(ctx)
|
||||
|
||||
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
|
||||
Log: log,
|
||||
System: sys,
|
||||
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||
AuthService: authSvc,
|
||||
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||
Search: search.NewHandler(meili, libStore, indexer, dto, log),
|
||||
Playback: pb,
|
||||
}))
|
||||
log.Info("starting", "version", buildinfo.Version)
|
||||
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||
}
|
||||
|
||||
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
|
||||
var (
|
||||
key ed25519.PrivateKey
|
||||
err error
|
||||
)
|
||||
if cfg.JWTPrivateKey == "" {
|
||||
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
|
||||
key, err = auth.GenerateKey()
|
||||
} else {
|
||||
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previous []ed25519.PrivateKey
|
||||
for _, s := range cfg.JWTPreviousKeys {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
k, err := auth.ParsePrivateKey(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
|
||||
}
|
||||
previous = append(previous, k)
|
||||
}
|
||||
clock := auth.SystemClock{}
|
||||
acfg := auth.DefaultConfig()
|
||||
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
|
||||
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
|
||||
acfg.SupportedLocales = cfg.SupportedLocales
|
||||
return auth.NewService(
|
||||
auth.NewPgStore(pool),
|
||||
auth.NewHasher(auth.DefaultArgon2Params),
|
||||
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
|
||||
auth.NewRedisLimiter(rdb),
|
||||
clock, auth.UUIDv7{}, log, acfg,
|
||||
)
|
||||
}
|
||||
+5
-1
@@ -8,14 +8,18 @@ require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/getkin/kin-openapi v0.127.0
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/redis/go-redis/v9 v9.10.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -37,9 +41,9 @@ require (
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
@@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user