Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f087ec9f88 | ||
|
|
cd84ceecb2 | ||
|
|
582c0dc3d4 | ||
|
|
1a693848f2 | ||
|
|
11a16cd023 | ||
|
|
0a05bfe2cd | ||
|
|
f1528de948 | ||
|
|
0a4cd4bff1 | ||
|
|
eed7038109 | ||
|
|
7fa7ee2c7d | ||
|
|
24707a0bfc | ||
|
|
a6c9fd8a00 | ||
|
|
bc55528afd | ||
|
|
a29a8b18ad | ||
|
|
e33abc948c | ||
|
|
ab49e22357 | ||
|
|
75ae2092ad | ||
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 | ||
|
|
02307802bc | ||
|
|
fff91c446b | ||
|
|
e158558da3 | ||
|
|
c84f90e2cc | ||
|
|
72567acd28 | ||
|
|
39aaaed969 | ||
|
|
8eb6b36888 | ||
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 |
No files matched your search
@@ -0,0 +1,27 @@
|
|||||||
|
name: ci / module boundaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
boundaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: '22' }
|
||||||
|
- run: node scripts/check-boundaries.mjs
|
||||||
@@ -71,7 +71,9 @@ jobs:
|
|||||||
done
|
done
|
||||||
- name: Go — миграции и запросы store
|
- name: Go — миграции и запросы store
|
||||||
working-directory: services/api
|
working-directory: services/api
|
||||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
# These suites share one database; dbtest also rolls all migrations down
|
||||||
|
# and cleans orphan catalog rows. Do not run the packages concurrently.
|
||||||
|
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
|
||||||
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||||
working-directory: services/api
|
working-directory: services/api
|
||||||
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||||
# (Linux AppImage/deb и Windows NSIS через cargo-xwin на ubuntu-latest).
|
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
|
||||||
name: ci / desktop
|
name: ci / desktop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Обновление сервера, агент и конфигурация
|
||||||
|
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
|
||||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||||
|
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -217,7 +218,6 @@ jobs:
|
|||||||
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
CARGO_BUILD_JOBS: "2"
|
CARGO_BUILD_JOBS: "2"
|
||||||
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||||
@@ -235,7 +235,7 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||||
libdbus-1-dev libsecret-1-dev patchelf libfuse2 \
|
libdbus-1-dev libsecret-1-dev patchelf \
|
||||||
nsis lld llvm clang
|
nsis lld llvm clang
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -250,7 +250,7 @@ jobs:
|
|||||||
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
restore-keys: cargo-desktop-
|
restore-keys: cargo-desktop-
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- name: Сборка AppImage и deb
|
- name: Сборка deb
|
||||||
id: linux
|
id: linux
|
||||||
working-directory: apps/desktop
|
working-directory: apps/desktop
|
||||||
run: |
|
run: |
|
||||||
@@ -258,27 +258,21 @@ jobs:
|
|||||||
pnpm bundle:linux
|
pnpm bundle:linux
|
||||||
bundle=src-tauri/target/release/bundle
|
bundle=src-tauri/target/release/bundle
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for f in "$bundle"/appimage/*.AppImage; do
|
|
||||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage"
|
|
||||||
done
|
|
||||||
for f in "$bundle"/deb/*.deb; do
|
for f in "$bundle"/deb/*.deb; do
|
||||||
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
done
|
done
|
||||||
ls -lh "$GITHUB_WORKSPACE"/pipu-music-${PIPU_VERSION}-linux.*
|
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
rm -rf src-tauri/target/release
|
rm -rf src-tauri/target/release
|
||||||
- name: Linux в релиз
|
- name: Linux в релиз
|
||||||
id: linux-upload
|
id: linux-upload
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
files=()
|
f="pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do
|
if [ ! -f "$f" ]; then
|
||||||
[ -f "$f" ] && files+=("$f")
|
echo "::error::нет $f после сборки"
|
||||||
done
|
|
||||||
if [ ${#files[@]} -eq 0 ]; then
|
|
||||||
echo "::error::нет AppImage и deb после сборки"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
node scripts/release.mjs desktop linux success "${files[@]}"
|
node scripts/release.mjs desktop linux success "$f"
|
||||||
- name: Windows toolchain
|
- name: Windows toolchain
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
@@ -328,3 +322,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
node scripts/release.mjs desktop linux cancelled
|
node scripts/release.mjs desktop linux cancelled
|
||||||
node scripts/release.mjs desktop windows cancelled
|
node scripts/release.mjs desktop windows cancelled
|
||||||
|
|
||||||
|
prune:
|
||||||
|
name: prune (старые файлы и прогоны)
|
||||||
|
needs: [release, android, desktop]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||||
|
- name: Оставить 8 релизов и 8 прогонов
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: node scripts/release.mjs prune
|
||||||
@@ -20,6 +20,8 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# env / secrets
|
# env / secrets
|
||||||
|
deploy/config.local
|
||||||
|
deploy/.management.lock
|
||||||
.env
|
.env
|
||||||
*.pem
|
*.pem
|
||||||
*.keystore
|
*.keystore
|
||||||
@@ -41,3 +43,6 @@ apps/desktop/src-tauri/target/
|
|||||||
apps/desktop/src-tauri/gen/
|
apps/desktop/src-tauri/gen/
|
||||||
apps/desktop/src-tauri/tauri.release.json
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
apps/desktop/release/
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Intermediate platform icon renders (final assets live under apps/).
|
||||||
|
/artifacts/icon-build/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Работа с проектом
|
||||||
|
|
||||||
|
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
|
||||||
|
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
|
||||||
|
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
|
||||||
|
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
|
||||||
|
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||||
|
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
|
||||||
|
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||||
|
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||||
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
|
|||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
- Документация: [docs/README.md](docs/README.md)
|
- Документация: [docs/README.md](docs/README.md)
|
||||||
|
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
|
||||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||||
|
pnpm check:boundaries
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||||
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles appimage,deb",
|
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
|
||||||
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
||||||
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"icons": "tauri icon ../web/public/favicon.svg"
|
"icons": "node ../../scripts/generate-icons.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.0"
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
|||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
@@ -38,5 +39,7 @@ keyring = { version = "3.6.3", features = ["apple-native"] }
|
|||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
lto = "thin"
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 46 KiB |
@@ -1,6 +1,7 @@
|
|||||||
mod media;
|
mod media;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod update;
|
||||||
|
|
||||||
use media::Media;
|
use media::Media;
|
||||||
use proxy::Proxy;
|
use proxy::Proxy;
|
||||||
@@ -110,6 +111,7 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
.manage(update::Updater::default())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let saved = store::load_server(app.handle())?;
|
let saved = store::load_server(app.handle())?;
|
||||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||||
@@ -150,7 +152,8 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
|
||||||
|
update::update_check, update::update_install
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("desktop app failed to start");
|
.expect("desktop app failed to start");
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
|||||||
}
|
}
|
||||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||||
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
|||||||
} else {
|
} else {
|
||||||
match transfer(&proxy, request, tail).await {
|
match transfer(&proxy, request, tail).await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
Err(_) => {
|
||||||
|
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
|
||||||
|
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
|
||||||
|
response
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
cors(response.headers_mut(), origin.as_deref());
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Server-proxied releases: never accept installer paths or commands from the UI.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{io::Write, path::Path, time::Duration};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Build {
|
||||||
|
client: String,
|
||||||
|
version: String,
|
||||||
|
filename: String,
|
||||||
|
size: u64,
|
||||||
|
sha256: Option<String>,
|
||||||
|
download_url: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Release { build: Build, notes: String }
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Updater { release: Mutex<Option<(String, Release)>> }
|
||||||
|
|
||||||
|
fn client() -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") { "windows" }
|
||||||
|
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_code(raw: &str) -> Option<u64> {
|
||||||
|
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
|
||||||
|
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
|
||||||
|
let rc: u64 = rc.parse().ok()?;
|
||||||
|
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
|
||||||
|
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http() -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
|
||||||
|
.build().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
|
||||||
|
let mut saved = updater.release.lock().await;
|
||||||
|
*saved = None;
|
||||||
|
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
|
||||||
|
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
|
||||||
|
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
|
||||||
|
.json().await.map_err(|e| e.to_string())?;
|
||||||
|
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
|
||||||
|
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
|
||||||
|
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
|
||||||
|
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
|
||||||
|
let release = Release { build, notes };
|
||||||
|
validate_build(&release.build)?;
|
||||||
|
*saved = Some((server, release.clone()));
|
||||||
|
Ok(Some(release))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_build(build: &Build) -> Result<(), String> {
|
||||||
|
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
|
||||||
|
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
|
||||||
|
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
|
||||||
|
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
|
||||||
|
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
|
||||||
|
return Err("Invalid installer URL".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
|
||||||
|
let saved = updater.release.lock().await;
|
||||||
|
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
|
||||||
|
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
|
||||||
|
let build = &release.build;
|
||||||
|
validate_build(build)?;
|
||||||
|
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let path = dir.join(&build.filename);
|
||||||
|
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
|
||||||
|
let result = async {
|
||||||
|
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
|
||||||
|
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
|
||||||
|
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||||
|
bytes += chunk.len() as u64;
|
||||||
|
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
|
||||||
|
hash.update(&chunk);
|
||||||
|
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
|
||||||
|
}
|
||||||
|
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
|
||||||
|
file.sync_all().map_err(|e| e.to_string())?;
|
||||||
|
drop(file);
|
||||||
|
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
|
||||||
|
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
|
||||||
|
launch(&path)?;
|
||||||
|
Ok(())
|
||||||
|
}.await;
|
||||||
|
let _ = std::fs::remove_file(&part);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch(path: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
|
||||||
|
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
|
||||||
|
} else { std::process::Command::new(path) };
|
||||||
|
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn release_order() {
|
||||||
|
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
|
||||||
|
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
|
||||||
|
assert_eq!(version_code("1.7.1"), Some(1070199));
|
||||||
|
assert_eq!(version_code("1.7.100"), None);
|
||||||
|
assert_eq!(version_code("1.7.1;cmd"), None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn installer_is_restricted_to_verified_server_asset() {
|
||||||
|
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
|
||||||
|
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
|
||||||
|
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
|
||||||
|
assert!(validate_build(&build).is_ok());
|
||||||
|
build.download_url = "https://other.example/installer.exe".into();
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
|
||||||
|
build.filename = "../installer.exe".into();
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
build.filename = format!("pipu.{extension}");
|
||||||
|
build.sha256 = None;
|
||||||
|
assert!(validate_build(&build).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||||
"linux": {
|
"linux": {
|
||||||
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||||
"appimage": { "bundleMediaFramework": true }
|
"appimage": { "bundleMediaFramework": false }
|
||||||
},
|
},
|
||||||
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Android
|
||||||
|
|
||||||
|
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
|
||||||
|
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
|
||||||
|
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
|
||||||
|
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
|
||||||
|
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
|
||||||
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
name: 'Pipu Music',
|
name: 'Pipu Music',
|
||||||
slug: 'pipu-music',
|
slug: 'pipu-music',
|
||||||
scheme: 'pipu',
|
scheme: 'pipu',
|
||||||
|
icon: './assets/icon.png',
|
||||||
version,
|
version,
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
userInterfaceStyle: 'automatic',
|
userInterfaceStyle: 'automatic',
|
||||||
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
newArchEnabled: false,
|
newArchEnabled: false,
|
||||||
backgroundColor: '#05070D',
|
backgroundColor: '#05070D',
|
||||||
android: {
|
android: {
|
||||||
|
adaptiveIcon: {
|
||||||
|
foregroundImage: './assets/adaptive-icon.png',
|
||||||
|
monochromeImage: './assets/monochrome-icon.png',
|
||||||
|
backgroundColor: '#05070D',
|
||||||
|
},
|
||||||
package: 'app.pipu.music',
|
package: 'app.pipu.music',
|
||||||
versionCode,
|
versionCode,
|
||||||
edgeToEdgeEnabled: true,
|
edgeToEdgeEnabled: true,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -8,32 +8,38 @@ import { player } from '../../player/controller';
|
|||||||
import { usePlayerState } from '../../player/hooks';
|
import { usePlayerState } from '../../player/hooks';
|
||||||
import { useColors } from '../../theme';
|
import { useColors } from '../../theme';
|
||||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||||
|
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
|
||||||
|
import { ListeningBanner } from '../../features/social/Listening';
|
||||||
import { UpdateDialog } from '../../ui/UpdateDialog';
|
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const unread = useSocialUnread();
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
index: t('nav.home'),
|
index: t('nav.home'),
|
||||||
search: t('mobile.search'),
|
search: t('mobile.search'),
|
||||||
collection: t('nav.collection'),
|
collection: t('nav.collection'),
|
||||||
profile: t('mobile.profile'),
|
profile: t('mobile.profile'),
|
||||||
|
friends: t('social.people'),
|
||||||
};
|
};
|
||||||
const net = useNetInfo();
|
const net = useNetInfo();
|
||||||
return (
|
return (
|
||||||
<>
|
<NativeChatProvider>
|
||||||
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||||
tabBar={({ state, navigation }) => (
|
tabBar={({ state, navigation }) => (
|
||||||
<View>
|
<View>
|
||||||
|
<ListeningBanner />
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
<TabBar
|
<TabBar
|
||||||
routes={state.routes.map((r) => r.name)}
|
routes={state.routes.map((r) => r.name)}
|
||||||
active={state.index}
|
active={state.index}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
|
badges={{ friends: unread }}
|
||||||
onPress={(name) => navigation.navigate(name)}
|
onPress={(name) => navigation.navigate(name)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -42,10 +48,11 @@ export default function TabsLayout() {
|
|||||||
<Tabs.Screen name="index" />
|
<Tabs.Screen name="index" />
|
||||||
<Tabs.Screen name="search" />
|
<Tabs.Screen name="search" />
|
||||||
<Tabs.Screen name="collection" />
|
<Tabs.Screen name="collection" />
|
||||||
|
<Tabs.Screen name="friends" />
|
||||||
<Tabs.Screen name="profile" />
|
<Tabs.Screen name="profile" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<UpdateDialog />
|
<UpdateDialog />
|
||||||
</>
|
</NativeChatProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +60,13 @@ function TabBar({
|
|||||||
routes,
|
routes,
|
||||||
active,
|
active,
|
||||||
labels,
|
labels,
|
||||||
|
badges,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
routes: string[];
|
routes: string[];
|
||||||
active: number;
|
active: number;
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
badges: Record<string, number>;
|
||||||
onPress: (name: string) => void;
|
onPress: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@@ -81,10 +90,13 @@ function TabBar({
|
|||||||
onPress={() => onPress(name)}
|
onPress={() => onPress(name)}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityState={{ selected: on }}
|
accessibilityState={{ selected: on }}
|
||||||
accessibilityLabel={labels[name]}
|
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
|
||||||
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||||
>
|
>
|
||||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
<View>
|
||||||
|
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||||
|
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
|
||||||
|
</View>
|
||||||
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||||
{labels[name]}
|
{labels[name]}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SocialScreen } from '../../features/social/Social';
|
||||||
|
export default function FriendsTab() { return <SocialScreen root />; }
|
||||||
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
|
|||||||
import { usePlayerState } from '../../player/hooks';
|
import { usePlayerState } from '../../player/hooks';
|
||||||
import { useColors } from '../../theme';
|
import { useColors } from '../../theme';
|
||||||
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||||
|
import { OnboardingGate } from '../../ui/Onboarding';
|
||||||
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||||
|
|
||||||
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||||
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
|
|||||||
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||||
*/
|
*/
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveHome() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tr = t as unknown as (k: string) => string;
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
|
|||||||
import { radius, useColors } from '../../theme';
|
import { radius, useColors } from '../../theme';
|
||||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||||
import { useDownloads } from '../../ui/downloads';
|
import { useDownloads } from '../../ui/downloads';
|
||||||
|
import { RecommendationSettings } from '../../ui/Onboarding';
|
||||||
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||||
import { useUpdateState } from '../../ui/UpdateDialog';
|
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
|
|||||||
@{user.username} · {t(`roles.${user.role}`)}
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
</Muted>
|
</Muted>
|
||||||
|
|
||||||
|
<RecommendationSettings />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Row label={t('language.label')}>
|
<Row label={t('language.label')}>
|
||||||
<Segmented<Locale>
|
<Segmented<Locale>
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ function Screens() {
|
|||||||
<Stack.Screen name="album/[id]" />
|
<Stack.Screen name="album/[id]" />
|
||||||
<Stack.Screen name="artist/[id]" />
|
<Stack.Screen name="artist/[id]" />
|
||||||
<Stack.Screen name="playlists" />
|
<Stack.Screen name="playlists" />
|
||||||
|
<Stack.Screen name="social" />
|
||||||
|
<Stack.Screen name="social-track" />
|
||||||
|
<Stack.Screen name="social-playlist" />
|
||||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { MusicShareSheet } from '../features/social/Social';
|
||||||
|
import { ListeningBanner } from '../features/social/Listening';
|
||||||
import { trackShareUrl } from '@pipu/core';
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
import { useSessionCtx, useUser } from '../lib/session';
|
import { useSessionCtx, useUser } from '../lib/session';
|
||||||
@@ -21,6 +23,7 @@ export default function PlayerScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
|
const locked = st.listening?.locked === true;
|
||||||
const prefs = useUser().ui_prefs;
|
const prefs = useUser().ui_prefs;
|
||||||
const { server } = useSessionCtx();
|
const { server } = useSessionCtx();
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
@@ -39,20 +42,12 @@ export default function PlayerScreen() {
|
|||||||
const duration = st.durationMs || track.duration_ms;
|
const duration = st.durationMs || track.duration_ms;
|
||||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
const share = async () => {
|
const share = () => setSharing(true);
|
||||||
if (!server || sharing) return;
|
|
||||||
setSharing(true);
|
|
||||||
try {
|
|
||||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
|
||||||
} catch {
|
|
||||||
Alert.alert(t('player.share'), t('player.share_error'));
|
|
||||||
} finally {
|
|
||||||
setSharing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
|
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||||
|
<ListeningBanner />
|
||||||
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||||
@@ -91,12 +86,18 @@ export default function PlayerScreen() {
|
|||||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
{track.artists.map((a) => a.name).join(', ')}
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
|
||||||
|
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
|
||||||
|
<Icon name="playlist-add" size={24} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ gap: 6 }}>
|
<View style={{ gap: 6 }}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
||||||
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||||
accessibilityRole="adjustable"
|
accessibilityRole="adjustable"
|
||||||
accessibilityLabel={t('player.progress')}
|
accessibilityLabel={t('player.progress')}
|
||||||
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
||||||
@@ -118,18 +119,18 @@ export default function PlayerScreen() {
|
|||||||
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||||
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||||
<Icon name="skip-previous" size={40} color={colors.text} />
|
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => player.toggle()}
|
disabled={locked} onPress={() => player.toggle()}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||||
<Icon name="skip-next" size={40} color={colors.text} />
|
<Icon name="skip-next" size={40} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { MusicShareSheet } from '../features/social/Social';
|
||||||
|
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||||
import { useSession, useUser } from '../lib/session';
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
import { player } from '../player/controller';
|
import { player } from '../player/controller';
|
||||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
@@ -77,7 +78,9 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Detail({ id }: { id: string }) {
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -89,6 +92,15 @@ function Detail({ id }: { id: string }) {
|
|||||||
const key = ['m-playlists', user.id, id];
|
const key = ['m-playlists', user.id, id];
|
||||||
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
const p = query.data;
|
const p = query.data;
|
||||||
|
const sharing = useMutation({ mutationFn: async () => {
|
||||||
|
if (!server || !p) throw new Error('Server unavailable');
|
||||||
|
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||||
|
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||||
|
setShareToken(token);
|
||||||
|
} });
|
||||||
|
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||||
|
|
||||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
@@ -102,14 +114,17 @@ function Detail({ id }: { id: string }) {
|
|||||||
const move = (index: number, delta: number) => {
|
const move = (index: number, delta: number) => {
|
||||||
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||||
};
|
};
|
||||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||||
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
|
||||||
|
<Muted>{t('playlists.share_hint')}</Muted>
|
||||||
|
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
|
||||||
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SharedMusic } from '../features/social/SharedMusic';
|
||||||
|
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { SharedMusic } from '../features/social/SharedMusic';
|
||||||
|
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SocialScreen as default } from '../features/social/Social';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
import { readDraft, saveDraft } from './drafts';
|
||||||
|
|
||||||
|
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
|
||||||
|
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
|
||||||
|
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
|
||||||
|
}
|
||||||
|
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
|
||||||
|
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
|
||||||
|
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
|
||||||
|
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
|
||||||
|
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
|
||||||
|
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
|
||||||
|
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
|
||||||
|
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
|
||||||
|
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
|
||||||
|
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
|
||||||
|
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
|
||||||
|
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
|
||||||
|
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
|
||||||
|
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
|
||||||
|
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
|
||||||
|
const refresh=()=>cache.invalidateQueries({queryKey});
|
||||||
|
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
|
||||||
|
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
|
||||||
|
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
|
||||||
|
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
|
||||||
|
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
|
||||||
|
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
|
||||||
|
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
|
||||||
|
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
|
||||||
|
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
|
||||||
|
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
|
||||||
|
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
|
||||||
|
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
|
||||||
|
<Bubble mine={m.sender_id===userId} animate={!reduced}>
|
||||||
|
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
|
||||||
|
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
|
||||||
|
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
|
||||||
|
</Pressable>
|
||||||
|
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
|
||||||
|
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
|
||||||
|
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
|
||||||
|
</Bubble>
|
||||||
|
</View>)}
|
||||||
|
</ScrollView>
|
||||||
|
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
|
||||||
|
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
|
||||||
|
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
|
||||||
|
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
|
||||||
|
</KeyboardAvoidingView>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { usePlayerState } from '../../player/hooks';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
function useListening() {
|
||||||
|
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
|
||||||
|
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||||
|
return { api, key, query, user };
|
||||||
|
}
|
||||||
|
export function ListeningPanel() {
|
||||||
|
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
|
||||||
|
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
|
||||||
|
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
|
||||||
|
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
|
||||||
|
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
|
||||||
|
</View>}
|
||||||
|
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
|
||||||
|
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||||
|
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||||
|
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
|
||||||
|
}
|
||||||
|
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||||
|
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||||
|
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||||
|
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||||
|
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||||
|
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||||
|
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||||
|
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
export function ListeningBanner() {
|
||||||
|
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||||
|
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
|
||||||
|
if (!room) return null;
|
||||||
|
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
|
||||||
|
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
|
||||||
|
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
|
||||||
|
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
|
||||||
|
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
|
||||||
|
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FlatList, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { player } from '../../player/controller';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||||
|
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
|
||||||
|
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
|
||||||
|
if (kind === 'track') {
|
||||||
|
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
|
||||||
|
return { id: track.id, name: track.title, tracks: [track] };
|
||||||
|
}
|
||||||
|
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
|
||||||
|
} });
|
||||||
|
const visible = query.isError ? undefined : query.data;
|
||||||
|
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
|
||||||
|
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
|
||||||
|
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||||
|
{query.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
|
||||||
|
</View>}
|
||||||
|
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
|
||||||
|
/></Screen>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||||
|
import { eventId } from '../../player/eventId';
|
||||||
|
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||||
|
import { ChatConversation } from './ChatConversation';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
|
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||||
|
import { useColors } from '../../theme';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||||
|
|
||||||
|
function useSocial() {
|
||||||
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const user = useUser();
|
||||||
|
const api = useMemo(() => socialApi(session), [session]);
|
||||||
|
const [active, setActive] = useState(AppState.currentState === 'active');
|
||||||
|
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
|
||||||
|
const key = ['m-social', server, user.id] as const;
|
||||||
|
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
|
||||||
|
return { api, key, inbox, user, active };
|
||||||
|
}
|
||||||
|
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||||
|
|
||||||
|
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
|
||||||
|
export function NativeChatProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
|
||||||
|
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||||
|
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
|
||||||
|
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
|
||||||
|
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
|
||||||
|
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||||
|
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
|
||||||
|
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
|
||||||
|
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
|
||||||
|
</Screen>
|
||||||
|
</View></View>
|
||||||
|
</Modal>
|
||||||
|
</ChatDockContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FriendRecommendations() {
|
||||||
|
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||||
|
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
|
||||||
|
}
|
||||||
|
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { inbox, api, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [sent, setSent] = useState<string[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const ids = useRef(new Map<string, string>());
|
||||||
|
const send = useMutation({ mutationFn: async (peer: string) => {
|
||||||
|
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
|
||||||
|
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
|
||||||
|
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
|
||||||
|
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
|
||||||
|
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
|
||||||
|
} });
|
||||||
|
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
|
||||||
|
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
|
||||||
|
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
|
||||||
|
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
|
||||||
|
<H1>{t('social.send_friend')}</H1>
|
||||||
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
|
||||||
|
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
|
||||||
|
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||||
|
const dock = useContext(ChatDockContext);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const router = useRouter();
|
||||||
|
const { api, inbox, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [peer, setPeer] = useState('');
|
||||||
|
const [notifications, setNotifications] = useState(false);
|
||||||
|
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||||
|
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
|
||||||
|
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||||
|
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
|
||||||
|
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||||
|
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
|
||||||
|
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
|
||||||
|
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||||
|
<Muted>{t('social.activity_visibility')}</Muted>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
|
||||||
|
<Muted>{t('social.activity_privacy_hint')}</Muted>
|
||||||
|
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
|
||||||
|
</View>
|
||||||
|
<ListeningPanel />
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
|
||||||
|
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
|
||||||
|
</View>
|
||||||
|
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
|
||||||
|
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{notifications ? <>
|
||||||
|
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
|
||||||
|
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
|
||||||
|
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
|
||||||
|
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
|
||||||
|
</View>)}
|
||||||
|
</> : <>
|
||||||
|
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
|
||||||
|
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
|
||||||
|
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
|
||||||
|
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
|
||||||
|
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
|
||||||
|
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
|
||||||
|
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
|
||||||
|
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
|
||||||
|
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
|
||||||
|
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
|
||||||
|
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
|
||||||
|
</View>}
|
||||||
|
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
|
||||||
|
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||||
|
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
|
||||||
|
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
|
||||||
|
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
|
||||||
|
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
|
||||||
|
</View>)}
|
||||||
|
</>}
|
||||||
|
</ScrollView></Screen>;
|
||||||
|
}
|
||||||
|
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
|
||||||
|
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
|
||||||
|
const [focused,setFocused]=useState(false);
|
||||||
|
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
|
||||||
|
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { sha256 } from 'js-sha256';
|
||||||
|
import { chatDraftKey } from '@pipu/core';
|
||||||
|
|
||||||
|
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
|
||||||
|
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
|
||||||
|
export async function readDraft(server: string, user: string, peer: string) {
|
||||||
|
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
|
||||||
|
}
|
||||||
|
export async function saveDraft(server: string, user: string, peer: string, text: string) {
|
||||||
|
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
|
||||||
|
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||||
|
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
|
||||||
|
}
|
||||||
@@ -272,7 +272,7 @@ class Downloads {
|
|||||||
if (track.album.cover_url) {
|
if (track.album.cover_url) {
|
||||||
const cover = this.cover(track.album.id);
|
const cover = this.cover(track.album.id);
|
||||||
const info = await FileSystem.getInfoAsync(cover);
|
const info = await FileSystem.getInfoAsync(cover);
|
||||||
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const info = await FileSystem.getInfoAsync(this.file(id));
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||||
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||||
@@ -286,4 +286,9 @@ class Downloads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
|
||||||
|
export function sizedCover(url: string, size: number): string {
|
||||||
|
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloads = new Downloads();
|
export const downloads = new Downloads();
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import NetInfo from '@react-native-community/netinfo';
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import {
|
import {
|
||||||
EventBuffer,
|
EventBuffer,
|
||||||
|
createActivityPublisher,
|
||||||
|
type ActivityPublisher,
|
||||||
|
ListeningCoordinator,
|
||||||
|
type ListeningRoom,
|
||||||
|
WaveCoordinator,
|
||||||
|
EARLY_SKIP_MS,
|
||||||
|
type WaveInfo,
|
||||||
|
type WaveOutcome,
|
||||||
|
type WaveFeedback,
|
||||||
PlayAttempt,
|
PlayAttempt,
|
||||||
normalizeGain,
|
normalizeGain,
|
||||||
unwrap,
|
unwrap,
|
||||||
@@ -17,21 +26,17 @@ import TrackPlayer, {
|
|||||||
} from 'react-native-track-player';
|
} from 'react-native-track-player';
|
||||||
|
|
||||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { PersistentEvents } from '../offline/events';
|
import { PersistentEvents } from '../offline/events';
|
||||||
import { offlineWave } from './offlineWave';
|
import { offlineWave } from './offlineWave';
|
||||||
import { eventId } from './eventId';
|
import { eventId } from './eventId';
|
||||||
|
|
||||||
export { offlineWave };
|
export { offlineWave };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export type { WaveInfo } from '@pipu/core';
|
||||||
sessionId: string;
|
|
||||||
activity: string | null;
|
|
||||||
mood: string | null;
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
|
listening?: import('@pipu/core').ListeningClientState;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
track: Track | null;
|
track: Track | null;
|
||||||
queue: Track[];
|
queue: Track[];
|
||||||
@@ -44,10 +49,6 @@ export interface PlayerState {
|
|||||||
wave: WaveInfo | null;
|
wave: WaveInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
|
||||||
|
|
||||||
const WAVE_REFILL_AT = 2;
|
|
||||||
const EARLY_SKIP_MS = 30_000;
|
|
||||||
const SAVE_EVERY_MS = 10_000;
|
const SAVE_EVERY_MS = 10_000;
|
||||||
|
|
||||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||||
@@ -76,6 +77,11 @@ async function quality(): Promise<Quality> {
|
|||||||
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||||
*/
|
*/
|
||||||
class Controller {
|
class Controller {
|
||||||
|
private activity: ActivityPublisher | null = null;
|
||||||
|
private listening: ListeningCoordinator | null = null;
|
||||||
|
private interrupted = false;
|
||||||
|
private remoteRoom: string | null = null;
|
||||||
|
private queueVersion = 0;
|
||||||
private session: AuthSession | null = null;
|
private session: AuthSession | null = null;
|
||||||
private server = '';
|
private server = '';
|
||||||
private events: EventBuffer | null = null;
|
private events: EventBuffer | null = null;
|
||||||
@@ -84,8 +90,13 @@ class Controller {
|
|||||||
private attempt: PlayAttempt | null = null;
|
private attempt: PlayAttempt | null = null;
|
||||||
private lastPositionMs = 0;
|
private lastPositionMs = 0;
|
||||||
private lastSave = 0;
|
private lastSave = 0;
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private readonly wavePolicy = new WaveCoordinator({
|
||||||
private waveLoading = false;
|
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
|
||||||
|
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||||
|
params: { path: { id } }, body: { feedback },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
private setupDone: Promise<void> | null = null;
|
private setupDone: Promise<void> | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
@@ -103,12 +114,27 @@ class Controller {
|
|||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
|
this.activity?.update(this.state);
|
||||||
|
this.listening?.update();
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||||
attach(session: AuthSession | null, server: string, userId: string | null) {
|
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||||
if (this.session === session) return;
|
if (this.session === session) return;
|
||||||
|
this.activity?.dispose();
|
||||||
|
this.listening?.dispose();
|
||||||
|
this.listening = null;
|
||||||
|
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
|
||||||
|
if (session && this.activity) {
|
||||||
|
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
|
||||||
|
read: this.getState,
|
||||||
|
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
|
||||||
|
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||||
|
changed: listening => this.update({ listening }),
|
||||||
|
});
|
||||||
|
this.listening.start();
|
||||||
|
}
|
||||||
void this.events?.flush();
|
void this.events?.flush();
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
@@ -224,47 +250,64 @@ class Controller {
|
|||||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||||
const q = await quality();
|
const q = await quality();
|
||||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
return tracks.map((t) => ({
|
return tracks.map((t) => {
|
||||||
id: t.id,
|
const cover = absolute(this.server, t.album.cover_url);
|
||||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
return {
|
||||||
title: t.title,
|
id: t.id,
|
||||||
artist: t.artists.map((a) => a.name).join(', '),
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||||
album: t.album.title,
|
title: t.title,
|
||||||
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
duration: t.duration_ms / 1000,
|
album: t.album.title,
|
||||||
}));
|
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
|
||||||
|
duration: t.duration_ms / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
|
const version = ++this.queueVersion;
|
||||||
|
const current = () => version === this.queueVersion && !this.listening?.locked;
|
||||||
const list = tracks.filter(playable);
|
const list = tracks.filter(playable);
|
||||||
const target = tracks[index];
|
const target = tracks[index];
|
||||||
if (!target || !list.length) return;
|
if (!target || !list.length) return;
|
||||||
await this.setup();
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
|
const native = await this.toRntp(list);
|
||||||
|
if (!current()) return;
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
this.finishAttempt(this.lastPositionMs / 1000);
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
const start = Math.max(list.indexOf(target), 0);
|
const start = Math.max(list.indexOf(target), 0);
|
||||||
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||||
await TrackPlayer.reset();
|
await TrackPlayer.reset();
|
||||||
await TrackPlayer.add(await this.toRntp(list));
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (!current()) return;
|
||||||
await TrackPlayer.skip(start);
|
await TrackPlayer.skip(start);
|
||||||
|
if (!current()) return;
|
||||||
await TrackPlayer.play();
|
await TrackPlayer.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
void TrackPlayer.skipToNext().catch(() => undefined);
|
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prev() {
|
async prev() {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
const { position } = await TrackPlayer.getProgress();
|
const { position } = await TrackPlayer.getProgress();
|
||||||
|
if (this.listening?.locked) return;
|
||||||
if (position > 3) await TrackPlayer.seekTo(0);
|
if (position > 3) await TrackPlayer.seekTo(0);
|
||||||
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(ms: number) {
|
seek(ms: number) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
const t = this.state.track;
|
const t = this.state.track;
|
||||||
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||||
this.attempt?.seek(ms);
|
this.attempt?.seek(ms);
|
||||||
@@ -302,21 +345,26 @@ class Controller {
|
|||||||
// --- «Моя волна» ---
|
// --- «Моя волна» ---
|
||||||
|
|
||||||
async startWave(activity: string | null, mood: string | null) {
|
async startWave(activity: string | null, mood: string | null) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
await this.setup();
|
await this.setup();
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
|
const version = this.wavePolicy.version;
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
// Нет связи с сервером — офлайн-волна из скачанного.
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||||
const local = offlineWave(downloads.downloaded());
|
const local = offlineWave(downloads.downloaded());
|
||||||
if (!local.length) throw e;
|
if (!local.length) throw e;
|
||||||
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||||
}
|
}
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
const tracks = res.tracks.filter(playable);
|
const tracks = res.tracks.filter(playable);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
this.finishAttempt(this.lastPositionMs / 1000);
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
this.wavePolicy.activate(res.session_id);
|
||||||
this.update({
|
this.update({
|
||||||
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
||||||
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
||||||
@@ -327,15 +375,19 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setWaveContext(activity: string | null, mood: string | null) {
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
|
if (this.listening?.locked) return;
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||||
|
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
params: { path: { id: wave.sessionId } },
|
params: { path: { id: wave.sessionId } },
|
||||||
body: { activity, mood },
|
body: { activity, mood },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.replaceUpcoming(res.tracks);
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
if (res.tracks.some(playable)) {
|
if (res.tracks.some(playable)) {
|
||||||
@@ -348,8 +400,8 @@ class Controller {
|
|||||||
|
|
||||||
private leaveWave() {
|
private leaveWave() {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
if (!wave) return;
|
if (!wave) return;
|
||||||
this.waveFeedback = [];
|
|
||||||
this.update({ wave: null });
|
this.update({ wave: null });
|
||||||
void this.session?.client
|
void this.session?.client
|
||||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
@@ -357,46 +409,36 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||||
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
this.wavePolicy.note(trackId, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
||||||
private async replaceUpcoming(tracks: Track[]) {
|
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
|
||||||
const keep = this.state.queue.slice(0, this.state.index + 1);
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||||
await TrackPlayer.removeUpcomingTracks();
|
|
||||||
const add = tracks.filter(playable);
|
const add = tracks.filter(playable);
|
||||||
await TrackPlayer.add(await this.toRntp(add));
|
const native = await this.toRntp(add);
|
||||||
this.update({ queue: [...keep, ...add] });
|
if (!current()) return;
|
||||||
|
await TrackPlayer.removeUpcomingTracks();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (current()) this.update({ queue: [...keep, ...add] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refillWave(refresh = false) {
|
private async refillWave(refresh = false) {
|
||||||
const wave = this.state.wave;
|
if (!this.session) return;
|
||||||
if (!wave || !this.session || this.waveLoading) return;
|
|
||||||
const remaining = this.state.queue.length - this.state.index - 1;
|
const remaining = this.state.queue.length - this.state.index - 1;
|
||||||
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||||
this.waveLoading = true;
|
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||||
const feedback = this.waveFeedback;
|
|
||||||
this.waveFeedback = [];
|
|
||||||
try {
|
|
||||||
const res = unwrap(
|
|
||||||
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
|
||||||
params: { path: { id: wave.sessionId } },
|
|
||||||
body: { feedback },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (this.state.wave?.sessionId !== wave.sessionId) return;
|
|
||||||
if (refresh) await this.replaceUpcoming(res.tracks);
|
|
||||||
else {
|
else {
|
||||||
const add = res.tracks.filter(playable);
|
const add = res.tracks.filter(playable);
|
||||||
await TrackPlayer.add(await this.toRntp(add));
|
const native = await this.toRntp(add);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (!current()) return;
|
||||||
this.update({ queue: [...this.state.queue, ...add] });
|
this.update({ queue: [...this.state.queue, ...add] });
|
||||||
}
|
}
|
||||||
this.update({ wave: { ...wave, fallback: res.fallback } });
|
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||||
} catch {
|
});
|
||||||
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
|
||||||
} finally {
|
|
||||||
this.waveLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- состояние ---
|
// --- состояние ---
|
||||||
@@ -421,18 +463,28 @@ class Controller {
|
|||||||
|
|
||||||
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||||
async restore() {
|
async restore() {
|
||||||
if (!this.session || this.state.track) return;
|
if (!this.session || this.state.track || this.listening?.locked) return;
|
||||||
|
const session = this.session;
|
||||||
|
const initial = this.state;
|
||||||
|
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
|
||||||
await this.setup();
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
try {
|
try {
|
||||||
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
|
||||||
|
if (!current()) return;
|
||||||
if (!st.track_id || !st.queue.length) return;
|
if (!st.track_id || !st.queue.length) return;
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||||
|
if (!current()) return;
|
||||||
const tracks = res.items.filter(playable);
|
const tracks = res.items.filter(playable);
|
||||||
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
|
const native = await this.toRntp(tracks);
|
||||||
|
if (!current()) return;
|
||||||
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||||
await TrackPlayer.reset();
|
await TrackPlayer.reset();
|
||||||
await TrackPlayer.add(await this.toRntp(tracks));
|
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||||
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||||
} catch {
|
} catch {
|
||||||
// офлайн или нет сохранённого состояния
|
// офлайн или нет сохранённого состояния
|
||||||
@@ -440,6 +492,8 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
|
this.queueVersion++;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
this.attempt = null;
|
this.attempt = null;
|
||||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||||
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||||
@@ -449,6 +503,35 @@ class Controller {
|
|||||||
this.save();
|
this.save();
|
||||||
void this.events?.flush();
|
void this.events?.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
|
||||||
|
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
|
||||||
|
setInterrupted(paused: boolean) { this.interrupted = paused; }
|
||||||
|
beginListeningAudio() { this.listening?.beginAudio(); }
|
||||||
|
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||||
|
if (!room.track_id) { await TrackPlayer.pause(); return; }
|
||||||
|
await this.setup();
|
||||||
|
if (!current()) return;
|
||||||
|
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||||
|
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||||
|
const native = await this.toRntp([track]);
|
||||||
|
if (!current()) return;
|
||||||
|
this.remoteRoom = room.id;
|
||||||
|
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
await TrackPlayer.pause();
|
||||||
|
if (!current()) return;
|
||||||
|
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
|
||||||
|
await TrackPlayer.reset();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
}
|
||||||
|
if (!current()) return;
|
||||||
|
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
|
||||||
|
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
|
||||||
|
if (!current()) { await TrackPlayer.pause(); return; }
|
||||||
|
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
|
||||||
|
else if (this.state.playing) await TrackPlayer.pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const player = new Controller();
|
export const player = new Controller();
|
||||||
@@ -7,14 +7,15 @@ import { player } from './controller';
|
|||||||
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||||
*/
|
*/
|
||||||
export async function PlaybackService(): Promise<void> {
|
export async function PlaybackService(): Promise<void> {
|
||||||
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
|
||||||
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
|
||||||
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
|
||||||
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||||
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||||
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||||
|
player.setInterrupted(paused || permanent);
|
||||||
if (permanent) void TrackPlayer.pause();
|
if (permanent) void TrackPlayer.pause();
|
||||||
else if (paused) void TrackPlayer.pause();
|
else if (paused) void TrackPlayer.pause();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FriendRecommendations } from '../features/social/Social';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
|
||||||
|
|
||||||
|
type Options = components['schemas']['Onboarding'];
|
||||||
|
type Choice = components['schemas']['OnboardingChoice'];
|
||||||
|
|
||||||
|
function useOnboarding() {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
return useQuery({ queryKey: ['onboarding', server, user.id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
|
staleTime: 0, refetchInterval: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
|
||||||
|
if (!query.data?.required) return <>{children}</>;
|
||||||
|
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
|
||||||
|
</ScrollView></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const errorText = useErrorText();
|
||||||
|
return <View>
|
||||||
|
<FriendRecommendations />
|
||||||
|
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||||
|
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||||
|
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||||
|
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
|
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
|
||||||
|
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
|
||||||
|
})), onSuccess: onDone });
|
||||||
|
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
|
||||||
|
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
|
||||||
|
}));
|
||||||
|
return <View style={{ gap: 20 }}>
|
||||||
|
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
|
||||||
|
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
|
||||||
|
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
|
||||||
|
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
|
||||||
|
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
|
||||||
|
<Muted>{t('onboarding.age_hint')}</Muted>
|
||||||
|
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
|
||||||
|
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
|
||||||
|
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
|
||||||
|
const on = selected.includes(item.value);
|
||||||
|
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
|
||||||
|
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
|
||||||
|
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
|
||||||
|
</Pressable>;
|
||||||
|
})}</View>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useSyncExternalStore } from 'react';
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { releaseNotesText } from '@pipu/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
@@ -10,7 +11,6 @@ import { Button } from './kit';
|
|||||||
export function useUpdateState() {
|
export function useUpdateState() {
|
||||||
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||||
export function UpdateDialog() {
|
export function UpdateDialog() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@@ -32,6 +32,7 @@ export function UpdateDialog() {
|
|||||||
|
|
||||||
const { release, required } = st;
|
const { release, required } = st;
|
||||||
const busy = st.status === 'downloading' || st.status === 'verifying';
|
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||||
|
const notes = releaseNotesText(release.notes_md);
|
||||||
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +58,9 @@ export function UpdateDialog() {
|
|||||||
{t('mobile.update_title', { version: release.version })}
|
{t('mobile.update_title', { version: release.version })}
|
||||||
</Text>
|
</Text>
|
||||||
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||||
{release.notes_md.trim() !== '' && (
|
{notes !== '' && (
|
||||||
<ScrollView style={{ maxHeight: 240 }}>
|
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
|
||||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||||
@@ -115,13 +116,3 @@ export function UpdateDialog() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
|
||||||
function plainNotes(md: string): string {
|
|
||||||
return md
|
|
||||||
.replace(/^#{1,6}\s*/gm, '')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
||||||
.replace(/^\s*[-*]\s+/gm, '• ')
|
|
||||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useSessionCtx } from '../lib/session';
|
import { useSessionCtx } from '../lib/session';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { absolute } from '../player/controller';
|
import { absolute } from '../player/controller';
|
||||||
import { radius, useColors, type Colors } from '../theme';
|
import { radius, useColors, type Colors } from '../theme';
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ export function Cover({
|
|||||||
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||||
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||||
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||||
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
|
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
|
||||||
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||||
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { AppState, Pressable, View } from 'react-native';
|
import { AppState, Pressable, View } from 'react-native';
|
||||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
|
|||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||||
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||||
|
|
||||||
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { AuthSession, Track } from '@pipu/core';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@react-native-community/netinfo', () => ({ default: {
|
||||||
|
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
|
||||||
|
} }));
|
||||||
|
vi.mock('../src/lib/prefs', () => ({
|
||||||
|
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
|
||||||
|
playerCache: { clearIfRequested: async () => {} },
|
||||||
|
}));
|
||||||
|
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
|
||||||
|
vi.mock('../src/offline/downloads', () => ({
|
||||||
|
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
|
||||||
|
sizedCover: (s: string) => s,
|
||||||
|
}));
|
||||||
|
vi.mock('react-native-track-player', () => ({
|
||||||
|
default: {
|
||||||
|
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
|
||||||
|
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
|
||||||
|
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
|
||||||
|
skip: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function track(id: string): Track {
|
||||||
|
return { id, title: id, duration_ms: 1000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
|
||||||
|
}
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (v: T) => void;
|
||||||
|
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
|
||||||
|
|
||||||
|
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('ignores a wave creation response after the player is reset', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const native = (await import('react-native-track-player')).default;
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const post = vi.fn().mockReturnValue(pending.promise);
|
||||||
|
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
const start = player.startWave(null, null);
|
||||||
|
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
await player.reset();
|
||||||
|
pending.resolve(reply('old'));
|
||||||
|
await start;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue).toEqual([]);
|
||||||
|
expect(native.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a late context response replace a manually selected playlist', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const session = { client: {
|
||||||
|
POST: vi.fn().mockResolvedValue(reply('wave')),
|
||||||
|
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
|
||||||
|
} };
|
||||||
|
player.attach(session as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
await player.startWave(null, null);
|
||||||
|
const change = player.setWaveContext('focus', null);
|
||||||
|
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
|
||||||
|
pending.resolve(reply('stale'));
|
||||||
|
await change;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||||
|
expect(player.getState().context?.type).toBe('album');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore a stale saved queue after manual playback starts', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const saved = deferred<unknown>();
|
||||||
|
const get = vi.fn().mockReturnValue(saved.promise);
|
||||||
|
const post = vi.fn();
|
||||||
|
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
const restoring = player.restore();
|
||||||
|
await vi.waitFor(() => expect(get).toHaveBeenCalled());
|
||||||
|
await player.playList([track('chosen')], 0, { type: 'queue' });
|
||||||
|
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
|
||||||
|
await restoring;
|
||||||
|
expect(player.getState().track?.id).toBe('chosen');
|
||||||
|
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Web и desktop UI
|
||||||
|
|
||||||
|
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
|
||||||
|
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
|
||||||
|
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
|
||||||
|
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
|
||||||
|
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
|
||||||
|
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
const saved = await page.evaluate(async () => {
|
||||||
|
const source = '/src/features/desktop/offline.ts';
|
||||||
|
const { DesktopOffline } = await import(source);
|
||||||
|
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
|
||||||
|
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
|
||||||
|
const offline = new DesktopOffline('test-server/alice', session);
|
||||||
|
await offline.ready;
|
||||||
|
await offline.save(track, true);
|
||||||
|
await offline.save({ ...track, id: 'recent-track' });
|
||||||
|
const tracks = offline.tracks();
|
||||||
|
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
|
||||||
|
offline.dispose();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
|
||||||
|
await page.reload();
|
||||||
|
const restored = await page.evaluate(async () => {
|
||||||
|
const source = '/src/features/desktop/offline.ts';
|
||||||
|
const { DesktopOffline } = await import(source);
|
||||||
|
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
|
||||||
|
const alice = new DesktopOffline('test-server/alice', session);
|
||||||
|
const bob = new DesktopOffline('test-server/bob', session);
|
||||||
|
await Promise.all([alice.ready, bob.ready]);
|
||||||
|
const before = alice.tracks().length;
|
||||||
|
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
|
||||||
|
await alice.clearRecent();
|
||||||
|
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
|
||||||
|
alice.dispose(); bob.dispose();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('pipu.locale', 'ru');
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
|
||||||
|
core: { invoke: async (command: string) => {
|
||||||
|
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
|
||||||
|
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
|
||||||
|
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
|
||||||
|
return null;
|
||||||
|
} },
|
||||||
|
event: { listen: async () => () => undefined },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Логин').fill('alice');
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||||
|
await page.getByRole('button', { name: 'Войти' }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toContainText('Доступна версия 1.8.0');
|
||||||
|
await expect(dialog).toContainText('• Офлайн-музыка');
|
||||||
|
await expect(dialog).not.toContainText('android-status');
|
||||||
|
await expect(dialog).not.toContainText('SHA-256');
|
||||||
|
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
|
||||||
|
await expect(dialog).toContainText('Установщик открыт');
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
|
||||||
|
});
|
||||||
@@ -92,6 +92,31 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const width of [390, 1280]) {
|
||||||
|
test(`player artist opens without a browser URL preview at ${width}px`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await login(page, 'alice');
|
||||||
|
await page.setViewportSize({ width, height: 844 });
|
||||||
|
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
|
||||||
|
await box.fill('кино');
|
||||||
|
await box.press('Enter');
|
||||||
|
await page.getByRole('link', { name: /Группа крови/ }).first().click();
|
||||||
|
await page.getByRole('listitem').first().click();
|
||||||
|
const artist = page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('link', { name: 'Кино', exact: true });
|
||||||
|
await artist.hover();
|
||||||
|
await expect(artist).not.toHaveAttribute('href');
|
||||||
|
expect(await artist.evaluate((el) => Boolean(el.closest('[title], a[href]')))).toBe(false);
|
||||||
|
if (width === 390) {
|
||||||
|
await artist.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
} else {
|
||||||
|
await artist.click();
|
||||||
|
}
|
||||||
|
await expect(page).toHaveURL(/\/artist\//);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Кино', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
|
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
|
||||||
await page.setViewportSize({ width: 320, height: 740 });
|
await page.setViewportSize({ width: 320, height: 740 });
|
||||||
await login(page, 'alice');
|
await login(page, 'alice');
|
||||||
@@ -102,7 +127,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
|
|||||||
await page.getByRole('listitem').first().click();
|
await page.getByRole('listitem').first().click();
|
||||||
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
|
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
|
||||||
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
|
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
|
||||||
|
const artist = player.getByRole('link', { name: 'Кино', exact: true });
|
||||||
|
await expect(artist).toBeVisible();
|
||||||
|
const artistArea = await artist.locator('..').boundingBox();
|
||||||
|
expect(artistArea!.width).toBeGreaterThan(220);
|
||||||
|
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
|
||||||
|
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
|
||||||
|
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
|
||||||
|
await page.screenshot({ path: info.outputPath('player-readable.png') });
|
||||||
await fits(page);
|
await fits(page);
|
||||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
for (const width of [320, 1280]) {
|
for (const width of [320, 1280]) {
|
||||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||||
@@ -10,14 +11,19 @@ for (const width of [320, 1280]) {
|
|||||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
const playlistsHeader = page.locator('header').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
|
||||||
|
});
|
||||||
|
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||||
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||||
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||||
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
await page.getByRole('listitem').first().click();
|
||||||
|
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
|
||||||
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
@@ -25,10 +31,28 @@ for (const width of [320, 1280]) {
|
|||||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||||
|
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
const shared = new URL(sharedUrl);
|
||||||
|
expect(shared.origin).toBe(new URL(page.url()).origin);
|
||||||
|
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate(() => {
|
await expect.poll(() => page.evaluate(() => {
|
||||||
const main = document.querySelector('main')!;
|
const main = document.querySelector('main')!;
|
||||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
})).toBeLessThanOrEqual(1);
|
})).toBeLessThanOrEqual(1);
|
||||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||||
|
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
|
|||||||
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
||||||
await slider.fill('4');
|
await slider.fill('4');
|
||||||
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '13–19', exact: true }).click();
|
||||||
|
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
|
||||||
|
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
});
|
});
|
||||||
test('9. login page switches between Russian and English', async ({ page }) => {
|
test('9. login page switches between Russian and English', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||||
|
await page.getByLabel('Логин').fill(username);
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
|
||||||
|
await page.getByRole('button', { name: 'Войти' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('job log follows new output and pauses while reading older lines', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let lines = 100;
|
||||||
|
window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent<number>).detail; });
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const response = await original(input, init);
|
||||||
|
const request = new Request(input, init);
|
||||||
|
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') {
|
||||||
|
const data = await response.json();
|
||||||
|
data.job = {
|
||||||
|
id: 'scroll-job', kind: 'update', state: 'running', target: 'main',
|
||||||
|
started_at: '2026-10-22T09:00:00Z', finished_at: '',
|
||||||
|
log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'),
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
history.pushState({}, '', '/admin/updates');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
const log = page.getByLabel('Журнал выполнения');
|
||||||
|
const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
|
||||||
|
await expect(log).toContainText('Build line 100');
|
||||||
|
expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
|
||||||
|
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 })));
|
||||||
|
await expect(log).toContainText('Build line 120');
|
||||||
|
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
await log.evaluate((el) => { el.scrollTop = 100; });
|
||||||
|
await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100);
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 })));
|
||||||
|
await expect(log).toContainText('Build line 140');
|
||||||
|
expect(await log.evaluate((el) => el.scrollTop)).toBe(100);
|
||||||
|
|
||||||
|
await log.evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||||
|
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 })));
|
||||||
|
await expect(log).toContainText('Build line 160');
|
||||||
|
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/updates');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Добавить источник' }).click();
|
||||||
|
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
|
||||||
|
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
|
||||||
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
|
||||||
|
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||||
|
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||||
|
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toContainText('Тестовая репа');
|
||||||
|
await expect(dialog).toContainText('v1.10.0');
|
||||||
|
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
|
||||||
|
await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/config');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
|
||||||
|
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
|
||||||
|
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
|
||||||
|
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
|
||||||
|
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
|
||||||
|
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
|
||||||
|
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||||
|
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
|
||||||
|
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
|
||||||
|
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
|
||||||
|
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update delegate can update but cannot open configuration', async ({ page }) => {
|
||||||
|
await login(page, 'update-manager', '/admin/updates');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||||
|
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const response = await original(input, init);
|
||||||
|
const request = new Request(input, init);
|
||||||
|
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') {
|
||||||
|
const data = await response.json();
|
||||||
|
const common = { group: 'api', type: 'string', secret: true, readonly: false };
|
||||||
|
data.entries = [
|
||||||
|
{ ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' },
|
||||||
|
{ ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' },
|
||||||
|
{ ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' },
|
||||||
|
];
|
||||||
|
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
history.pushState({}, '', '/admin/config');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
const env = page.getByLabel('ENV_SECRET · config.local', { exact: true });
|
||||||
|
const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true });
|
||||||
|
await expect(env).toHaveValue('');
|
||||||
|
await expect(env).toHaveAttribute('type', 'password');
|
||||||
|
await expect(defaults).toHaveValue('');
|
||||||
|
await expect(defaults).toHaveAttribute('placeholder', '');
|
||||||
|
await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible();
|
||||||
|
for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) {
|
||||||
|
expect(await page.getByRole('main').innerHTML()).not.toContain(canary);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click();
|
||||||
|
await expect(env).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/config');
|
||||||
|
const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true });
|
||||||
|
await token.fill('replacement-canary');
|
||||||
|
await expect(token).toHaveAttribute('type', 'password');
|
||||||
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||||
|
await expect(token).toHaveValue('');
|
||||||
|
await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||||
|
expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary user is redirected away from server management', async ({ page }) => {
|
||||||
|
await login(page, 'alice', '/admin/updates');
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/updates');
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'main', exact: true }).click();
|
||||||
|
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||||
|
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
|
||||||
|
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
|
||||||
|
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||||
|
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||||
|
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/updates');
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const request = new Request(input, init);
|
||||||
|
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
const response = await original(input, init);
|
||||||
|
const data = await response.json();
|
||||||
|
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
|
||||||
|
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||||
|
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
|
||||||
|
await login(page, 'mobile-admin', '/admin/config');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||||
|
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
|
||||||
|
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
|
||||||
|
await expect(save).not.toBeVisible();
|
||||||
|
await expect(apply).not.toBeVisible();
|
||||||
|
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
|
||||||
|
await field.fill('6');
|
||||||
|
await expect(save).toBeVisible();
|
||||||
|
await field.clear();
|
||||||
|
await expect(save).not.toBeVisible();
|
||||||
|
await expect(apply).not.toBeVisible();
|
||||||
|
await field.fill('7');
|
||||||
|
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
|
||||||
|
await expect(save).not.toBeVisible();
|
||||||
|
await expect(field).toHaveValue('');
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const width of [320, 800, 1280]) {
|
||||||
|
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
const openFriends = async () => {
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
|
||||||
|
};
|
||||||
|
await openFriends();
|
||||||
|
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||||
|
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
|
||||||
|
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
|
||||||
|
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
|
||||||
|
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
|
||||||
|
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||||
|
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
|
||||||
|
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
|
||||||
|
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||||
|
await page.getByRole('listitem').first().click();
|
||||||
|
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
|
||||||
|
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
|
||||||
|
if (width === 800) {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
|
||||||
|
}
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
|
||||||
|
await page.screenshot({ path: info.outputPath('share-music.png') });
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||||
|
await openFriends();
|
||||||
|
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
||||||
|
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
|
||||||
|
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
|
||||||
|
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
|
||||||
|
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
|
||||||
|
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
|
||||||
|
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
|
||||||
|
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
|
||||||
|
if (width === 320) {
|
||||||
|
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
|
||||||
|
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
|
||||||
|
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
||||||
|
await page.setViewportSize({ width, height: 560 });
|
||||||
|
}
|
||||||
|
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
||||||
|
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
||||||
|
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y);
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main')!;
|
||||||
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
|
})).toBeLessThanOrEqual(1);
|
||||||
|
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||||
|
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [320, 1280]) {
|
||||||
|
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await login(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const original = window.fetch;
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const req = new Request(input, init);
|
||||||
|
const path = new URL(req.url).pathname;
|
||||||
|
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
|
||||||
|
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (path === '/api/v1/admin/users/import') {
|
||||||
|
const archive = await req.json();
|
||||||
|
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
|
||||||
|
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.suggestedFilename()).toBe('pipu-users.json');
|
||||||
|
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
|
||||||
|
const file = dialog.getByLabel('Файл пользователей (.json)');
|
||||||
|
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
|
||||||
|
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
|
||||||
|
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
|
||||||
|
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
|
||||||
|
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
|
||||||
|
})) });
|
||||||
|
await expect(dialog).toContainText('Учётных записей в файле: 1');
|
||||||
|
await page.screenshot({ path: info.outputPath('user-import.png') });
|
||||||
|
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
|
||||||
|
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
|
||||||
|
await expect(dialog.getByRole('status')).toContainText('alice');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
if (width < 768) {
|
||||||
|
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
|
||||||
|
} else await page.evaluate(() => {
|
||||||
|
history.pushState({}, '', '/admin/sources');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
|
||||||
|
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
|
||||||
|
await page.screenshot({ path: info.outputPath('source-covers.png') });
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('moderator cannot see user transfer controls', async ({ page }) => {
|
||||||
|
await login(page, 'moderator');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||||
<title>pipu</title>
|
<title>pipu</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
|
||||||
|
<stop stop-color="#9CC6FF"/>
|
||||||
|
<stop offset=".42" stop-color="#2F80FF"/>
|
||||||
|
<stop offset=".78" stop-color="#6354E8"/>
|
||||||
|
<stop offset="1" stop-color="#3A1FA8"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
|
||||||
|
<stop stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#DFEBFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
|
||||||
|
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 840 B |
@@ -1,4 +1,4 @@
|
|||||||
import { AuthSession } from '@pipu/core';
|
import { AuthSession, refreshListening } from '@pipu/core';
|
||||||
import { createI18n } from '@pipu/i18n';
|
import { createI18n } from '@pipu/i18n';
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
import { createMemoryHistory } from '@tanstack/react-router';
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
@@ -10,10 +10,188 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter, safeRedirect } from './app/router';
|
import { createAppRouter, safeRedirect } from './app/router';
|
||||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('social', () => {
|
||||||
|
it('enables listening together only while a friend is online, independently of music privacy', async () => {
|
||||||
|
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||||
|
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
const conversation = await screen.findByRole('region', { name: 'Написать' });
|
||||||
|
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||||
|
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||||
|
api.presence.set(bob.id, Date.now());
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await within(conversation).findByText('В сети');
|
||||||
|
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
|
||||||
|
expect(api.activity.has(bob.id)).toBe(false);
|
||||||
|
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
|
||||||
|
await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||||
|
api.presence.set(bob.id, Date.now() - 46_000);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||||
|
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||||
|
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds music beyond the last text page without acknowledging unread messages', async () => {
|
||||||
|
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||||
|
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||||
|
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
|
||||||
|
await screen.findByRole('link', { name: 'Открыть трек' });
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||||
|
const firstCall = api.calls.length;
|
||||||
|
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await screen.findByText('Новая музыка');
|
||||||
|
expect(api.messages.at(-1)?.read).toBe(false);
|
||||||
|
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
|
||||||
|
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
|
||||||
|
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
|
||||||
|
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await screen.findByRole('heading', { name: 'bob' });
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
|
||||||
|
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await screen.findByText('Новое сообщение от carol');
|
||||||
|
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
|
||||||
|
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
|
||||||
|
expect(api.friendships.size).toBe(2);
|
||||||
|
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
|
||||||
|
expect(api.friendships.size).toBe(2);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
|
||||||
|
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
|
||||||
|
await waitFor(() => expect(api.friendships.size).toBe(1));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a room with locked playback controls, independent volume and local exit', async () => {
|
||||||
|
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||||
|
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||||
|
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
|
||||||
|
refreshListening(session);
|
||||||
|
await screen.findByText('Слушаете вместе с bob');
|
||||||
|
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
|
||||||
|
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
|
||||||
|
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||||
|
});
|
||||||
|
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
|
||||||
|
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||||
|
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
|
||||||
|
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
|
||||||
|
await user.type(composer(), 'Первый вариант');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||||
|
await screen.findByText('Первый вариант');
|
||||||
|
const actions = async (name: string) => {
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
|
||||||
|
await user.click(await screen.findByRole('menuitem', { name }));
|
||||||
|
};
|
||||||
|
await actions('Редактировать');
|
||||||
|
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByText('Исправленный текст', { exact: true });
|
||||||
|
await actions('❤️');
|
||||||
|
await screen.findByRole('button', { name: '❤️ 1' });
|
||||||
|
await actions('Ответить');
|
||||||
|
await user.type(composer(), 'Ответ на музыку');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||||
|
await waitFor(() => expect(api.messages).toHaveLength(2));
|
||||||
|
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
|
||||||
|
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
|
||||||
|
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
|
||||||
|
await actions('Удалить для всех');
|
||||||
|
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
|
||||||
|
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
|
||||||
|
await user.type(composer(), 'Мой черновик');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Друзья' }));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Написать' }));
|
||||||
|
expect(composer()).toHaveValue('Мой черновик');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves privacy and independent request admission preferences', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' }));
|
||||||
|
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
|
||||||
|
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||||
|
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
|
||||||
|
await user.click(screen.getByText('Слушать вместе', { exact: true }));
|
||||||
|
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
|
||||||
|
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
|
||||||
|
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
|
||||||
|
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
|
||||||
|
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
|
||||||
|
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
|
||||||
|
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
|
||||||
|
});
|
||||||
|
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const bob = makeUser('bob', 'user');
|
||||||
|
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Друзья и общение' });
|
||||||
|
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Уведомления 1' }));
|
||||||
|
await screen.findByText('Хочет добавить вас в друзья');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
|
||||||
|
expect(api.notifications[0]?.read).toBe(true);
|
||||||
|
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
|
||||||
|
});
|
||||||
|
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
|
||||||
|
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
|
||||||
|
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
|
||||||
|
await screen.findByRole('button', { name: 'Отменить заявку' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
|
||||||
|
await screen.findByRole('button', { name: 'В друзья' });
|
||||||
|
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Принять' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Написать' }));
|
||||||
|
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||||
|
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
|
||||||
|
expect(api.messages).toHaveLength(1);
|
||||||
|
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
|
||||||
|
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
|
||||||
|
await screen.findByRole('button', { name: 'Борис · Отправлено' });
|
||||||
|
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
|
||||||
|
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||||
const api = fakeApi(users);
|
const api = fakeApi(users);
|
||||||
const i18n = await createI18n('ru');
|
const i18n = await createI18n('ru');
|
||||||
@@ -27,7 +205,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
|
|||||||
<App session={session} router={router} queryClient={queryClient} />
|
<App session={session} router={router} queryClient={queryClient} />
|
||||||
</I18nextProvider>,
|
</I18nextProvider>,
|
||||||
);
|
);
|
||||||
return { api, router, session, i18n, user: userEvent.setup() };
|
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('sign-in', () => {
|
describe('sign-in', () => {
|
||||||
@@ -291,10 +469,11 @@ describe('player', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||||
expect(router.state.location.pathname).toBe(path);
|
expect(router.state.location.pathname).toBe(path);
|
||||||
await user.click(screen.getByRole('listitem'));
|
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
|
||||||
|
await screen.findByRole('button', { name: 'Ссылка скопирована' });
|
||||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,6 +588,26 @@ describe('safeRedirect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('playlists', () => {
|
describe('playlists', () => {
|
||||||
|
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('listitem')[0]!);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Из плеера');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(api.playlists.size).toBe(1));
|
||||||
|
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
// Simulate automatic advancement while the picker is open.
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
|
||||||
|
});
|
||||||
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
@@ -459,3 +658,84 @@ describe('playlists', () => {
|
|||||||
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('recommendation preferences', () => {
|
||||||
|
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
|
||||||
|
const admin = makeUser('owner', 'admin');
|
||||||
|
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '13–19' }));
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||||
|
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||||
|
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
|
||||||
|
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await user.click(screen.getByRole('button', { name: '20–29' }));
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||||
|
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||||
|
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
|
||||||
|
await router.navigate({ to: '/' });
|
||||||
|
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
|
||||||
|
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
|
||||||
|
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||||
|
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('playlist sharing', () => {
|
||||||
|
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
|
||||||
|
const playlist = created.data!;
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||||
|
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
|
||||||
|
await screen.findByRole('button', { name: 'Ссылка скопирована' });
|
||||||
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
|
||||||
|
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
|
||||||
|
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
|
||||||
|
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
|
||||||
|
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||||
|
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||||
|
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
|
||||||
|
await screen.findByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
|
||||||
|
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
|
||||||
|
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||||
|
await screen.findByRole('alert');
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the shared playlist after login without starting playback', async () => {
|
||||||
|
const bob = makeUser('bob', 'user');
|
||||||
|
const shareId = '00000000-0000-4000-9001-000000000001';
|
||||||
|
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
|
||||||
|
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
await user.type(screen.getByLabelText('Логин'), bob.username);
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), bob.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'От друга' });
|
||||||
|
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
|
||||||
|
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
|
||||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||||
@@ -24,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
|
|||||||
import { TrackPage } from '../pages/TrackPage';
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
@@ -33,6 +35,8 @@ import { SettingsPage } from '../pages/SettingsPage';
|
|||||||
import { SetupPage } from '../pages/SetupPage';
|
import { SetupPage } from '../pages/SetupPage';
|
||||||
import { AppShell } from '../widgets/AppShell';
|
import { AppShell } from '../widgets/AppShell';
|
||||||
import { RouteError } from '../widgets/RouteError';
|
import { RouteError } from '../widgets/RouteError';
|
||||||
|
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
|
||||||
|
import { SocialPage } from '../features/social/Social';
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
@@ -144,8 +148,14 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||||
|
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
|
||||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||||
|
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||||
|
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
|
||||||
|
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
|
||||||
|
});
|
||||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||||
|
|
||||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
@@ -269,6 +279,14 @@ const adminSettingsRoute = createRoute({
|
|||||||
component: AdminSettingsPage,
|
component: AdminSettingsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const serverUpdatesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
downloadAppRoute,
|
downloadAppRoute,
|
||||||
androidAppRoute,
|
androidAppRoute,
|
||||||
@@ -278,8 +296,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
appRoute.addChildren([
|
appRoute.addChildren([
|
||||||
homeRoute,
|
homeRoute,
|
||||||
|
activityPlaylistRoute,
|
||||||
|
socialRoute,
|
||||||
trackRoute,
|
trackRoute,
|
||||||
playlistsRoute,
|
playlistsRoute,
|
||||||
|
downloadsRoute,
|
||||||
|
sharedPlaylistRoute,
|
||||||
playlistRoute,
|
playlistRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
@@ -297,6 +319,8 @@ const routeTree = rootRoute.addChildren([
|
|||||||
myUploadsRoute,
|
myUploadsRoute,
|
||||||
uploadQueueRoute,
|
uploadQueueRoute,
|
||||||
adminSettingsRoute,
|
adminSettingsRoute,
|
||||||
|
serverUpdatesRoute,
|
||||||
|
serverConfigRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthSession, type AuthUser } from '@pipu/core';
|
||||||
|
|
||||||
import { createDesktop, isDesktop } from './desktop';
|
import { createDesktop, desktopFetch, isDesktop } from './desktop';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('desktop bridge', () => {
|
describe('desktop bridge', () => {
|
||||||
|
it('restores the last desktop user when the proxy cannot reach the server', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||||
|
const user = { id: 'alice', username: 'alice' } as AuthUser;
|
||||||
|
const clear = vi.fn();
|
||||||
|
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
|
||||||
|
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
|
||||||
|
} });
|
||||||
|
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
|
||||||
|
expect(clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||||
|
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
|
||||||
|
expect((await desktopFetch('http://local/api')).status).toBe(401);
|
||||||
|
});
|
||||||
it('is inactive in the browser', () => {
|
it('is inactive in the browser', () => {
|
||||||
expect(isDesktop()).toBe(false);
|
expect(isDesktop()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
|
|||||||
return api() !== undefined;
|
return api() !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
/** A failed loopback hop must behave like a network failure to offline auth restore. */
|
||||||
|
export const desktopFetch: typeof fetch = async (input, init) => {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
const tauri = api();
|
const tauri = api();
|
||||||
if (!tauri) throw new Error('Tauri API unavailable');
|
if (!tauri) throw new Error('Tauri API unavailable');
|
||||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
const invoke = desktopInvoke;
|
||||||
|
|
||||||
export type DesktopHandle = {
|
export type DesktopHandle = {
|
||||||
proxyBase: string;
|
proxyBase: string;
|
||||||
|
|||||||
@@ -8,12 +8,18 @@
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover img {
|
.coverImg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverImgOn {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round {
|
.round {
|
||||||
@@ -59,10 +65,52 @@
|
|||||||
width: 128px;
|
width: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardCoverWrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.cardCover {
|
.cardCover {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 4px;
|
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardCover,
|
||||||
|
.card:focus-within .cardCover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #05070d;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardPlay,
|
||||||
|
.card:focus-within .cardPlay,
|
||||||
|
.cardPlay:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay:active {
|
||||||
|
transform: scale(0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
@@ -104,6 +152,12 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
animation: pipuIn 0.2s ease-out both;
|
||||||
|
animation-delay: var(--stagger, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackStatic {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track:has(.trackAlbum) {
|
.track:has(.trackAlbum) {
|
||||||
@@ -272,6 +326,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.cardPlay { opacity: 1; transform: none; }
|
||||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Fragment, useRef, type ReactNode } from 'react';
|
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||||
import { AddToPlaylist } from './playlists';
|
import { AddToPlaylist } from './playlists';
|
||||||
|
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import type { PlayContext } from '../player/player';
|
import type { PlayContext } from '../player/player';
|
||||||
@@ -28,6 +31,7 @@ function fallbackColors(id: string): string[] {
|
|||||||
|
|
||||||
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||||
export function coverSrc(url: string, cssPx: number): string {
|
export function coverSrc(url: string, cssPx: number): string {
|
||||||
|
if (!url.includes('/api/v1/covers/')) return url;
|
||||||
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||||
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||||
return `${url}&size=${size}`;
|
return `${url}&size=${size}`;
|
||||||
@@ -58,7 +62,7 @@ export function Cover({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{album.cover_url ? (
|
{album.cover_url ? (
|
||||||
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
||||||
) : (
|
) : (
|
||||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||||
)}
|
)}
|
||||||
@@ -67,25 +71,58 @@ export function Cover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Исполнители через запятую, каждый — ссылка. */
|
/** Исполнители через запятую, каждый — ссылка. */
|
||||||
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{artists.map((a, i) => (
|
{artists.map((a, i) => (
|
||||||
<Fragment key={a.id}>
|
<Fragment key={a.id}>
|
||||||
{i > 0 && ', '}
|
{i > 0 && ', '}
|
||||||
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
{hideUrlPreview ? <button type="button" role="link" className={s.link}
|
||||||
|
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
|
||||||
{a.name}
|
{a.name}
|
||||||
</Link>
|
</button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||||
|
{a.name}
|
||||||
|
</Link>}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoverImg({ src }: { src: string }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className={cx(s.coverImg, ready && s.coverImgOn)}
|
||||||
|
onLoad={() => setReady(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const play = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void (async () => {
|
||||||
|
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
||||||
|
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
||||||
|
})();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||||
<Cover album={album} size="100%" className={s.cardCover} />
|
<span className={s.cardCoverWrap}>
|
||||||
|
<Cover album={album} size="100%" className={s.cardCover} />
|
||||||
|
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
||||||
|
<Icon src={playIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<span className={s.cardTitle}>{album.title}</span>
|
<span className={s.cardTitle}>{album.title}</span>
|
||||||
<span className={s.cardMeta}>
|
<span className={s.cardMeta}>
|
||||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||||
@@ -154,7 +191,8 @@ export function TrackList({
|
|||||||
role="listitem"
|
role="listitem"
|
||||||
tabIndex={tr.available ? 0 : -1}
|
tabIndex={tr.available ? 0 : -1}
|
||||||
aria-current={isCurrent || undefined}
|
aria-current={isCurrent || undefined}
|
||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
||||||
|
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||||
@@ -185,6 +223,7 @@ export function TrackList({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||||
|
{!actions && <SaveOfflineButton track={tr} compact />}
|
||||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||||
{disliked ? (
|
{disliked ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback, useState } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSession } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
import { queueIcon } from '../shared/ui/icons';
|
import { playlistAddIcon } from '../shared/ui/icons';
|
||||||
import s from './catalog.module.css';
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
export type Playlist = components['schemas']['Playlist'];
|
export type Playlist = components['schemas']['Playlist'];
|
||||||
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlaylist(id: string) {
|
export function usePlaylist(id: string, shared = false) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
return useQuery({ queryKey: ['playlists', id],
|
const user = useUser();
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
||||||
|
queryFn: async () => shared
|
||||||
|
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
||||||
|
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||||
@@ -61,7 +64,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const cache = useQueryClient();
|
const cache = useQueryClient();
|
||||||
@@ -84,14 +87,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
|
|||||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
});
|
||||||
|
|
||||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
||||||
|
const close = useCallback(() => setSelectedIds(null), []);
|
||||||
return <>
|
return <>
|
||||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
|
||||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
||||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { releaseNotesText, type Track } from '@pipu/core';
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthState, useSession } from '../../app/session';
|
||||||
|
import { desktopInvoke, useDesktop } from '../../desktop';
|
||||||
|
import { TrackList } from '../../entities/catalog';
|
||||||
|
import { usePlayer, usePlayerState } from '../../player/context';
|
||||||
|
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||||
|
import { formatBytes } from '../../shared/lib/format';
|
||||||
|
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
|
||||||
|
export function useOffline() {
|
||||||
|
const offline = usePlayer().offline;
|
||||||
|
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
|
||||||
|
return offline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
|
||||||
|
const offline = useOffline();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
if (!offline) return null;
|
||||||
|
return <span>
|
||||||
|
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
|
||||||
|
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
|
||||||
|
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
|
||||||
|
setBusy(true); setError(false);
|
||||||
|
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
|
||||||
|
.catch(() => setError(true)).finally(() => setBusy(false));
|
||||||
|
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
|
||||||
|
{error && <span role="alert">{t('desktop.download_failed')}</span>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopOfflineSection() {
|
||||||
|
const offline = useOffline();
|
||||||
|
const { track } = usePlayerState();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
|
||||||
|
if (!offline) return null;
|
||||||
|
const tracks = offline.tracks();
|
||||||
|
return <section className={s.section}>
|
||||||
|
<h2>{t('desktop.offline')}</h2>
|
||||||
|
<p className={s.note}>{t('desktop.cache_hint')}</p>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span>{formatBytes(offline.bytes, i18n.language)}</span>
|
||||||
|
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
|
||||||
|
{track && <SaveOfflineButton track={track} />}
|
||||||
|
</div>
|
||||||
|
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
|
||||||
|
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopDownloadsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopOfflineBanner() {
|
||||||
|
const state = useAuthState();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release = { build: { version: string; size: number }; notes: string };
|
||||||
|
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
|
||||||
|
let update: Update = { status: 'idle' };
|
||||||
|
let generation = 0;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
|
||||||
|
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
|
||||||
|
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
|
||||||
|
async function checkUpdate(server: string, manual = false) {
|
||||||
|
if (update.status === 'checking' || update.status === 'installing') return;
|
||||||
|
const key = `pipu.update.checked.${server}`;
|
||||||
|
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
|
||||||
|
setUpdate({ status: 'checking' });
|
||||||
|
const requestGeneration = generation;
|
||||||
|
try {
|
||||||
|
const release = await desktopInvoke<Release | null>('update_check');
|
||||||
|
if (requestGeneration !== generation) return;
|
||||||
|
localStorage.setItem(key, String(Date.now()));
|
||||||
|
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
|
||||||
|
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopUpdateButton() {
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const state = useUpdate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!desktop?.server) return null;
|
||||||
|
return <section className={s.section}>
|
||||||
|
<h2>{t('desktop.updates')}</h2>
|
||||||
|
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
|
||||||
|
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
|
||||||
|
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopUpdateDialog() {
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const session = useSession();
|
||||||
|
const state = useUpdate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!desktop?.server) return;
|
||||||
|
const check = () => void checkUpdate(desktop.server!);
|
||||||
|
check();
|
||||||
|
window.addEventListener('focus', check);
|
||||||
|
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
|
||||||
|
}, [desktop, session]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!desktop) return;
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
// The native updater emits progress through the same Tauri event surface as media controls.
|
||||||
|
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
|
||||||
|
void api?.event.listen('update-progress', (e) => {
|
||||||
|
const progress = Number(e.payload);
|
||||||
|
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
|
||||||
|
}).then((fn) => { if (disposed) fn(); else stop = fn; });
|
||||||
|
return () => { disposed = true; stop?.(); };
|
||||||
|
}, [desktop]);
|
||||||
|
if (!desktop || !state.release || state.dismissed) return null;
|
||||||
|
const release = state.release;
|
||||||
|
const busy = state.status === 'installing';
|
||||||
|
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
|
||||||
|
const notes = releaseNotesText(release.notes);
|
||||||
|
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
|
||||||
|
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
|
||||||
|
<Button busy={busy} onClick={() => {
|
||||||
|
setUpdate({ status: 'installing', release, progress: 0 });
|
||||||
|
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
|
||||||
|
}}>{t('mobile.update_install')}</Button>
|
||||||
|
</>}>
|
||||||
|
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
|
||||||
|
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
|
||||||
|
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
|
||||||
|
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
|
||||||
|
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthSession, PlayEvent } from '@pipu/core';
|
||||||
|
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
|
||||||
|
import { createDesktop } from '../../desktop';
|
||||||
|
import { albumTracks } from '../../test/fakeApi';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('desktop cache policy', () => {
|
||||||
|
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
|
||||||
|
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
|
||||||
|
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists offline events across sink restarts and deduplicates retries', async () => {
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
|
||||||
|
await createDesktop();
|
||||||
|
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
|
||||||
|
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
|
||||||
|
const owner = offlineOwner(session)!;
|
||||||
|
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
|
||||||
|
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
|
||||||
|
await desktopEventSink(session, owner)([event]);
|
||||||
|
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
|
||||||
|
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
|
||||||
|
import { desktopHandle } from '../../desktop';
|
||||||
|
|
||||||
|
export const RECENT_LIMIT = 500 * 1024 * 1024;
|
||||||
|
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
|
||||||
|
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
|
||||||
|
|
||||||
|
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
|
||||||
|
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
|
||||||
|
const removed: string[] = [];
|
||||||
|
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
|
||||||
|
if (total <= limit) break;
|
||||||
|
removed.push(e.id);
|
||||||
|
total -= size(e);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offlineOwner(session: AuthSession): string | null {
|
||||||
|
const desktop = desktopHandle();
|
||||||
|
return desktop?.server && session.state.status === 'signed_in'
|
||||||
|
? JSON.stringify([desktop.server, session.state.user.id]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
|
||||||
|
export class DesktopOffline {
|
||||||
|
private db: Promise<IDBDatabase> | null = null;
|
||||||
|
private loaded: Promise<void> | null = null;
|
||||||
|
private entries: Entry[] = [];
|
||||||
|
private urls = new Map<string, Track>();
|
||||||
|
private objects = new Map<string, string[]>();
|
||||||
|
private inUse = new Set<string>();
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
private revision = 0;
|
||||||
|
private writes: Promise<unknown> = Promise.resolve();
|
||||||
|
private disposed = false;
|
||||||
|
get ready(): Promise<void> {
|
||||||
|
this.loaded ??= this.reload();
|
||||||
|
return this.loaded;
|
||||||
|
}
|
||||||
|
activate() { this.disposed = false; }
|
||||||
|
constructor(readonly owner: string, private session: AuthSession) {}
|
||||||
|
private database(): Promise<IDBDatabase> {
|
||||||
|
return this.db ??= new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
|
||||||
|
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
||||||
|
snapshot = () => this.revision;
|
||||||
|
private async reload() {
|
||||||
|
const db = await this.database();
|
||||||
|
const entries = await new Promise<Entry[]>((resolve, reject) => {
|
||||||
|
const req = db.transaction('tracks').objectStore('tracks').getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result as Entry[]);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.entries = entries;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (this.urls.has(e.id)) continue;
|
||||||
|
const audio = URL.createObjectURL(e.audio);
|
||||||
|
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
|
||||||
|
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
|
||||||
|
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
|
||||||
|
}
|
||||||
|
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
|
||||||
|
this.urls.delete(id);
|
||||||
|
if (!this.inUse.has(id)) this.release(id);
|
||||||
|
}
|
||||||
|
this.revision++;
|
||||||
|
for (const fn of this.listeners) fn();
|
||||||
|
}
|
||||||
|
tracks() { return this.entries.map((e) => this.local(e.track)); }
|
||||||
|
private release(id: string) {
|
||||||
|
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
|
||||||
|
this.objects.delete(id);
|
||||||
|
}
|
||||||
|
/** Keep only files currently playing/crossfading alive after cache deletion. */
|
||||||
|
keep(ids: string[]) {
|
||||||
|
this.inUse = new Set(ids);
|
||||||
|
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
|
||||||
|
}
|
||||||
|
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
|
||||||
|
has(id: string) { return this.entries.some((e) => e.id === id); }
|
||||||
|
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
|
||||||
|
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
|
||||||
|
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
|
||||||
|
const work = this.writes.catch(() => undefined).then(async () => {
|
||||||
|
const db = await this.database();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction('tracks', 'readwrite');
|
||||||
|
fn(tx.objectStore('tracks'));
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
await this.reload();
|
||||||
|
});
|
||||||
|
this.writes = work;
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
private downloads = new Map<string, Promise<void>>();
|
||||||
|
private downloadTail: Promise<unknown> = Promise.resolve();
|
||||||
|
async save(track: Track, pinned = false): Promise<void> {
|
||||||
|
await this.ready;
|
||||||
|
const pending = this.downloads.get(track.id);
|
||||||
|
if (pending) { await pending; return this.save(track, pinned); }
|
||||||
|
const existing = this.entries.find((e) => e.id === track.id);
|
||||||
|
if (existing) {
|
||||||
|
await this.mutate((store) => {
|
||||||
|
const latest = this.entries.find((e) => e.id === track.id);
|
||||||
|
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
|
||||||
|
this.downloadTail = job;
|
||||||
|
this.downloads.set(track.id, job);
|
||||||
|
try { await job; } finally { this.downloads.delete(track.id); }
|
||||||
|
}
|
||||||
|
private async download(track: Track, pinned: boolean) {
|
||||||
|
if (this.disposed) return;
|
||||||
|
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
|
||||||
|
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
|
||||||
|
const response = await fetch(fresh.stream_url);
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
const audio = await response.blob();
|
||||||
|
if (!audio.size) throw new Error('Empty download');
|
||||||
|
let cover: Blob | undefined;
|
||||||
|
if (fresh.album.cover_url) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${fresh.album.cover_url}&size=300`);
|
||||||
|
if (response.ok) cover = await response.blob();
|
||||||
|
} catch { /* Artwork is optional. */ }
|
||||||
|
}
|
||||||
|
if (this.disposed) return;
|
||||||
|
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
|
||||||
|
await this.mutate((store) => {
|
||||||
|
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
|
||||||
|
for (const id of removed) store.delete(id);
|
||||||
|
if (!removed.includes(entry.id)) store.put(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
|
||||||
|
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (!this.disposed) return;
|
||||||
|
for (const id of this.objects.keys()) this.release(id);
|
||||||
|
void this.downloadTail.catch(() => undefined).then(() => this.writes)
|
||||||
|
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist before sending; retries keep event IDs for server-side deduplication. */
|
||||||
|
export function desktopEventSink(session: AuthSession, owner: string) {
|
||||||
|
const key = `pipu.events.${owner}`;
|
||||||
|
let sending = Promise.resolve();
|
||||||
|
return async (events: PlayEvent[]) => {
|
||||||
|
const work = sending.catch(() => undefined).then(async () => {
|
||||||
|
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
|
||||||
|
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
|
||||||
|
localStorage.setItem(key, JSON.stringify(merged));
|
||||||
|
if (offlineOwner(session) !== owner) return;
|
||||||
|
while (merged.length) {
|
||||||
|
if (offlineOwner(session) !== owner) return;
|
||||||
|
const batch = merged.slice(0, 100);
|
||||||
|
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
|
||||||
|
merged.splice(0, batch.length);
|
||||||
|
localStorage.setItem(key, JSON.stringify(merged));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sending = work;
|
||||||
|
await work;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||||
|
import { JobPanel } from './JobPanel';
|
||||||
|
import s from './management.module.css';
|
||||||
|
|
||||||
|
export function ConfigPage() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [draft, setDraft] = useState<Record<string, string | null>>({});
|
||||||
|
const [baseRevision, setBaseRevision] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [group, setGroup] = useState('all');
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const dirty = Object.keys(draft).length > 0;
|
||||||
|
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
|
||||||
|
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
|
||||||
|
const running = status.data?.job?.state === 'running';
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
||||||
|
window.addEventListener('beforeunload', prevent);
|
||||||
|
return () => window.removeEventListener('beforeunload', prevent);
|
||||||
|
}, [dirty]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
|
||||||
|
}, [status.data?.job?.id, status.data?.job?.state, qc]);
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
|
||||||
|
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
|
||||||
|
});
|
||||||
|
const apply = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
|
||||||
|
onSuccess: (job) => {
|
||||||
|
setConfirm(false);
|
||||||
|
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const change = (key: string, value: string | null) => {
|
||||||
|
const entry = query.data!.entries.find((v) => v.key === key)!;
|
||||||
|
const unchanged = value === null ? entry.local === null
|
||||||
|
: entry.secret ? value === '' : value === (entry.local ?? '');
|
||||||
|
const next = { ...draft };
|
||||||
|
if (unchanged) delete next[key]; else next[key] = value;
|
||||||
|
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
|
||||||
|
setDraft(next); setSaved(false); save.reset();
|
||||||
|
};
|
||||||
|
if (query.isPending) return <PageSkeleton />;
|
||||||
|
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
|
||||||
|
return <div className={s.page}>
|
||||||
|
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
|
||||||
|
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||||
|
{query.data && <>
|
||||||
|
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
|
||||||
|
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
|
||||||
|
<p className={s.note}>{t('management.config_files_hint')}</p>
|
||||||
|
<div className={s.configList}>{entries.map((entry) => {
|
||||||
|
const changed = Object.hasOwn(draft, entry.key);
|
||||||
|
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
|
||||||
|
const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored;
|
||||||
|
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
||||||
|
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
||||||
|
<div className={s.values}><span>{t('management.effective')}: <code>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(entry.env) ?? '—'}</code></span></div>
|
||||||
|
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
||||||
|
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} autoComplete={entry.secret ? 'new-password' : 'off'} value={value} disabled={running || save.isPending} placeholder={entry.secret ? entry.local ? t('management.secret_saved') : '' : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||||
|
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
||||||
|
</div>}
|
||||||
|
</section>;
|
||||||
|
})}</div>
|
||||||
|
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
|
||||||
|
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
|
||||||
|
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
|
||||||
|
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
|
||||||
|
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
|
||||||
|
</section>}
|
||||||
|
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
|
||||||
|
<div role="status">{t('management.pending_restart')}</div>
|
||||||
|
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
|
||||||
|
</section>}
|
||||||
|
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||||
|
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
|
||||||
|
<JobPanel job={status.data?.job} reconnecting={status.isError} />
|
||||||
|
</>}
|
||||||
|
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useLayoutEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import s from './management.module.css';
|
||||||
|
|
||||||
|
export type Job = components['schemas']['ServerJob'];
|
||||||
|
|
||||||
|
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const logRef = useRef<HTMLPreElement>(null);
|
||||||
|
const followLog = useRef(true);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
followLog.current = true;
|
||||||
|
}, [job?.id]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const log = logRef.current;
|
||||||
|
if (log && followLog.current) log.scrollTop = log.scrollHeight;
|
||||||
|
}, [job?.id, job?.log]);
|
||||||
|
if (!job) return null;
|
||||||
|
return <section className={s.card} aria-label={t('management.job')}>
|
||||||
|
<div className={s.cardHead}>
|
||||||
|
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
|
||||||
|
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
|
||||||
|
</div>
|
||||||
|
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
|
||||||
|
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
|
||||||
|
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
|
||||||
|
<pre ref={logRef} className={s.log} tabIndex={0} aria-label={t('management.log')} onScroll={(event) => {
|
||||||
|
const log = event.currentTarget;
|
||||||
|
followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
|
||||||
|
}}>{job.log || t('management.waiting')}</pre>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
|
||||||
|
import { JobPanel } from './JobPanel';
|
||||||
|
import s from './management.module.css';
|
||||||
|
|
||||||
|
type Source = components['schemas']['ServerSource'];
|
||||||
|
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
|
||||||
|
|
||||||
|
export function UpdatesPage() {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
const session = useSession();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [sourceId, setSourceId] = useState('');
|
||||||
|
const [channel, setChannel] = useState<Channel>('latest');
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Source[] | null>(null);
|
||||||
|
const fresh = useRef(false);
|
||||||
|
const queryKey = ['server-updates', sourceId, channel, version];
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
const force = fresh.current;
|
||||||
|
fresh.current = false;
|
||||||
|
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
||||||
|
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
||||||
|
const running = query.data?.job?.state === 'running';
|
||||||
|
const checking = query.isFetching;
|
||||||
|
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
|
||||||
|
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
|
||||||
|
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
|
||||||
|
});
|
||||||
|
const start = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
|
||||||
|
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
|
||||||
|
})),
|
||||||
|
onSuccess: (job) => {
|
||||||
|
setConfirm(false);
|
||||||
|
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
onError: () => { void refresh(); },
|
||||||
|
});
|
||||||
|
if (query.isPending) return <PageSkeleton />;
|
||||||
|
return <div className={s.page}>
|
||||||
|
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
|
||||||
|
<Button onClick={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</Button>
|
||||||
|
</header>
|
||||||
|
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||||
|
{query.data && <>
|
||||||
|
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
|
||||||
|
<section className={s.card}>
|
||||||
|
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
|
||||||
|
<label className={s.selectLabel}>{t('management.source')}
|
||||||
|
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
|
||||||
|
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={s.url}>{source?.url}</p>
|
||||||
|
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
|
||||||
|
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
|
||||||
|
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
|
||||||
|
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
|
||||||
|
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||||
|
<div className={s.updateAction}>
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
|
||||||
|
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
|
||||||
|
</div>
|
||||||
|
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<JobPanel job={query.data.job} reconnecting={query.isError} />
|
||||||
|
</>}
|
||||||
|
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
|
||||||
|
<p>{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}</p>
|
||||||
|
<p className={s.note}>{t('management.update_warning')}</p>
|
||||||
|
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||||
|
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" disabled={!canInstall} busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
|
||||||
|
</Dialog>}
|
||||||
|
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
|
||||||
|
<p className={s.note}>{t('management.sources_hint')}</p>
|
||||||
|
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
|
||||||
|
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
|
||||||
|
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
|
||||||
|
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
|
||||||
|
</div>)}</div>
|
||||||
|
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||||
|
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
|
||||||
|
</Dialog>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
||||||
|
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
|
||||||
|
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
|
||||||
|
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
|
||||||
|
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
|
||||||
|
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
|
||||||
|
.hero strong.priority { font-size: 19px; }
|
||||||
|
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
|
||||||
|
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.card h2 { margin: 0; font-size: 18px; }
|
||||||
|
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
|
||||||
|
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
|
||||||
|
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||||
|
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
|
||||||
|
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.status[data-state='succeeded'] { color: var(--ok); }
|
||||||
|
.status[data-state='failed'] { color: var(--danger); }
|
||||||
|
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
|
||||||
|
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
|
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
|
||||||
|
.configList { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.configRow[data-changed] { border-color: var(--accent); }
|
||||||
|
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
|
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
|
||||||
|
.values code { overflow-wrap: anywhere; }
|
||||||
|
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
|
||||||
|
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
|
||||||
|
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
|
||||||
|
|
||||||
|
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDesktop } from '../../desktop';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||||
|
import { ListenTogetherButton } from './Listening';
|
||||||
|
import { ChatMusic, MessageText } from './ChatMusic';
|
||||||
|
import { Presence } from './Presence';
|
||||||
|
import s from './social.module.css';
|
||||||
|
|
||||||
|
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
|
||||||
|
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const server = useDesktop()?.server ?? window.location.origin;
|
||||||
|
const draftKey = chatDraftKey(server, userId, person.id);
|
||||||
|
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
|
||||||
|
const [reply, setReply] = useState<DirectMessage | null>(null);
|
||||||
|
const [editing, setEditing] = useState<DirectMessage | null>(null);
|
||||||
|
const [view, setView] = useState<'conversation' | 'music'>('conversation');
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [emojis, setEmojis] = useState(false);
|
||||||
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const beforeEdit = useRef('');
|
||||||
|
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
|
||||||
|
const typedAt = useRef(0);
|
||||||
|
const composer = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const scroll = useRef<HTMLDivElement>(null);
|
||||||
|
const previousLast = useRef<number | undefined>(undefined);
|
||||||
|
const [visible, setVisible] = useState(document.visibilityState === 'visible');
|
||||||
|
const music = view === 'music';
|
||||||
|
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
|
||||||
|
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) return;
|
||||||
|
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
|
||||||
|
}, [text, draftKey, editing]);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [text, active, visible, editing, music, person.id, api]);
|
||||||
|
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
|
||||||
|
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
|
||||||
|
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
|
||||||
|
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
|
||||||
|
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
|
||||||
|
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
|
||||||
|
const list = [...(messages.data?.pages ?? [])].reverse().flat();
|
||||||
|
const last = list.at(-1)?.seq;
|
||||||
|
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scroll.current;
|
||||||
|
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
|
||||||
|
previousLast.current = last;
|
||||||
|
}, [last, active, q, music]);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scroll.current;
|
||||||
|
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
|
||||||
|
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
|
||||||
|
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, messages.dataUpdatedAt]);
|
||||||
|
const refresh = () => cache.invalidateQueries({ queryKey });
|
||||||
|
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
|
||||||
|
const send = useMutation({ mutationFn: async () => {
|
||||||
|
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
|
||||||
|
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
|
||||||
|
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
|
||||||
|
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
|
||||||
|
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
|
||||||
|
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
|
||||||
|
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
|
||||||
|
const older = async () => {
|
||||||
|
const el = scroll.current;
|
||||||
|
const height = el?.scrollHeight ?? 0;
|
||||||
|
const top = el?.scrollTop ?? 0;
|
||||||
|
await messages.fetchNextPage();
|
||||||
|
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
|
||||||
|
};
|
||||||
|
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||||
|
<header className={s.chatHeader}>
|
||||||
|
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
|
||||||
|
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
|
||||||
|
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
|
||||||
|
</header>
|
||||||
|
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
|
||||||
|
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
|
||||||
|
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
|
||||||
|
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
|
||||||
|
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
|
||||||
|
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
|
||||||
|
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
|
||||||
|
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
|
||||||
|
{list.map((m, i) => <Fragment key={m.id}>
|
||||||
|
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
|
||||||
|
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
|
||||||
|
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
|
||||||
|
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
|
||||||
|
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
|
||||||
|
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
|
||||||
|
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
|
||||||
|
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
|
||||||
|
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
|
||||||
|
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
|
||||||
|
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
|
||||||
|
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
|
||||||
|
]} />}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Fragment>)}
|
||||||
|
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
|
||||||
|
</div>
|
||||||
|
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
|
||||||
|
{!music && <>
|
||||||
|
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
|
||||||
|
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
|
||||||
|
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
|
||||||
|
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||||
|
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
|
||||||
|
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
|
||||||
|
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
|
||||||
|
</form>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { MessageText } from './ChatMusic';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('message text', () => {
|
||||||
|
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
|
||||||
|
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('href', url);
|
||||||
|
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
|
||||||
|
expect(screen.getByRole('link')).toHaveAttribute('href', url);
|
||||||
|
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
|
||||||
|
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
|
||||||
|
expect(container.querySelector('script')).toBeNull();
|
||||||
|
expect(container.textContent).toContain('%D0%BC%20');
|
||||||
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
|
||||||
|
expect(container.textContent).toContain(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { unwrap, type DirectMessage } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { Cover } from '../../entities/catalog';
|
||||||
|
import { usePlayer, usePlayerState } from '../../player/context';
|
||||||
|
import { formatDuration } from '../../shared/lib/format';
|
||||||
|
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
|
||||||
|
import { Button, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from './social.module.css';
|
||||||
|
|
||||||
|
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
|
||||||
|
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const state = usePlayerState();
|
||||||
|
const id = message.attachment_id!;
|
||||||
|
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
|
||||||
|
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
|
||||||
|
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
|
||||||
|
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
|
||||||
|
const isTrack = message.attachment_kind === 'track';
|
||||||
|
const query = isTrack ? track : playlist;
|
||||||
|
const tr = !track.isError ? track.data : undefined;
|
||||||
|
const pl = !playlist.isError ? playlist.data : undefined;
|
||||||
|
const metadataTitle = isTrack ? tr?.title : pl?.name;
|
||||||
|
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
|
||||||
|
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
|
||||||
|
const current = !!tr && tr.id === state.track?.id;
|
||||||
|
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
|
||||||
|
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
|
||||||
|
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
|
||||||
|
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
|
||||||
|
<div className={s.musicInfo}>
|
||||||
|
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
|
||||||
|
<span>{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
|
||||||
|
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
|
||||||
|
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
|
||||||
|
}} />
|
||||||
|
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
|
||||||
|
export function MessageText({ text }: { text: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const long = text.length > 600;
|
||||||
|
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
|
||||||
|
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
|
||||||
|
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
|
||||||
|
if (!/^https?:\/\//.test(part)) return part;
|
||||||
|
try {
|
||||||
|
const original = originalParts[i] ?? part;
|
||||||
|
const url = new URL(original);
|
||||||
|
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
|
||||||
|
} catch { return part; }
|
||||||
|
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useDesktop } from '../../desktop';
|
||||||
|
import { usePlayer, usePlayerState } from '../../player/context';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||||
|
import { waveHeadphonesIcon } from '../../shared/ui/icons';
|
||||||
|
import s from './social.module.css';
|
||||||
|
|
||||||
|
function useListening() {
|
||||||
|
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
|
||||||
|
const api = useMemo(() => socialApi(session), [session]);
|
||||||
|
const key = ['listening', server, user.id] as const;
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||||
|
return { api, key, query, user };
|
||||||
|
}
|
||||||
|
export function ListeningPanel({ showPreferences = true, showRequests = true }: { showPreferences?: boolean; showRequests?: boolean }) {
|
||||||
|
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||||
|
const { api, key, query, user } = useListening();
|
||||||
|
const prefs = query.data?.preferences;
|
||||||
|
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <div className={s.stack}>
|
||||||
|
{showPreferences && <details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
|
||||||
|
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
|
||||||
|
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
|
||||||
|
</details>}
|
||||||
|
{showRequests && query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
|
||||||
|
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||||
|
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||||
|
return <><Button disabled={!person.online} title={!person.online ? t('social.together_offline') : undefined} variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && person.online && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||||
|
}
|
||||||
|
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||||
|
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||||
|
const { api, key } = useListening(); const state = usePlayerState();
|
||||||
|
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||||
|
const [id] = useState(uuidv7);
|
||||||
|
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||||
|
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||||
|
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||||
|
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
|
||||||
|
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||||
|
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
export function ListeningBanner() {
|
||||||
|
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
|
||||||
|
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||||
|
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
|
||||||
|
if (!room) return null;
|
||||||
|
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
|
||||||
|
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
|
||||||
|
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import s from './social.module.css';
|
||||||
|
|
||||||
|
export function Presence({ online }: { online: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <span className={`${s.presence} ${online ? s.presenceOnline : ''}`}><span className={s.presenceDot} aria-hidden />{t(online ? 'social.online' : 'social.offline')}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession, useUser } from '../../app/session';
|
||||||
|
import { useDesktop } from '../../desktop';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||||
|
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
|
||||||
|
import s from './social.module.css';
|
||||||
|
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||||
|
import { ChatConversation } from './ChatConversation';
|
||||||
|
import { Presence } from './Presence';
|
||||||
|
|
||||||
|
const scopes = new WeakMap<object, number>();
|
||||||
|
let nextScope = 0;
|
||||||
|
|
||||||
|
function useSocial() {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const server = useDesktop()?.server ?? '';
|
||||||
|
const api = useMemo(() => socialApi(session), [session]);
|
||||||
|
if (!scopes.has(session)) scopes.set(session, ++nextScope);
|
||||||
|
const key = ['social', server, scopes.get(session), user.id] as const;
|
||||||
|
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
|
||||||
|
return { api, key, inbox, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||||
|
|
||||||
|
export function FriendRecommendations() {
|
||||||
|
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||||
|
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||||
|
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||||
|
export const useMusicShare = () => useContext(ShareContext);
|
||||||
|
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [share, setShare] = useState<ShareValue | null>(null);
|
||||||
|
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
|
||||||
|
}
|
||||||
|
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { api, inbox, key } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [done, setDone] = useState<string[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const ids = useRef(new Map<string, string>());
|
||||||
|
const action = useMutation({ mutationFn: async (peer: string) => {
|
||||||
|
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
|
||||||
|
if (peer === 'external') {
|
||||||
|
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
|
||||||
|
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
|
||||||
|
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
|
||||||
|
setDone(old => [...old, peer]);
|
||||||
|
await cache.invalidateQueries({ queryKey: key });
|
||||||
|
} });
|
||||||
|
return <Dialog title={t('player.share')} onClose={close}>
|
||||||
|
<div className={s.stack}><strong>{value.attachment.title}</strong>
|
||||||
|
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
|
||||||
|
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
|
||||||
|
<h3>{t('social.send_friend')}</h3>
|
||||||
|
{inbox.isPending && <p>{t('social.loading')}</p>}
|
||||||
|
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
|
||||||
|
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
|
||||||
|
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocialTab = 'messages' | 'people' | 'requests' | 'notifications';
|
||||||
|
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
|
||||||
|
|
||||||
|
export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { api, inbox, key, user } = useSocial();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [tab, setTab] = useState<SocialTab>('messages');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [settings, setSettings] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||||
|
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia?.('(max-width: 767px)');
|
||||||
|
if (!media) return;
|
||||||
|
const change = () => setNarrow(media.matches);
|
||||||
|
media.addEventListener('change', change);
|
||||||
|
return () => media.removeEventListener('change', change);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||||
|
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
|
||||||
|
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||||
|
const people = inbox.data?.people ?? [];
|
||||||
|
const friends = people.filter(p => p.relationship === 'friend');
|
||||||
|
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
|
||||||
|
const initialPeer = conversations[0]?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
// Pin the initial desktop selection: a newer message in another dialog must
|
||||||
|
// never switch the conversation the user is reading or composing in.
|
||||||
|
if (!narrow && !peer && initialPeer) void navigate({ to: '/friends', search: { peer: initialPeer }, replace: true });
|
||||||
|
}, [narrow, peer, initialPeer, navigate]);
|
||||||
|
const selected = friends.find(p => p.id === peer) ?? (!peer ? conversations[0] : undefined);
|
||||||
|
const requests = people.filter(p => p.relationship === 'incoming' || p.relationship === 'outgoing');
|
||||||
|
const incoming = requests.filter(p => p.relationship === 'incoming').length;
|
||||||
|
const messageUnread = friends.reduce((sum, p) => sum + p.unread, 0);
|
||||||
|
const filtered = (tab === 'messages' ? conversations : requests).filter(p => `${p.display_name} ${p.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
|
||||||
|
const shownPeople = tab === 'people' ? q.length >= 2 ? results.data ?? [] : friends : filtered;
|
||||||
|
const open = (id: string) => {
|
||||||
|
const person = people.find(p => p.id === id);
|
||||||
|
setSearch(''); setQ('');
|
||||||
|
setTab(person?.relationship === 'friend' ? 'messages' : person?.relationship === 'incoming' || person?.relationship === 'outgoing' ? 'requests' : 'people');
|
||||||
|
void navigate({ to: '/friends', search: { peer: id } });
|
||||||
|
};
|
||||||
|
const findPeople = () => { setTab('people'); setSearch(''); setQ(''); requestAnimationFrame(() => searchRef.current?.focus()); };
|
||||||
|
const back = () => { void navigate({ to: '/friends', search: {} }).then(() => listRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')?.focus()); };
|
||||||
|
const act = (person: SocialPerson, op: SocialAction['action']) => {
|
||||||
|
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
|
||||||
|
else action.mutate({ action: op, target_id: person.id });
|
||||||
|
};
|
||||||
|
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
|
||||||
|
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
|
||||||
|
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
|
||||||
|
<header className={s.pageHeader}>
|
||||||
|
<div><span className={s.eyebrow}>{t('social.space')}</span><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></div>
|
||||||
|
<div className={s.headerActions}>
|
||||||
|
<Button variant="ghost" icon={currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon} onClick={() => setSettings(true)}>{t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings')}</Button>
|
||||||
|
<Button variant="ghost" icon={inboxIcon} aria-pressed={tab === 'notifications'} onClick={() => setTab('notifications')}>{t('social.notifications')}{inbox.data?.unread ? <span className={s.badge}>{inbox.data.unread}</span> : null}</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ListeningPanel showPreferences={false} />
|
||||||
|
<section className={s.hub}>
|
||||||
|
<nav className={s.tabs} aria-label={t('social.title')}>
|
||||||
|
{(['messages', 'people', 'requests'] as const).map(value => <button key={value} type="button" aria-pressed={tab === value} onClick={() => { setTab(value); setSearch(''); setQ(''); }}>{t(`social.${value}`)}{(value === 'requests' ? incoming : value === 'messages' ? messageUnread : 0) > 0 && <span className={s.badge}>{value === 'requests' ? incoming : messageUnread}</span>}</button>)}
|
||||||
|
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
|
||||||
|
</nav>
|
||||||
|
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
|
||||||
|
{tab === 'notifications' ? <div className={s.events}>
|
||||||
|
<h2>{t('social.notifications')}</h2>
|
||||||
|
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
|
||||||
|
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
|
||||||
|
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
|
||||||
|
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||||
|
</article>)}
|
||||||
|
</div> : <div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||||
|
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
|
||||||
|
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
|
||||||
|
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
|
||||||
|
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
|
||||||
|
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
|
||||||
|
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
|
||||||
|
<span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span><Presence online={p.online} />{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
|
||||||
|
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
|
||||||
|
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
|
||||||
|
</aside>
|
||||||
|
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !settings && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
{settings && <Dialog title={t('social.privacy_settings')} onClose={() => setSettings(false)}>
|
||||||
|
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
|
||||||
|
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
|
||||||
|
<ListeningPanel showRequests={false} />
|
||||||
|
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => act(p, op)} />)}</section>}
|
||||||
|
</Dialog>}
|
||||||
|
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialAction['action']) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <Menu label={t('social.person_actions', { name: person.display_name })} trigger={<Icon src={moreIcon} />} items={[
|
||||||
|
...(person.relationship === 'friend' ? [{ label: t('social.remove'), danger: true, onSelect: () => act('remove') }] : []),
|
||||||
|
{ label: t(person.relationship === 'blocked' ? 'social.unblock' : 'social.block'), danger: person.relationship !== 'blocked', onSelect: () => act(person.relationship === 'blocked' ? 'unblock' : 'block') },
|
||||||
|
]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
|
||||||
|
<div className={s.identity}><span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div>{p.relationship === 'friend' && <Presence online={p.online} />}</div></div>
|
||||||
|
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{t('social.listening')}</span><Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}</div>}
|
||||||
|
<div className={s.actions}>
|
||||||
|
{p.relationship === 'friend' && <><Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button><ListenTogetherButton person={p} /></>}
|
||||||
|
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
|
||||||
|
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
|
||||||
|
{['incoming', 'outgoing'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : 'social.cancel')}</Button>}
|
||||||
|
<FriendMenu person={p} act={act} />
|
||||||
|
</div>
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||||
|
.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; }
|
||||||
|
.pageHeader p { margin: 8px 0 0; }
|
||||||
|
.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); }
|
||||||
|
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
|
||||||
|
.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; }
|
||||||
|
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
|
||||||
|
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
|
||||||
|
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
|
||||||
|
.tabs > button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
|
||||||
|
.tabs > button[aria-pressed='true']::after { content: ''; position: absolute; bottom: -11px; height: 2px; left: 16px; right: 16px; background: var(--accent2); border-radius: 2px; animation: tabIn .2s ease-out; }
|
||||||
|
.tabs > button:hover { color: var(--text); background: var(--hover); }
|
||||||
|
.addFriend { margin-left: auto; }
|
||||||
|
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent-fill); color: var(--on-accent, #fff); font-size: 11px; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(230px, 29%) minmax(0, 1fr); }
|
||||||
|
.peopleList { padding: 12px 10px; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||||
|
.workspace > .peopleList { border-right: 1px solid var(--line); background: var(--bg2); }
|
||||||
|
.listSearch { display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 0 12px; min-height: 44px; margin: 2px 4px 14px; color: var(--text3); }
|
||||||
|
.listSearch:focus-within { border-color: var(--accent); }
|
||||||
|
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
|
||||||
|
.listSearch input::placeholder { color: var(--text3); }
|
||||||
|
.listSearch input:focus-visible { outline-offset: 4px; }
|
||||||
|
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
|
||||||
|
.dialogRow:hover { background: var(--hover); }
|
||||||
|
.dialogRow:active { transform: scale(.985); }
|
||||||
|
.dialogRow.selected { background: var(--accent-soft); }
|
||||||
|
.dialogBody { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||||
|
.dialogTitle { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
|
||||||
|
.dialogTitle strong { font-size: 14px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.dialogTitle time { color: var(--text3); font-size: 10px; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.preview { color: var(--text2); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
|
||||||
|
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
|
||||||
|
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
|
||||||
|
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
|
||||||
|
.presenceOnline .presenceDot { background: var(--ok); }
|
||||||
|
.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
|
||||||
|
.directory .peopleList { padding: 0; overflow: visible; }
|
||||||
|
.directory .listSearch { max-width: 440px; margin-left: 0; }
|
||||||
|
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.person > .actions { margin-left: auto; }
|
||||||
|
.person.selected { background: var(--accent-soft); }
|
||||||
|
.identity { display: flex; gap: 12px; align-items: center; min-width: 0; }
|
||||||
|
.identity > div { min-width: 0; }
|
||||||
|
.identity strong { display: block; overflow-wrap: anywhere; }
|
||||||
|
.activity { display: grid; gap: 4px; max-width: 300px; font-size: 13px; }
|
||||||
|
.activity a, .nowListening a { color: var(--text); text-decoration: none; }
|
||||||
|
.activity a:hover, .nowListening a:hover { color: var(--accent2); text-decoration: underline; }
|
||||||
|
.listening { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 11px; }
|
||||||
|
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.stack { display: grid; gap: 12px; }
|
||||||
|
.stack:empty { display: none; }
|
||||||
|
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 32px; }
|
||||||
|
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
|
||||||
|
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
|
||||||
|
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
|
||||||
|
.events h2 { font-size: 18px; margin: 8px 0 20px; }
|
||||||
|
.event { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 18px 12px; border-bottom: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.eventBody { flex: 1; min-width: 100px; overflow-wrap: anywhere; }
|
||||||
|
.eventBody p { color: var(--text2); font-size: 13px; margin: 4px 0; }
|
||||||
|
.event.unread { background: var(--accent-soft); }
|
||||||
|
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
|
||||||
|
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||||
|
.privacy summary { cursor: pointer; font-weight: 600; }
|
||||||
|
.privacy p { margin-bottom: 0; }
|
||||||
|
.privacy .privacy { margin: 12px 0; }
|
||||||
|
.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; }
|
||||||
|
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
||||||
|
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
|
||||||
|
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||||
|
.chatIdentity { flex: 1; min-width: 0; }
|
||||||
|
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||||
|
.chatIdentity > .presence { display: inline-flex; }
|
||||||
|
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
|
||||||
|
.backButton { display: none; }
|
||||||
|
.nowListening { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); color: var(--ok); flex-shrink: 0; }
|
||||||
|
.nowListening > div { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12px; }
|
||||||
|
.nowListening span { font-size: 11px; }
|
||||||
|
.nowListening a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||||
|
.chatTabs { display: flex; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||||
|
.chatTabs button { position: relative; display: flex; align-items: center; gap: 6px; padding: 12px 0; font-size: 12px; color: var(--text3); transition: color .16s; }
|
||||||
|
.chatTabs button[aria-pressed='true'] { color: var(--text); }
|
||||||
|
.chatTabs button[aria-pressed='true']::after { content: ''; height: 2px; position: absolute; left: 0; right: 0; bottom: -1px; border-radius: 2px; background: var(--accent2); animation: tabIn .18s ease-out; }
|
||||||
|
.chatSearch { display: flex; gap: 12px; align-items: end; padding: 12px 20px; border-bottom: 1px solid var(--line); animation: panelIn .18s ease-out; flex-shrink: 0; }
|
||||||
|
.chatSearch > div { flex: 1; }
|
||||||
|
.messages { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-width: thin; padding: 22px 28px; }
|
||||||
|
.chatSkeleton { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.older { align-self: center; flex-shrink: 0; }
|
||||||
|
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0 10px; flex-shrink: 0; }
|
||||||
|
.newMessages { text-align: center; color: var(--accent2); font-size: 11px; border-bottom: 1px solid var(--line2); padding: 6px; }
|
||||||
|
.bubble { display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 5px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: min(82%, 560px); min-width: 100px; flex-shrink: 0; align-self: start; overflow-wrap: anywhere; animation: messageIn .22s cubic-bezier(.2,.8,.2,1); }
|
||||||
|
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
|
||||||
|
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
|
||||||
|
.messageText a, .attachmentCaption a { color: var(--accent2); }
|
||||||
|
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
|
||||||
|
.messageFoot small { font-size: 10px; color: var(--text3); }
|
||||||
|
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
|
||||||
|
.messageFoot button:hover { color: var(--text); background: var(--hover); }
|
||||||
|
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
|
||||||
|
.deleted { font-style: italic; color: var(--text3); }
|
||||||
|
.sending { opacity: .6; }
|
||||||
|
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); padding: 4px 8px; font-size: 12px; cursor: pointer; transition: transform .15s, background .15s; }
|
||||||
|
.reactions button:hover { transform: translateY(-2px); }
|
||||||
|
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
|
||||||
|
.typing { display: flex; align-items: center; gap: 8px; min-height: 24px; padding: 0 24px 4px; color: var(--text3); font-size: 11px; flex-shrink: 0; }
|
||||||
|
.typingDots { display: flex; gap: 3px; }
|
||||||
|
.typingDots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: typingPulse 1s ease-in-out infinite alternate; }
|
||||||
|
.typingDots i:nth-child(2) { animation-delay: .15s; }
|
||||||
|
.typingDots i:nth-child(3) { animation-delay: .3s; }
|
||||||
|
.replyBar { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .16s ease-out; }
|
||||||
|
.replyBar > div { flex: 1; min-width: 0; }
|
||||||
|
.replyBar small { color: var(--accent2); }
|
||||||
|
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; overflow-wrap: anywhere; }
|
||||||
|
.composer { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--line); align-items: end; flex-shrink: 0; background: var(--surface); }
|
||||||
|
.composer textarea { resize: none; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 12px; background: var(--surface2); color: var(--text); padding: 10px 14px; min-height: 42px; max-height: 144px; font: inherit; font-size: 14px; line-height: 22px; scrollbar-width: thin; transition: border-color .18s, background .18s; }
|
||||||
|
.composer textarea:focus { border-color: var(--accent); }
|
||||||
|
.composer textarea::placeholder { color: var(--text3); }
|
||||||
|
.emojiPicker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 20px; border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .18s ease-out; }
|
||||||
|
.emojiPicker button { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; transition: background .15s, transform .15s; }
|
||||||
|
.emojiPicker button:hover { background: var(--hover); transform: translateY(-2px); }
|
||||||
|
.jumpToBottom { position: absolute; bottom: 94px; align-self: center; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); z-index: 2; animation: panelIn .18s ease-out; }
|
||||||
|
.musicCard { display: flex; align-items: center; gap: 12px; min-width: 0; width: 320px; max-width: 100%; padding: 6px 0; }
|
||||||
|
.musicInfo { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
|
||||||
|
.musicInfo a { color: var(--text); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||||
|
.musicInfo a:hover { color: var(--accent2); text-decoration: underline; }
|
||||||
|
.musicInfo > span { font-size: 11px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.musicCover { width: 48px; height: 48px; display: grid; place-items: center; background: var(--surface3); border-radius: 8px; color: var(--text3); flex-shrink: 0; }
|
||||||
|
.musicUnavailable { opacity: .7; }
|
||||||
|
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
|
.musicMessages .musicCard { width: 100%; }
|
||||||
|
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
|
||||||
|
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
|
||||||
|
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes panelIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
|
||||||
|
@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||||
|
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||||
|
@media(max-width: 767px) {
|
||||||
|
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; }
|
||||||
|
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
|
||||||
|
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
|
||||||
|
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
|
||||||
|
.conversationOpen .workspace > .chat { display: flex; flex: 1; } .backButton { display: inline-flex; } .chatHeader { padding: 10px 8px; gap: 6px; }
|
||||||
|
.chatHeader .avatar { width: 32px; height: 32px; font-size: 13px; } .chatIdentity h2 { font-size: 14px; } .chatIdentity > span { font-size: 11px; }
|
||||||
|
.chatTools > button, .chatTools > div > button { min-width: 36px; width: 36px; } .iconButton { min-width: 36px; width: 36px; } .chatTools > div:last-child > button { width: 32px; min-width: 32px; }
|
||||||
|
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
|
||||||
|
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
|
||||||
|
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
|
||||||
|
.directory, .events { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
|
||||||
|
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
|
||||||
|
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
|
||||||
|
}
|
||||||
|
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
|
||||||
|
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .events, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { useUploadsApi } from './queries';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [artist, setArtist] = useState('');
|
||||||
|
const [album, setAlbum] = useState('');
|
||||||
|
const [year, setYear] = useState('');
|
||||||
|
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.updateBatch(batchId, {
|
||||||
|
...(artist.trim() ? { artist: artist.trim() } : {}),
|
||||||
|
...(album.trim() ? { album: album.trim() } : {}),
|
||||||
|
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
|
||||||
|
...(cover !== undefined ? { cover_base64: cover } : {}),
|
||||||
|
}),
|
||||||
|
onSuccess: onSaved,
|
||||||
|
});
|
||||||
|
const pickCover = (file: File | undefined) => {
|
||||||
|
if (!file) {
|
||||||
|
setCover(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const data = String(reader.result ?? '');
|
||||||
|
const comma = data.indexOf(',');
|
||||||
|
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.batch_edit_title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
|
||||||
|
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
||||||
|
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
||||||
|
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.batch_cover')}</span>
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" onClick={() => setCover(null)}>
|
||||||
|
{t('uploads.batch_cover_clear')}
|
||||||
|
</Button>
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { SessionProvider } from '../../app/session';
|
||||||
|
import { mobileFixtures } from '../../test/mobileFixtures';
|
||||||
|
import { EditDialog } from './EditDialog';
|
||||||
|
import type { UploadFile } from './common';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function fixture(fail = false) {
|
||||||
|
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
const fetchImpl: typeof fetch = async (input) => {
|
||||||
|
const req = input as Request;
|
||||||
|
if (req.method === 'PATCH') {
|
||||||
|
requests.push(await req.json());
|
||||||
|
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
|
||||||
|
};
|
||||||
|
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
|
||||||
|
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
|
||||||
|
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
|
||||||
|
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
|
||||||
|
return { requests, onSaved, onClose, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('saves edited metadata with the target source and closes only after success', async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
|
||||||
|
expect(f.onClose).toHaveBeenCalledOnce();
|
||||||
|
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor and input when a concurrent review rejects the save', async () => {
|
||||||
|
const f = await fixture(true);
|
||||||
|
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
|
||||||
|
expect(screen.getByRole('dialog')).toBeVisible();
|
||||||
|
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
|
||||||
|
expect(f.onSaved).not.toHaveBeenCalled();
|
||||||
|
expect(f.onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import { useUploadsApi, useUploadSources } from './queries';
|
||||||
|
import type { UploadFile } from './common';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
type Meta = components['schemas']['UploadMeta'];
|
||||||
|
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||||
|
|
||||||
|
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
||||||
|
const [source, setSource] = useState(file.target_source_id ?? '');
|
||||||
|
const sources = useUploadSources();
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
||||||
|
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
||||||
|
);
|
||||||
|
const num = (k: 'year' | 'track' | 'disc') => (
|
||||||
|
<Field
|
||||||
|
label={t(`uploads.meta_${k}`)}
|
||||||
|
value={m[k] === null ? '' : String(m[k])}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.edit_title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
||||||
|
<div className={u.grid}>
|
||||||
|
{text('artist')}
|
||||||
|
{text('albumartist')}
|
||||||
|
{text('album')}
|
||||||
|
{text('title')}
|
||||||
|
{text('genre')}
|
||||||
|
{num('year')}
|
||||||
|
{num('track')}
|
||||||
|
{num('disc')}
|
||||||
|
</div>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.target_source')}</span>
|
||||||
|
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||||
|
<option value="">{t('uploads.target_default')}</option>
|
||||||
|
{accepting.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={s.note}>{t('uploads.tags_note')}</p>
|
||||||
|
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||||
|
import { editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { needsFormatAttention } from './files';
|
||||||
|
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, cx, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
export function BatchCard({
|
||||||
|
files,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onEditBatch,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
files: UploadFile[];
|
||||||
|
selectable: boolean;
|
||||||
|
selected: Set<string>;
|
||||||
|
onToggle: (ids: string[], on: boolean) => void;
|
||||||
|
onEdit: (f: UploadFile) => void;
|
||||||
|
onEditBatch: (batchId: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const first = files[0]!;
|
||||||
|
const ids = files.map((f) => f.id);
|
||||||
|
const all = ids.every((id) => selected.has(id));
|
||||||
|
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
||||||
|
return (
|
||||||
|
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||||
|
<div className={u.batchHead}>
|
||||||
|
{selectable && (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||||
|
)}
|
||||||
|
<b>{first.author.username}</b>
|
||||||
|
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||||
|
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||||
|
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{selectable && (
|
||||||
|
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||||
|
{t('uploads.edit_batch')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{files.map((f) => (
|
||||||
|
<QueueRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
selectable={selectable}
|
||||||
|
checked={selected.has(f.id)}
|
||||||
|
onCheck={(on) => onToggle([f.id], on)}
|
||||||
|
onEdit={() => onEdit(f)}
|
||||||
|
onRetry={() => onRetry(f.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueRow({
|
||||||
|
file: f,
|
||||||
|
selectable,
|
||||||
|
checked,
|
||||||
|
onCheck,
|
||||||
|
onEdit,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
file: UploadFile;
|
||||||
|
selectable: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
onCheck: (on: boolean) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const reason = useReasonText()(f.status_reason);
|
||||||
|
const m = f.metadata;
|
||||||
|
const quality = [
|
||||||
|
f.format?.toUpperCase(),
|
||||||
|
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
||||||
|
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
||||||
|
f.bit_depth ? `${f.bit_depth} bit` : null,
|
||||||
|
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
||||||
|
formatBytes(f.size_bytes, i18n.language),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
||||||
|
return (
|
||||||
|
<div className={u.queueRow}>
|
||||||
|
{selectable ? (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className={u.meta}>
|
||||||
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||||
|
{m && (
|
||||||
|
<span className={u.sub}>
|
||||||
|
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={u.sub}>{quality}</span>
|
||||||
|
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
||||||
|
{f.client_rel_path ?? f.original_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={u.meta}>
|
||||||
|
<div className={u.flags}>
|
||||||
|
<UploadStatusBadge status={f.status} />
|
||||||
|
{needsFormatAttention(f.format, f.original_name) && (
|
||||||
|
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||||
|
{t('uploads.flag_attention')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||||
|
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||||
|
{f.matched_album_id ? (
|
||||||
|
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||||
|
) : (
|
||||||
|
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||||
|
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
||||||
|
{f.duplicate_level && f.status === 'duplicate' && (
|
||||||
|
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={u.rowActions}>
|
||||||
|
{f.stream_url && (
|
||||||
|
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||||
|
<Icon src={editIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{f.status === 'import_failed' && (
|
||||||
|
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
||||||
|
{t('uploads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Dialog } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
export function RejectDialog({
|
||||||
|
count,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.reject_title', { count })}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.reject_reason')}</span>
|
||||||
|
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
|
||||||
|
import { BatchCard } from './QueueRows';
|
||||||
|
import { BatchEditDialog } from './BatchEditDialog';
|
||||||
|
import { EditDialog } from './EditDialog';
|
||||||
|
import { RejectDialog } from './RejectDialog';
|
||||||
|
import { useUploadQueue, useUploadsApi } from './queries';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
const TABS = {
|
||||||
|
review: ['pending_review'],
|
||||||
|
failed: ['import_failed'],
|
||||||
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
||||||
|
imported: ['imported'],
|
||||||
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||||
|
} satisfies Record<string, UploadStatus[]>;
|
||||||
|
type Tab = keyof typeof TABS;
|
||||||
|
|
||||||
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
||||||
|
export function UploadQueue() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [tab, setTab] = useState<Tab>('review');
|
||||||
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||||
|
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
|
||||||
|
const list = useUploadQueue(TABS[tab], tab, onlyPossible);
|
||||||
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||||
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
};
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: api.retry,
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
||||||
|
|
||||||
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
||||||
|
const batches = useMemo(() => {
|
||||||
|
const m = new Map<string, UploadFile[]>();
|
||||||
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
||||||
|
return [...m.values()];
|
||||||
|
}, [items]);
|
||||||
|
const toggle = (ids: string[], on: boolean) =>
|
||||||
|
setSelected((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const selectable = tab === 'review' || tab === 'failed';
|
||||||
|
const ids = [...selected];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('uploads.queue_title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<QuotaMeter />
|
||||||
|
</div>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||||
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
||||||
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
||||||
|
{t(`uploads.tab_${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
||||||
|
{t('uploads.only_possible')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||||
|
)}
|
||||||
|
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
||||||
|
|
||||||
|
{batches.map((files) => (
|
||||||
|
<BatchCard
|
||||||
|
key={files[0]!.batch_id}
|
||||||
|
files={files}
|
||||||
|
selectable={selectable}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggle}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onEditBatch={setBatchEdit}
|
||||||
|
onRetry={(id) => retry.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('uploads.load_more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ids.length > 0 && (
|
||||||
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
||||||
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
{tab === 'review' && (
|
||||||
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
||||||
|
{t('uploads.approve')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{batchEdit && (
|
||||||
|
<BatchEditDialog
|
||||||
|
batchId={batchEdit}
|
||||||
|
onClose={() => setBatchEdit(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setBatchEdit(null);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditDialog
|
||||||
|
file={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
review.reset();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rejecting && (
|
||||||
|
<RejectDialog
|
||||||
|
count={ids.length}
|
||||||
|
busy={review.isPending}
|
||||||
|
onClose={() => setRejecting(false)}
|
||||||
|
onReject={(reason) => {
|
||||||
|
setRejecting(false);
|
||||||
|
review.mutate({ decision: 'reject', ids, reason });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
||||||
|
function problemError(p: components['schemas']['Problem']) {
|
||||||
|
return new ApiError(p.status, p.code, p.params ?? {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
type Settings = components['schemas']['UploadSettings'];
|
||||||
|
const GB = 1024 ** 3;
|
||||||
|
|
||||||
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||||
|
export function UploadSettingsSection() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const settings = useUploadSettings();
|
||||||
|
const sources = useUploadSources();
|
||||||
|
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.data) setForm(toForm(settings.data));
|
||||||
|
}, [settings.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const f = form!;
|
||||||
|
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
||||||
|
return api.updateSettings({
|
||||||
|
queue_limit_bytes: gb(f.queue), max_file_bytes: gb(f.maxFile),
|
||||||
|
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||||
|
default_source_id: f.source || null, allow_quality_upgrade: f.upgrade,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
queryClient.setQueryData(['upload-settings'], data);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!form) return null;
|
||||||
|
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
||||||
|
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
||||||
|
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('uploads.settings_title')}</h2>
|
||||||
|
<div className={u.settingsGrid}>
|
||||||
|
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||||
|
<Field
|
||||||
|
label={t('uploads.set_per_user')}
|
||||||
|
value={form.perUser}
|
||||||
|
hint={t('uploads.set_per_user_hint')}
|
||||||
|
onChange={(v) => setForm({ ...form, perUser: v })}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||||
|
<label>
|
||||||
|
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||||
|
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||||
|
<option value="">{t('uploads.set_source_auto')}</option>
|
||||||
|
{sources.data
|
||||||
|
?.filter((x) => x.accepts_uploads)
|
||||||
|
.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label className={u.settingsCheck}>
|
||||||
|
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||||
|
<span>{t('uploads.set_upgrade')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(st: Settings) {
|
||||||
|
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
||||||
|
return {
|
||||||
|
queue: gb(st.queue_limit_bytes),
|
||||||
|
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||||
|
maxFile: gb(st.max_file_bytes),
|
||||||
|
source: st.default_source_id ?? '',
|
||||||
|
upgrade: st.allow_quality_upgrade,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ApiClient, components, paths } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
|
||||||
|
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
|
||||||
|
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
|
||||||
|
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
/** The feature owns transport mapping; views only provide form values. */
|
||||||
|
export function uploadsApi(client: ApiClient) {
|
||||||
|
return {
|
||||||
|
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
|
||||||
|
await client.GET('/api/v1/admin/uploads', {
|
||||||
|
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
|
||||||
|
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
|
||||||
|
),
|
||||||
|
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
||||||
|
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
|
||||||
|
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
|
||||||
|
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
|
||||||
|
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
|
||||||
|
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { formatBytes } from '../../shared/lib/format';
|
||||||
|
import { cx } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
export type UploadStatus = components['schemas']['UploadStatus'];
|
||||||
|
export type UploadFile = components['schemas']['UploadFile'];
|
||||||
|
|
||||||
|
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
|
||||||
|
uploading: 'info',
|
||||||
|
processing: 'info',
|
||||||
|
pending_review: 'warn',
|
||||||
|
approved: 'info',
|
||||||
|
importing: 'info',
|
||||||
|
imported: 'ok',
|
||||||
|
import_failed: 'bad',
|
||||||
|
duplicate: 'muted',
|
||||||
|
invalid: 'bad',
|
||||||
|
rejected: 'bad',
|
||||||
|
withdrawn: 'muted',
|
||||||
|
expired: 'muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
|
||||||
|
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
|
||||||
|
|
||||||
|
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadQuota() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['upload-quota'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
|
||||||
|
export function QuotaMeter() {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const quota = useUploadQuota();
|
||||||
|
const id = useId();
|
||||||
|
if (!quota.data) return null;
|
||||||
|
const q = quota.data;
|
||||||
|
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||||
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
|
return (
|
||||||
|
<div className={u.quota}>
|
||||||
|
<span className={u.quotaText} id={id}>
|
||||||
|
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||||
|
{q.per_user_limit_bytes !== null &&
|
||||||
|
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||||
|
</span>
|
||||||
|
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
|
||||||
|
export function useReasonText(): (reason: string | null) => string | null {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
return (reason) => {
|
||||||
|
if (!reason) return null;
|
||||||
|
const code = reason.split(':')[0]!.trim();
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
if (i18n.exists(key)) {
|
||||||
|
const rest = reason.slice(code.length + 1).trim();
|
||||||
|
return rest ? `${tr(key)}: ${rest}` : tr(key);
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { uploadsApi } from './api';
|
||||||
|
import { MOVING, type UploadStatus } from './common';
|
||||||
|
|
||||||
|
export function useUploadsApi() { return uploadsApi(useSession().client); }
|
||||||
|
|
||||||
|
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ['admin-uploads', tab, onlyPossible],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadSources() {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadSettings() {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer';
|
||||||
|
|
||||||
|
export function UserTransfer() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [archive, setArchive] = useState<UserArchive | null>(null);
|
||||||
|
const [fileError, setFileError] = useState(false);
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
|
const exporting = useMutation({
|
||||||
|
mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))),
|
||||||
|
});
|
||||||
|
const importing = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })),
|
||||||
|
onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||||
|
});
|
||||||
|
const busy = importing.isPending || reading;
|
||||||
|
const close = () => { if (!busy) { setOpen(false); setArchive(null); } };
|
||||||
|
|
||||||
|
const read = async (file: File | undefined) => {
|
||||||
|
setArchive(null);
|
||||||
|
setFileError(false);
|
||||||
|
importing.reset();
|
||||||
|
if (!file) return;
|
||||||
|
setReading(true);
|
||||||
|
try {
|
||||||
|
if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size');
|
||||||
|
setArchive(parseUserArchive(await file.text()));
|
||||||
|
} catch { setFileError(true); }
|
||||||
|
finally { setReading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
|
||||||
|
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
|
||||||
|
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
|
||||||
|
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
|
||||||
|
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
|
||||||
|
{t('transfer.apply')}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<p className={s.note}>{t('transfer.description')}</p>
|
||||||
|
<p className={s.note}>{t('transfer.sensitive')}</p>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('transfer.file')}</span>
|
||||||
|
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
|
||||||
|
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
|
||||||
|
{importing.data && <div role="status">
|
||||||
|
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
|
||||||
|
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
|
||||||
|
</div>}
|
||||||
|
</Dialog>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseUserArchive } from './transfer';
|
||||||
|
|
||||||
|
describe('user archive preview', () => {
|
||||||
|
const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' };
|
||||||
|
it('accepts a versioned export and a UTF-8 BOM', () => {
|
||||||
|
const archive = { version: 1, users: [entry] };
|
||||||
|
expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}',
|
||||||
|
JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }),
|
||||||
|
JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }),
|
||||||
|
JSON.stringify({ version: 1, users: Array(10001).fill(entry) }),
|
||||||
|
])('rejects unsupported or malformed input', (text) => {
|
||||||
|
expect(() => parseUserArchive(text)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
|
||||||
|
export type UserArchive = components['schemas']['UserArchive'];
|
||||||
|
export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Only previews metadata; the server validates every account before writing. */
|
||||||
|
export function parseUserArchive(text: string): UserArchive {
|
||||||
|
const data: unknown = JSON.parse(text.replace(/^\uFEFF/, ''));
|
||||||
|
if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 ||
|
||||||
|
!('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive');
|
||||||
|
for (const entry of data.users) {
|
||||||
|
if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' ||
|
||||||
|
!['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') {
|
||||||
|
throw new Error('archive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data as UserArchive;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadUserArchive(archive: UserArchive) {
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'pipu-users.json';
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
import { createDesktop, DesktopProvider } from './desktop';
|
import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ const session = new AuthSession({
|
|||||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||||
baseUrl: desktop?.proxyBase,
|
baseUrl: desktop?.proxyBase,
|
||||||
refreshStore: desktop?.refreshStore,
|
refreshStore: desktop?.refreshStore,
|
||||||
|
fetch: desktop ? desktopFetch : undefined,
|
||||||
});
|
});
|
||||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
import { AddToPlaylist } from '../entities/playlists';
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (album.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (album.isError) return <NotFound error={album.error} />;
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
const a = album.data;
|
const a = album.data;
|
||||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
if (!a) return null;
|
||||||
const ctx = { type: 'album' as const, id, label: a?.title };
|
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
const ctx = { type: 'album' as const, id, label: a.title };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
|
{a.cover_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.cover_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||||
<Cover album={a ?? { id }} size={200} />
|
<Cover album={a} size={200} className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.album')}</span>
|
<span className={c.kind}>{t('catalog.album')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.title}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
{a.artist.name}
|
||||||
{a.artist.name}
|
</Link>
|
||||||
</Link>
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
</span>
|
||||||
</span>
|
{a.tracks.some((tr) => tr.available) && (
|
||||||
)}
|
|
||||||
{a && a.tracks.some((tr) => tr.available) && (
|
|
||||||
<div className={c.heroActions}>
|
<div className={c.heroActions}>
|
||||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
|
||||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
{t('catalog.play')}
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
<TrackList tracks={a.tracks} numbered context={ctx} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import s from './auth.module.css';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||||
*/
|
*/
|
||||||
export function AppDownloadPage() {
|
export function AppDownloadPage() {
|
||||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
@@ -40,10 +40,17 @@ export function AppDownloadPage() {
|
|||||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||||
<h1>{t('app.title')}</h1>
|
<h1>{t('app.title')}</h1>
|
||||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
{latest.isPending ? null : data ? (
|
{latest.isPending ? (
|
||||||
|
<p role="status">{t('common:loading')}</p>
|
||||||
|
) : latest.isError ? (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
{primary ? (
|
{primary ? (
|
||||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
<>
|
||||||
|
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||||
|
<OlderVersions items={primary.older ?? []} />
|
||||||
|
</>
|
||||||
) : detected ? (
|
) : detected ? (
|
||||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -72,12 +79,15 @@ export function AppDownloadPage() {
|
|||||||
{others.map((id) => {
|
{others.map((id) => {
|
||||||
const file = files[id]!;
|
const file = files[id]!;
|
||||||
return (
|
return (
|
||||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
<div key={id} className={s.otherBlock}>
|
||||||
{t(`app.download.${id}`)}
|
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||||
<span>
|
{t(`app.download.${id}`)}
|
||||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
<span>
|
||||||
</span>
|
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
|
||||||
</a>
|
</span>
|
||||||
|
</a>
|
||||||
|
<OlderVersions items={file.older ?? []} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +101,22 @@ export function AppDownloadPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className={s.older}>
|
||||||
|
<summary>{t('app.older')}</summary>
|
||||||
|
{items.map((b) => (
|
||||||
|
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
|
||||||
|
{t('app.version', { version: b.version })}
|
||||||
|
<span>{formatBytes(b.size, i18n.language)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (artist.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (artist.isError) return <NotFound error={artist.error} />;
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
const a = artist.data;
|
const a = artist.data;
|
||||||
|
if (!a) return null;
|
||||||
const tracks = top.data ?? [];
|
const tracks = top.data ?? [];
|
||||||
const ctx = { type: 'artist' as const, id, label: a?.name };
|
const ctx = { type: 'artist' as const, id, label: a.name };
|
||||||
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
<Cover album={{ id }} size={168} round />
|
{a.image_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.image_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.name}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
</span>
|
||||||
</span>
|
<div className={c.heroActions}>
|
||||||
)}
|
<Button
|
||||||
{a && (
|
variant="primary"
|
||||||
<div className={c.heroActions}>
|
className={c.pill}
|
||||||
<Button
|
icon={waveIcon}
|
||||||
variant="primary"
|
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||||
className={c.pill}
|
>
|
||||||
icon={waveIcon}
|
{t('catalog.artist_wave')}
|
||||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
</Button>
|
||||||
>
|
{tracks.length > 0 && (
|
||||||
{t('catalog.artist_wave')}
|
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||||
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
{tracks.length > 0 && (
|
)}
|
||||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
</div>
|
||||||
{t('catalog.play')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
|
|||||||