diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..af0d417 --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,115 @@ +name: CI/CD + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +concurrency: + group: production-deploy + cancel-in-progress: false + +jobs: + backend-tests: + name: BackEnd + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore Musify.slnx + + - name: Build + run: dotnet build Musify.slnx --no-restore -c Release + + - name: Test + run: dotnet test Musify.slnx --no-build -c Release + + web-player-checks: + name: WebPlayer + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: web-player + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web-player/package-lock.json + + - run: npm ci + + - name: Type-check + run: npm run check + + - name: Lint + run: npm run lint + + # Runs on the production host itself, through the existing self-hosted + # Gitea Actions runner (act_runner), which also carries a "production" + # label registered in native (non-docker) mode so its steps see the host's + # real Docker daemon directly (Docker socket + a bind-mounted deploy + # directory — see deploy/README.md for how that runner is set up). + # + # DEPLOY_PATH is the persistent clone already on that host (the one whose + # deploy/.env.prod is filled in and matches SharedServices/.env.prod) — + # deploy reuses it instead of a fresh checkout, since .env.prod is + # untracked and only lives there. + deploy: + name: Deploy + needs: [backend-tests, web-player-checks] + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + runs-on: [production] + timeout-minutes: 20 + env: + DEPLOY_PATH: /home/ubuntu/deploys/musify + COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml + steps: + - name: Pull latest master + run: | + set -euo pipefail + git config --global --add safe.directory "$DEPLOY_PATH" + cd "$DEPLOY_PATH" + git fetch origin master + git reset --hard origin/master + + - name: Migrate database, generate keys + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE up -d wait-for-postgres keys-init migrate + + # Both jobs verify themselves through the public auth./s3. URLs, served + # by the shared edge nginx (Infrastructure's edge/), and are idempotent. + - name: Create OIDC app and S3 bucket + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE up -d seaweedfs-init zitadel-init + for job in musify-seaweedfs-init musify-zitadel-init; do + code=$(docker wait "$job") + if [ "$code" != "0" ]; then + docker logs "$job" + echo "$job exited with $code" + exit 1 + fi + done + + - name: Build and start apps + run: | + set -euo pipefail + cd "$DEPLOY_PATH" + $COMPOSE --profile apps up -d --build api worker gateway web-player diff --git a/.gitignore b/.gitignore index 151e9d4..067b398 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ obj/ *.userosscache .vs/ artifacts/ +TestResults/ +*.trx +*.coverage # Rider / VS .idea/ @@ -25,5 +28,4 @@ Thumbs.db # Deploy secrets and runtime state deploy/keys/ deploy/secrets/ -deploy/nginx/certs/ deploy/zitadel/.output/ diff --git a/backend/Hosts/Musify.Api/WebApi.http b/backend/Hosts/Musify.Api/WebApi.http deleted file mode 100644 index 213749e..0000000 --- a/backend/Hosts/Musify.Api/WebApi.http +++ /dev/null @@ -1,6 +0,0 @@ -@WebApi_HostAddress = http://localhost:5111 - -GET {{WebApi_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/backend/Hosts/Musify.Api/AppSettings.Development.json b/backend/Hosts/Musify.Api/appsettings.Development.json similarity index 100% rename from backend/Hosts/Musify.Api/AppSettings.Development.json rename to backend/Hosts/Musify.Api/appsettings.Development.json diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/appsettings.json similarity index 100% rename from backend/Hosts/Musify.Api/AppSettings.json rename to backend/Hosts/Musify.Api/appsettings.json diff --git a/backend/Hosts/Musify.Worker/AppSettings.Development.json b/backend/Hosts/Musify.Worker/appsettings.Development.json similarity index 100% rename from backend/Hosts/Musify.Worker/AppSettings.Development.json rename to backend/Hosts/Musify.Worker/appsettings.Development.json diff --git a/backend/Hosts/Musify.Worker/AppSettings.json b/backend/Hosts/Musify.Worker/appsettings.json similarity index 100% rename from backend/Hosts/Musify.Worker/AppSettings.json rename to backend/Hosts/Musify.Worker/appsettings.json diff --git a/backend/Musify.Api.Dockerfile b/backend/Musify.Api.Dockerfile index f27651e..2d35b60 100644 --- a/backend/Musify.Api.Dockerfile +++ b/backend/Musify.Api.Dockerfile @@ -12,10 +12,6 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj COPY . . RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore -# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host -# looks for appsettings*.json. Add lowercase copies so they get loaded. -RUN cp /app/AppSettings.json /app/appsettings.json \ - && cp /app/AppSettings.Development.json /app/appsettings.Development.json FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/backend/Musify.Worker.Dockerfile b/backend/Musify.Worker.Dockerfile index b254969..dad7085 100644 --- a/backend/Musify.Worker.Dockerfile +++ b/backend/Musify.Worker.Dockerfile @@ -11,9 +11,6 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj COPY . . RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore -# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux. -RUN cp /app/AppSettings.json /app/appsettings.json \ - && cp /app/AppSettings.Development.json /app/appsettings.Development.json FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 08029c1..4b69699 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -1,11 +1,14 @@ # Production configuration for deploy/compose.yml + compose.prod.yml. # Copy to deploy/.env.prod, then: # -# 1. replace every `example.com` with your domain, +# 1. replace every `*.example.com` with your actual domains (each service +# below has its own, independent domain — they don't need to share a +# root, e.g. for DNS providers that don't allow multi-level +# subdomains), # 2. replace every CHANGE_ME with a strong random value # (`openssl rand -hex 24`), -# 3. point these DNS records at the server, all proxied/TLS-terminated the -# same way: example.com, www, api, auth, stream, s3. +# 3. point each domain below at the server and add its vhost to the shared +# edge (Infrastructure's edge/conf.d). # # Requires ../../../Infrastructure running first, on the same Docker host, # with its own `infra-net` network already created. See @@ -14,12 +17,13 @@ DOTNET_ENVIRONMENT=Production -# --- Domain ------------------------------------------------------------------ -# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it. -ROOT_DOMAIN=example.com -PUBLIC_WEB_URL=https://example.com -PUBLIC_API_URL=https://api.example.com -PUBLIC_STREAM_URL=https://stream.example.com +# --- Domains ------------------------------------------------------------- +# Public URLs. They must match the vhosts of the shared edge nginx +# (Infrastructure's edge/conf.d), which is what serves them; the domains are +# independent, they don't need to share a root. +PUBLIC_WEB_URL=https://musify.example.com +PUBLIC_API_URL=https://musifyapi.example.com +PUBLIC_STREAM_URL=https://musifystream.example.com PUBLIC_AUTH_URL=https://auth.example.com S3_PUBLIC_URL=https://s3.example.com @@ -62,6 +66,4 @@ SESSION_SECRET=CHANGE_ME AUTH_ALLOW_INSECURE_HTTP=false # --- Misc -------------------------------------------------------------------- -AUTH_REQUIRE_HTTPS=true -# Largest request body nginx accepts (audio uploads go through the web player). -MAX_UPLOAD_SIZE=1024m +AUTH_REQUIRE_HTTPS=true \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md index cf08e32..e7be331 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,7 +13,7 @@ wherever you actually cloned it. |---|---| | `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. | -| `compose.prod.yml` | Production overlay: adds the nginx edge and joins Infrastructure's `infra-net` network. | +| `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind @@ -113,26 +113,26 @@ SeaweedFS is done from the `Infrastructure` repo instead. ## Production -On a server with a domain: +Musify doesn't run its own nginx: the shared edge in Infrastructure +(`edge/`) owns host ports 80/443, terminates TLS for every project's domains +and proxies to Musify's containers over `infra-net`. On a server with +domains: 1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled in. It creates the `infra-net` network this stack joins. -2. From this repository's root: +2. Bring up its `edge/` (see Infrastructure's README) and add Musify's + vhosts and certificate there (`edge/conf.d/musify.conf`). The five + public hostnames (web, api, stream, auth, s3) are independent: they + don't need to share a root domain. +3. From this repository's root: ```sh cp deploy/.env.prod.example deploy/.env.prod ``` -Fill it in: - -1. Replace every `example.com` with your domain and every `CHANGE_ME` with a - random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`. -2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin - certificate nginx serves. The stack assumes Cloudflare in *Full (strict)* - mode in front of it, so a Cloudflare Origin Certificate is enough; any - certificate valid for the five hostnames works. -3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server, - proxied and TLS-terminated the same way. + Replace every `*.example.com` with your domains (they must match the + edge's vhosts) and every `CHANGE_ME` with a random value + (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`. Then: @@ -141,14 +141,45 @@ docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compo docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build ``` -The first command starts Musify's bootstrap jobs and the nginx edge; the -second builds and starts the applications, already wired to the OIDC client -ids the first one produced. Both are idempotent, so running them again -deploys a new version (`... --profile apps up -d --build` alone is enough for -an update once the stack already exists). +The first command starts Musify's bootstrap jobs (`zitadel-init` and +`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.` +URLs, so the edge has to be serving them by then); the second builds and +starts the applications, already wired to the OIDC client ids the first one +produced. Both are idempotent, so running them again deploys a new version +(`... --profile apps up -d --build` alone is enough for an update once the +stack already exists). -Only nginx publishes ports (80/443). Every other Musify service, and all of -Infrastructure's, is reachable only on the internal `infra-net` network. +Nothing in this stack publishes a port; everything is reachable only on the +internal `infra-net` network, through the edge. + +### Continuous deployment + +`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every +push/PR, and on a successful push to `master` deploys automatically. The +deploy job runs on `gitea-runner`, the self-hosted Gitea Actions runner on +the production host (`~/gitea/docker-compose.yml`), configured by +`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned +to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`, +`ubuntu-latest` is `node:22` (JS actions need Node inside the job +container), and a `production:host` label runs steps natively. + +Native mode means the label's steps execute inside the runner container +itself rather than a fresh one. That container has `/var/run/docker.sock` +mounted, so `docker`/`docker compose` there still control the host's real +Docker daemon (sibling containers, "Docker outside of Docker"). Its compose +service also bind-mounts `/home/ubuntu/deploys` into itself at the *same* +path, which matters because compose's own relative bind mounts (`./keys`) +are resolved by the host daemon against that literal path; mounting it +anywhere else would silently bind the wrong (empty) directory. + +`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`) +is a persistent clone on that host, reused instead of a fresh checkout each +run since `deploy/.env.prod` is untracked and only lives there. +`/home/ubuntu/deploys/SharedServices` is the sibling clone of the +`Infrastructure` repo (shared services + `edge/`), already running; its +`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own +`deploy/.env.prod` by design (see that repo's README). The edge is deployed +from that repo, not from this pipeline. --- @@ -159,13 +190,9 @@ deploy/ ├─ compose.yml # Musify's own services: wait-for-postgres, keys-init, │ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player ├─ compose.dev.yml # dev overlay: host ports -├─ compose.prod.yml # prod overlay: nginx edge, joins infra-net +├─ compose.prod.yml # prod overlay: joins infra-net ├─ .env.example / .env.prod.example -├─ keys/ # RS256 stream-ticket key pair (generated) -└─ nginx/ - ├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst - ├─ snippets/proxy.conf # proxy headers shared by every vhost - └─ certs/ # origin.pem + origin.key (not versioned) +└─ keys/ # RS256 stream-ticket key pair (generated) ``` The application images are built from `backend/Musify.*.Dockerfile` (build @@ -180,9 +207,6 @@ and `web-player/Dockerfile`. the comments in `.env.example`). - **Zitadel** runs `start-from-init`, which is idempotent: the same command works on an empty volume and on an existing one. -- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`; - on case-sensitive Linux the host looks for `appsettings*.json`, so their - Dockerfiles create lowercase copies at publish time. - **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they reuse the existing project/apps/bucket by name, and `migrate` is a normal `dotnet ef database update`, a no-op once the schema is current. diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index f4fd18d..53e497d 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -1,11 +1,10 @@ name: musify-prod -# Production overlay: adds the nginx edge and joins Infrastructure's shared -# `infra-net` network so this project's containers can reach Postgres, -# Zitadel, SeaweedFS, RabbitMQ and Jaeger by container name. None of them -# run as part of this compose project anymore (see -# ../../../Infrastructure/README.md, which must already be up). Nothing but -# nginx publishes a port; every other service is reachable only on infra-net. +# Production overlay: joins Infrastructure's shared `infra-net` network so +# this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ +# and Jaeger by container name, and so the shared edge nginx (Infrastructure's +# `edge/`, which owns host ports 80/443 and terminates TLS for every domain) +# can reach api, gateway and web-player. Nothing here publishes a port. # # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build @@ -20,31 +19,3 @@ services: zitadel-init: volumes: - ./.env.prod:/workspace/.env - - # Every vhost proxies through `set $upstream ...; proxy_pass $upstream;` - # with the embedded Docker DNS resolver (see nginx/templates), so nginx - # does not need api/gateway/web-player to exist at its own startup, only - # at request time. That is what lets it start in the FIRST command - # (profile-gated services aren't part of that invocation yet) and still be - # reachable at the auth domain for zitadel-init to provision through. - nginx: - image: nginx:1.27-alpine - container_name: musify-nginx - environment: - # The entrypoint renders /etc/nginx/templates/*.template with envsubst. - # The filter keeps it from eating nginx's own $host / $remote_addr. - MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN} - MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m} - NGINX_ENVSUBST_FILTER: ^MUSIFY_ - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/templates:/etc/nginx/templates:ro - - ./nginx/snippets:/etc/nginx/snippets:ro - - ./nginx/certs:/etc/nginx/certs:ro - - nginx_cache:/var/cache/nginx - restart: unless-stopped - -volumes: - nginx_cache: diff --git a/deploy/compose.yml b/deploy/compose.yml index 3a055aa..c36778f 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -19,7 +19,7 @@ # file, so the apps start with real values. See deploy/README.md. # Backend settings that are identical for the API and the Worker. ASP.NET Core -# maps `__` to `:`, so these override the matching AppSettings.json sections. +# maps `__` to `:`, so these override the matching appsettings.json sections. # POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments # (host.docker.internal in dev, the container name over infra-net in prod, # see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL diff --git a/deploy/nginx/snippets/proxy.conf b/deploy/nginx/snippets/proxy.conf deleted file mode 100644 index 934e050..0000000 --- a/deploy/nginx/snippets/proxy.conf +++ /dev/null @@ -1,9 +0,0 @@ -# Shared proxy settings for every Musify vhost. -proxy_http_version 1.1; -proxy_set_header Host $host; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto https; -proxy_set_header X-Forwarded-Host $host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection $connection_upgrade; diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template deleted file mode 100644 index ed2ac8a..0000000 --- a/deploy/nginx/templates/default.conf.template +++ /dev/null @@ -1,119 +0,0 @@ -# Musify edge. The nginx entrypoint renders this with envsubst into -# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the -# MUSIFY_* variables set in compose.prod.yml. -# -# Cloudflare (Full strict) terminates the public edge; this box only needs to -# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to -# a compose service by name through the embedded Docker DNS resolver, so nginx -# re-resolves it instead of caching the IP of a container that may be recreated. - -resolver 127.0.0.11 valid=10s; - -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} - -server { - listen 80; - listen [::]:80; - server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN} - api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN} - stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN}; - return 301 https://$host$request_uri; -} - -# Web player (SvelteKit, adapter-node). Uploads are posted to its own form -# actions, so it needs the large body size too. -server { - listen 443 ssl; - http2 on; - server_name ${MUSIFY_ROOT_DOMAIN}; - client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - location / { - set $upstream http://web-player:3000; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - } -} - -server { - listen 443 ssl; - http2 on; - server_name www.${MUSIFY_ROOT_DOMAIN}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri; -} - -server { - listen 443 ssl; - http2 on; - server_name api.${MUSIFY_ROOT_DOMAIN}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - location / { - set $upstream http://api:5111; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - } -} - -# Audio bytes. Buffering off keeps range requests (seeking) responsive. -server { - listen 443 ssl; - http2 on; - server_name stream.${MUSIFY_ROOT_DOMAIN}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - location / { - set $upstream http://gateway:8081; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - proxy_buffering off; - } -} - -server { - listen 443 ssl; - http2 on; - server_name auth.${MUSIFY_ROOT_DOMAIN}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - location / { - set $upstream http://zitadel:8080; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - grpc_set_header Host $host; - } -} - -# Presigned S3 PUT/GET straight from the browser. -server { - listen 443 ssl; - http2 on; - server_name s3.${MUSIFY_ROOT_DOMAIN}; - client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE}; - - ssl_certificate /etc/nginx/certs/origin.pem; - ssl_certificate_key /etc/nginx/certs/origin.key; - - location / { - set $upstream http://seaweedfs:8333; - proxy_pass $upstream; - include /etc/nginx/snippets/proxy.conf; - proxy_request_buffering off; - } -} diff --git a/docs/development.md b/docs/development.md index 3775245..9f9824b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -134,10 +134,10 @@ omits the `` list when there are no errors, unlike AWS S3), and (load → modify → save) because it ran before EF had detected the pending changes. -## Config (AppSettings) +## Config (appsettings) -Convention: `AppSettings.json` is the template, with empty or neutral -values; `AppSettings.Development.json` has the real dev values; in Docker +Convention: `appsettings.json` is the template, with empty or neutral +values; `appsettings.Development.json` has the real dev values; in Docker everything is overridden with environment variables (`Section__Key`) from `deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication` values go into user-secrets (not the repo). Copy them from `deploy/.env` diff --git a/docs/media-processing.md b/docs/media-processing.md index 8de9e30..8ee9836 100644 --- a/docs/media-processing.md +++ b/docs/media-processing.md @@ -36,7 +36,7 @@ Grouped by area: ## What the Worker needs configured -The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `AppSettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`. +The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`. ## Background jobs diff --git a/web-player/.prettierignore b/web-player/.prettierignore index 1f10deb..a69f55c 100644 --- a/web-player/.prettierignore +++ b/web-player/.prettierignore @@ -9,3 +9,5 @@ package package-lock.json src/lib/api/schema.d.ts static +.agents +skills-lock.json diff --git a/web-player/.prettierrc b/web-player/.prettierrc index 664d576..ee3f14c 100644 --- a/web-player/.prettierrc +++ b/web-player/.prettierrc @@ -3,6 +3,7 @@ "singleQuote": true, "trailingComma": "none", "printWidth": 100, + "endOfLine": "auto", "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "tailwindStylesheet": "./src/routes/layout.css", "overrides": [ diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index bd69e59..20035f7 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -1,56 +1,79 @@ -# Musify — web-player +# Musify: web-player -Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**. +Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill. ## Stack -SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`). +SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`). -No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`). +There's no `svelte.config.js` (config is inline in `vite.config.ts`) and no `tailwind.config.js` (config lives in `src/routes/layout.css`). -## Comandos +## Commands ```sh -npm run dev # servidor de desarrollo (puerto 5173) +npm run dev # dev server (port 5173) npm run build -npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio -npm run format # prettier --write . -npm run lint # prettier --check . && eslint . && npm run lint:tokens -npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo) +npm run check # svelte-check, must exit 0 errors before closing a change +npm run format # prettier --write . +npm run lint # prettier --check . && eslint . && npm run lint:tokens +npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below) ``` -## Reglas +## Rules -- **Sin comentarios** en el código salvo que sean imprescindibles. -- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio. -- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave. -- **Server-only bajo `$lib/server/`**; nunca importado desde cliente. -- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`. -- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`. -- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita. +- **No comments** in code unless essential. +- `npm run lint` and `npm run check` must exit clean before closing a change. +- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed. +- **Server only code goes under `$lib/server/`**, and it's never imported from client code. +- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop. +- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`. +- Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`. -## Estilos +## Folder structure -El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica". +- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`. +- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search. +- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`. +- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader. +- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls. +- `src/lib/components/ui/` holds the design system components, split into five categories: + - `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on). + - `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on). + - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). + - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). + - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. +- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`. -- **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas. -- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`). -- **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto. -- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`). -- **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`). -- **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token. -- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. -- Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada). +## Styles + +The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one. + +- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`. +- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`). +- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. +- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`). +- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth. +- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. +- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. +- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. + +## Motion + +Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding. + +Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items. + +The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. ## Auth -`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`. +`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`. -Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`. +Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`. ## Backend API -Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server: +Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action: ```ts import { createApiClient } from '$lib/server/api'; @@ -58,4 +81,4 @@ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefine const { data, error: err } = await api.GET('/tracks'); ``` -Devuelve `{ data, error }` (estilo ErrorOr), no lanza. Los tipos viven en `src/lib/api/schema.d.ts` y se regeneran con `npm run gen:api` (requiere la API dev levantada en `API_BASE_URL`, que expone `/openapi/v1.json` solo en Development). +It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development). diff --git a/web-player/docs/frontend-refactor-plan.md b/web-player/docs/frontend-refactor-plan.md deleted file mode 100644 index 8cc0074..0000000 --- a/web-player/docs/frontend-refactor-plan.md +++ /dev/null @@ -1,931 +0,0 @@ -# Plan — Refactor y unificación del frontend (web-player) - -> Plan de trabajo para agentes/IA con contexto limitado (~128k). Sustituye a -> `design-system-refactor-plan.md` (cuyo contenido ya está mayormente aplicado). -> Basado en una lectura completa de **todos** los `.svelte`, `.css` y `.ts` de cliente -> de `web-player/src` (2026-09-18, rama `design-prot`). - -## Cómo usar este documento - -1. **Lee siempre la sección A (Contexto común)** — es corta y todas las partes la asumen. -2. Elige **una parte** (P0…P14). Cada parte indica: objetivo, prerrequisitos, archivos - a leer, tareas, API exacta de lo que se crea, tabla de mapeo y criterios de cierre. -3. Una parte = un commit/PR. No mezcles partes. -4. Si una parte necesita algo de otra (p. ej. un token o un helper) y **no existe - todavía**, créalo tú siguiendo la especificación de la sección B — están descritos con - ruta y API exactas para que cualquier parte pueda crearlos de forma idéntica. -5. Al terminar una parte, marca su casilla en la sección D (Registro). - -Orden recomendado (las flechas son dependencias **blandas**, ver punto 4): - -``` -P0 → P1 → P2 - ↘ P3 (Artwork/Avatar) ─┐ - ↘ P4 (Botones) ───────┤ - ↘ P5 (Helpers TS) ─────┼→ P6 (Menús) → P7 (Filas) → P8 (Tablas) → P9 (Tarjetas) - └→ P10 (Cabeceras) → P11 (Formularios) → P12 (Chrome) → P13 (Páginas) → P14 (Barrido final) -``` - ---- - -## A. Contexto común (leer siempre) - -### A.1 Stack y reglas del proyecto (de `web-player/CLAUDE.md`) - -- SvelteKit 2 + **Svelte 5 runes** (`$props()` con `interface Props`, `$state`, - `$derived`, `$effect`; nada de `export let` ni `$:`; `{#each}` siempre con clave). -- **Tailwind 4**: no hay `tailwind.config.js`; la config vive en `src/routes/layout.css` - (`@theme`, `@utility`). Tokens de color crudos en `src/lib/theme/theme.css` (`--mf-*`), - tokens dinámicos por hue en `src/lib/theme/tokens.ts` (`buildThemeTokens`). -- Sin comentarios en código salvo imprescindibles. -- Cierre de cada parte: `npm run check` y `npm run lint` **limpios** (desde `web-player/`). -- Iconos: `@lucide/svelte/icons/`. - -### A.2 Gotchas conocidos (no reintroducirlos) - -1. **Tokens animados por hue** (`--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`, - `--mf-hairline`, `--mf-hero-bg`, `--mf-modal-glow`, `--mf-spotlight-bg`, - `--mf-logo-grad`, `--mf-cover-grad`, `--mf-accent*`, `--mf-ambient`) se aplican como - **estilo inline** en `` desde `+layout.svelte`. Un override en - `:root[data-theme='light']` **no gana**: la variante clara va en `buildThemeTokens()`. -2. **Conflictos de clases Tailwind**: nunca `class="bg-a {cond ? 'bg-b' : ''}"`. Siempre - mutuamente excluyente: `class={cond ? 'bg-b' : 'bg-a'}`. Overrides de la clase base de - un componente compartido → prop/variante, no `!important`. -3. Texto sin clase de tamaño hereda 16px del navegador. Todo texto visible debe tener una - receta tipográfica (ver B.1.6). -4. `--mf-ink` es "texto sobre CTA" y se invierte en claro; `--mf-tile-shade`, - `--mf-cover-grad`, `--mf-liked-grad` son "arte" (no invertir). -5. Referencia visual: **Inicio** (`src/routes/+page.svelte`). Un cambio de refactor no - debe cambiar el aspecto salvo donde la parte lo diga explícitamente (columna "Δ"). - -### A.3 Verificación de cada parte - -```sh -cd web-player -npm run check -npm run lint -``` - -Visual (Browser pane, `.claude/launch.json` → `musify-web`; login de dev en la memoria del -proyecto): abre cada página tocada **y** Inicio; prueba estado activo (una canción -sonando), hover, móvil (375px) y modo claro (menú de cuenta → "Modo blanco"). - -### A.4 Línea base medida (para saber si avanzas) - -| Métrica | Hoy | -| ------------------------------------------------- | --------------------------------------- | -| Componentes `.svelte` (lib + rutas `components/`) | 61 | -| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | -| Clases arbitrarias `x-[…]` | 44 | -| Espaciados fraccionarios (`h-3.75`, `gap-2.75`…) | ~190 apariciones, 60 valores distintos | -| Radios distintos usados | 13 (6 tokens + 7 de Tailwind por defecto) | -| `strokeWidth` distintos | 13 valores | -| Atributos `style="…"` | 24 | -| Pluralizaciones a mano `=== 1 ?` | 15 | -| Errores de `eslint` | 2 (`liked/+page.svelte`) | - -Objetivo global: ~61 → ~45 componentes, −1.500/−2.000 líneas, 0 arbitrarios fuera de una -lista blanca, 1 escala por dimensión. - ---- - -## B. Especificaciones compartidas (fuente única) - -Cualquier parte que necesite algo de aquí y no exista, lo crea **exactamente así**. - -### B.1 Tokens (en `src/routes/layout.css`, dentro de `@theme`) - -Tailwind 4 genera utilidades a partir de estos namespaces: `--radius-*` → `rounded-*`, -`--spacing-*` → `size-* w-* h-* p-* m-* gap-*`, `--container-*` → `max-w-*`, -`--tracking-*` → `tracking-*`, `--leading-*` → `leading-*`. - -#### B.1.1 Radios (`--radius-*`) - -| Token | Valor | Para qué | -| ----------------- | ---------- | ----------------------------------------- | -| `rounded-tag` | `0.25rem` | badges, celdas mini (ExplicitBadge, índice) | -| `rounded-thumb` | `0.5rem` | carátulas ≤ 36px, logo | -| `rounded-control` | `0.625rem` | inputs, filas, items de menú, botones | -| `rounded-art` | `0.8125rem`| carátulas 42–88px, tarjetas de grid | -| `rounded-art-lg` | `1.125rem` | carátulas ≥ 136px, dropzones | -| `rounded-panel` | `1.125rem` | contenedores, menús, modales, tiles | -| `rounded-panel-lg`| `1.375rem` | hero, spotlight, "mejor resultado" | -| `rounded-full` | (Tailwind) | pastillas, avatares, botones redondos | - -Eliminar `--radius-art-round` (0 usos). Mapeo de lo existente: - -| Hoy | px | Usos | Pasa a | Δ | -| ---------------------------- | -- | ---- | ----------------- | ---- | -| `rounded-sm`, `rounded` | 4 | 2 | `rounded-tag` | 0 | -| `rounded-md` | 6 | 1 | `rounded-thumb` | +2px | -| `rounded-lg` | 8 | 4 | `rounded-thumb` | 0 | -| `rounded-xl` | 12 | 6 | `rounded-control` | −2px | -| `rounded-2xl` | 16 | 8 | `rounded-art-lg` (arte) / `rounded-panel` (contenedor) | +2px | -| `rounded-3xl` | 24 | 1 | `rounded-panel-lg`| −2px | - -#### B.1.2 Tamaños de carátula/arte (`--spacing-cover-*`) - -| Token | Valor | Usos actuales que absorbe (Δ) | -| ---------------- | ------------------------------ | --------------------------------------------------------------- | -| `cover-xs` | `2.25rem` (36) | thumbs de menús `h-9`; sidebar `h-8.5` (+2) | -| `cover-sm` | `2.625rem` (42) | filas de tabla `h-10.5`; cola y "Populares" `h-10` (+2); player móvil `h-11` (−2) | -| `cover-md` | `3rem` (48) | player escritorio `md:h-12` | -| `cover-lg` | `3.5rem` (56) | "Continuar" `h-14`; filas de búsqueda `h-13` (+4); tarjeta playlist de Inicio `h-15.5` (−6) | -| `cover-xl` | `5.5rem` (88) | "Mejor resultado" `h-22` | -| `cover-2xl` | `8.5rem` (136) | spotlight de Inicio `h-34` | -| `cover-hero` | `9rem` (144) | cabeceras de colección, dropzones, avatar de perfil | -| `cover-hero-sm` | `10.25rem` (164) | cabeceras de colección a partir de `sm:` | - -Uso: `size-cover-sm` (Tailwind 4 `size-*` = `width`+`height`). **Nunca** `h-X w-X` en -pareja: siempre `size-*`. - -#### B.1.3 Iconos (`--spacing-icon-*`) y trazo - -| Token | Valor | Absorbe | -| ---------- | ---------- | ---------------------------------------------- | -| `icon-xs` | `0.875rem` | `h-3.5` (14) | -| `icon-sm` | `1rem` | `h-3.75` (15), `h-4` (16), `h-4.25` (17) | -| `icon-md` | `1.125rem` | `h-4.5` (18) | -| `icon-lg` | `1.25rem` | `h-5` (20), `h-6` (24, login móvil) | -| `icon-xl` | `2.25rem` | `h-9`/`h-10` (estados vacíos, dropzone, Me gusta) | - -Trazo: en `layout.css`, fuera de `@theme`: - -```css -@layer base { - svg.lucide { - stroke-width: 1.8; - } -} -@utility stroke-thin { stroke-width: 1.3; } -@utility stroke-regular { stroke-width: 1.8; } -@utility stroke-bold { stroke-width: 2.5; } -``` - -La propiedad CSS `stroke-width` gana al atributo de presentación que pone Lucide, así que -el prop `strokeWidth` deja de tener efecto: **se elimina de todos los usos**. Mapeo: -`1–1.4` → `stroke-thin`, `1.5–2` → (nada, default), `2.5–3` → `stroke-bold`, -`strokeWidth={0}` + `fill` → clase `stroke-0`. Verifica en el navegador (DevTools → -Computed `stroke-width`) que el default aplica antes de migrar en masa. - -#### B.1.4 Anchos (`--container-*`) y layout - -| Token | Valor | Absorbe | -| --------------------- | -------- | --------------------------------------------------------- | -| `max-w-prose-sm` | `26rem` | `max-w-105` (error), `max-w-108` (hero p), `max-w-110`, `max-w-115` (sin resultados) | -| `max-w-hero` | `34rem` | `max-w-135` (hero, seekbar) | -| `max-w-search` | `30rem` | `max-w-120` (buscador TopBar) | -| `max-w-cta` | `20rem` | `max-w-80` (botones login) | - -Ya existen y se mantienen en `theme.css`: `--mf-sidebar-w`, `--mf-queue-w`, `--mf-nav-h`, -`--mf-player-h`, `--mf-safe-b`. Añadir: - -```css ---mf-player-side-w: 16rem; /* w-64 de PlayerBar/PlayerExtras */ ---mf-menu-w: 15rem; /* w-60 de menús contextuales (= MENU_WIDTH 240) */ ---mf-submenu-w: 16rem; /* w-64 (= SUBMENU_WIDTH 256) */ ---mf-range-h: 3.5px; /* barra de progreso y volumen */ ---mf-range-thumb: 11px; ---mf-volume-w: 84px; -``` - -Las constantes TS `MENU_WIDTH`/`SUBMENU_WIDTH` (duplicadas hoy en `TrackContextMenu` y -`AlbumContextMenu`) pasan a `src/lib/config.ts` con un comentario-no: el nombre basta. - -#### B.1.5 Capas (`z-index`) - -En `theme.css` `:root`: `--z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40; ---z-submenu: 50; --z-modal: 60; --z-grain: 80;`. Uso: `z-(--z-menu)`. Mapeo 1:1 con los -valores actuales (`z-10`,`z-20`,`z-30`,`z-40`,`z-50`,`z-[60]`, `body::after` 80). Nota: -`MobileHeader` y `PlayerDock` usan `z-40` pero son "sticky", no menú → `--z-sticky` -(verificar que el menú de cuenta sigue encima del header: menú 40 > sticky 20). - -#### B.1.6 Tipografía (recetas `@utility` en `layout.css`) - -Escala tipográfica = la de Tailwind (`text-xs`…`text-4xl`), **sin tamaños arbitrarios**. -Añadir en `@theme`: `--tracking-display: -0.02em; --tracking-eyebrow: 0.1em; ---leading-body: 1.65;`. Recetas (el color **no** va en la receta, se añade aparte): - -```css -@utility text-display-1 { @apply font-display text-3xl font-medium tracking-tight sm:text-4xl; } -@utility text-display-2 { @apply font-display text-2xl font-medium tracking-tight; } -@utility text-display-3 { @apply font-display text-xl font-medium tracking-display; } -@utility text-display-4 { @apply font-display text-lg font-medium tracking-tight; } -@utility text-eyebrow { @apply text-xs font-medium tracking-widest uppercase; } -@utility text-body { @apply text-sm leading-body; } -@utility text-count { @apply text-xs tabular-nums; } -``` - -> Si `@apply` con variante `sm:` dentro de `@utility` no compila, usa -> `@media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); }` -> como hace `page-x`. - -Mapeo de lo existente: - -| Patrón hoy | Receta | -| -------------------------------------------------------------------------------------------- | ------------------------------ | -| `font-display text-3xl … sm:text-4xl` (PageHeader, CollectionHeader, hero Inicio, h1 búsqueda `text-2xl sm:text-3xl font-semibold tracking-[-0.03em]`) | `text-display-1` (Δ búsqueda: +1 paso) | -| `font-display text-2xl` (stats Inicio, título spotlight, "¡Subida!") | `text-display-2` | -| `font-display text-xl … tracking-[-0.02em]` (Modal, EmptyState, +error, "Sin resultados", diálogos de borrado) | `text-display-3` (todos `font-medium`) | -| `font-display text-lg` (SectionHeading, "Mejor resultado", Queue "En cola" `text-base`) | `text-display-4` | -| `text-xs font-medium tracking-widest uppercase` (+ variantes `tracking-[0.14em]`, `[0.16em]`, `tracking-wider`, `text-sm font-semibold`) | `text-eyebrow` | -| `text-sm leading-[1.65] text-fg-2` | `text-body text-fg-2` | -| `text-xs text-muted tabular-nums` | `text-count text-muted` | -| `tracking-[-0.01em]` (MixTile) | quitar | - -#### B.1.7 Recetas de superficie y movimiento (`@utility` en `layout.css`) - -```css -@utility theme-transition { transition: background 0.5s ease-out; } /* 8× transition-[background] duration-500 */ -@utility glass-bar { /* PlayerBar ×2, TabsBar */ - background-image: var(--mf-bar-bg); - border-top: 1px solid var(--mf-hairline); - backdrop-filter: blur(24px); - transition: background 0.5s ease-out; -} -@utility grid-cards { /* MediaGrid por defecto: 150/165 */ - display: grid; - gap: --spacing(3); - grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); - @media (width >= 40rem) { - gap: --spacing(4); - grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr)); - } -} -@utility grid-cards-lg { /* /playlists y /u/[id]: 180/210 */ - display: grid; - gap: --spacing(3); - grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr)); - @media (width >= 40rem) { - gap: --spacing(4); - grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr)); - } -} -@utility grid-tiles { /* mixes de Inicio: 164 */ - display: grid; - gap: --spacing(5); - grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr)); -} -@utility grid-wide { /* playlists de Inicio (258) y géneros (268) → 16.5rem, Δ ±6px */ - display: grid; - gap: --spacing(3.5); - grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); -} -``` - -`.glass-panel` ya existe; `Modal` debe usarla en lugar de su `style=` inline duplicado. - -Stagger (sustituye los 5 `style="animation-delay:…"` con 40 **o** 45ms): - -```css -.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); } -``` - -Uso: `style="--i:{i}"`. Único `style=` permitido junto con los de B.4. - -#### B.1.8 Colores: limpieza - -- Borrar utilidades sin uso: `--color-accent-dim`, `--color-accent-glow`, - `--color-accent-hair`, `--color-accent-line`, `--color-accent-muted` (0 usos como - clase; `--mf-accent-dim` sí se usa en `::selection`, se mantiene la variable `--mf-`). -- `--color-on-accent` es alias de `--mf-on-art` (igual que `--color-on-art`) → borrar y - usar `on-art`. `--color-hover` = `--mf-surface-2` (igual que `surface-2`) → mantener - **solo** `hover` como semántico de hover y dejar `surface-2` para fondos en reposo. -- `--mf-sidebar-bg` no lo usa ningún componente (Sidebar usa `bg-bg`) → borrar de - `theme.css` y de `buildThemeTokens`. -- Botón: `--mf-btn-bg` = `--mf-surface-2`; `--mf-btn-secondary-bg` solo difiere en modo - claro. Exponer en `@theme` `--color-btn`, `--color-btn-hover`, `--color-btn-glass`, - `--color-btn-glass-hover` y eliminar los `bg-[var(--mf-btn-…)]` de `Button.svelte`. -- Scrollbar: `rgba(255,255,255,.18/.32)` fijo en `layout.css` (roto en claro) → tokens - `--mf-scrollbar`, `--mf-scrollbar-hover` con variante en `:root[data-theme='light']`. -- `MixTile`: `to-black/42`, `bg-ink/76`, `border-on-art/12` → `to-scrim/40`, receta de - `PlayButton` (B.3). - -### B.2 Helpers TypeScript (fuente única) - -| Archivo (nuevo) | API | Sustituye | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | -| `src/lib/actions/pressable.ts` | `pressable(node, fn)` → añade `role="button"`, `tabindex=0`, click + Enter/Espacio | 7 copias de `onkeydown` Enter/Space (Inicio ×5, Queue, TrackTitleCell) | -| `src/lib/actions/clickOutside.ts` | `clickOutside(node, fn)` | `onDocumentClick` de `AccountMenu` | -| `src/lib/toggle.svelte.ts` | `createToggle(initial=false)` → `{ get open, show(), close(), toggle() }` | `playlists.svelte.ts` y `player/queuePanel.svelte.ts` (idénticos) | -| `src/lib/format.ts` (ampliar) | `plural(n, one, many)` → `"1 canción"`; `fmtCount(n,'canción','canciones')`; **minúscula siempre** | 15 pluralizaciones a mano, con mayúsculas inconsistentes ("Canción"/"canción") | -| `src/lib/navigation.svelte.ts` (ampliar) | `searchHref(q)`; `appNavLinks(user, counts?)` → `{href,label,icon,count?,primary:boolean}[]` | `buildHref` en TopBar y explore; listas de links de Sidebar y TabsBar | -| `src/lib/player/player.svelte.ts` (ampliar) | `isQueueCurrent(items)`, `playAllOrToggle(items)`, `playShuffled(items)`; renombrar `addToQueue` → `playNextItem` | `playAll`/`isCurrentQueue` en álbum, playlist, liked, mixes, spotlight de Inicio (5 copias) | -| `src/lib/player/player.svelte.ts` | `toQueueItem(track)` único (normaliza `duration`/`listensCount` a `number`) | mapeos a mano en `recentlyPlayed.ts` (×2), `liked.svelte.ts` (×2), `liked/+page`, `tracks.ts#targetForQueueItem` y los `Number(…)` dispersos | -| `src/lib/menus.svelte.ts` | `createTrackMenu()` → `{ state, open(e, track), close(), playNext() }` | estado + handler de menú contextual en Inicio, Explorar, Mezcla | - -### B.3 Componentes nuevos (API) - -| Componente (`$lib/components/ui/`) | Props | Sustituye / absorbe | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| `Artwork.svelte` | `src?`, `trackIds?: (string\|number)[]`, `size: 'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'2xl'\|'hero'`, `shape?: 'auto'\|'round'`, `imageSize?: 'small'\|'medium'\|'large'` (auto por `size`), `fallback?: 'music'\|'gradient'\|'none'`, `class?`, `children?` (overlay) | `Cover`, `PlaylistArt`, `MixArt`; radio automático por tamaño (xs→thumb, sm–xl→art/control, 2xl/hero→art-lg). Prioridad: `src` → mosaico si ≥4 `trackIds` → primera carátula → fallback. Un único `onerror`. | -| `Avatar.svelte` | `name`, `src?`, `size: 'sm'\|'md'\|'lg'\|'hero'`, `class?` | iniciales/imagen en TopBar, MobileHeader, Explorar (×2), `ArtistAvatar` | -| `ListRow.svelte` | `href?` \| `onclick?`, `oncontextmenu?`, `active?`, `size?: 'sm'\|'md'\|'lg'`, `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `meta?`, `leading?: Snippet`, `art: Snippet<[string]>` \| artwork props, `trailing?: Snippet` | `SearchResultRow` + 7 filas a mano (ver P7) | -| `MediaIdentity.svelte` | `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `active?`, `art?: Snippet` \| `artwork` props, `titleClass?` | `TrackTitleCell` (renombrado + generalizado) y el bloque de `player/TrackInfo` | -| `TrackList.svelte` | `tracks: TrackLike[]`, `columns?: ('album'\|'added'\|'uploaded'\|'plays')[]`, `index?`, `onPlay(i)`, `rowAction?: {action, icon, label}` \| `rowActionSnippet?`, `oncontextmenu?(e, track)` | `TrackTable`+`TrackRow`+`TrackIndexCell`+`TrackMeta` y las 6 tablas (P8) | -| `ContextMenu.svelte` | `x`, `y`, `openLeft`, `onClose`, `items: {icon,label,onclick}[]`, `playlistAction?: {action: string, fields: Record, label}`, `playlists` | `TrackContextMenu` + `AlbumContextMenu` (90% idénticos) | -| `PlayButton.svelte` | `playing?`, `size: 'sm'\|'md'\|'lg'`, `variant: 'strong'\|'glass'`, `label`, `onclick?`, `as?: 'button'\|'span'` | play de TransportControls, "Mejor resultado", overlay de MixTile | -| `ConfirmDialog.svelte` | `open`, `onClose`, `title`, `description`, `confirmLabel`, `action` (form POST) | 2 modales de borrado con estilos distintos | -| `Slider.svelte` | `value`, `max`, `label`, `oninput`, `variant: 'seek'\|'volume'` | `SeekBar` (click-only) + `VolumeControl` (`` - (serializado con un helper `tokensToCss(tokens)` en `tokens.ts`). -3. Borrar de `theme.css` todos los tokens que genera `buildThemeTokens` (dark y light). - `theme.css` queda solo con neutrales fijos, superficies, texto, layout y z-index. -4. `+layout.svelte`: los dos `div` de fondo con `style=` → `@utility app-backdrop` / - `app-vignette` en `layout.css`. `main` con `padding-bottom: calc(…)` inline → clase - con `--mf-dock-h` que se activa con `data-has-track` en el propio `main`. - -Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando -el hue; check+lint. - ---- - -### P3 — `Artwork` y `Avatar` (arte unificado) - -**Objetivo:** un único componente de carátula y uno de avatar. - -Hallazgos: -- `Cover`, `PlaylistArt` y `MixArt` implementan la misma cascada (imagen → mosaico 2×2 → - primera carátula → placeholder). `MixArt` ≡ `PlaylistArt` con - `trackIds = items.map(i => i.trackId)`. -- `AlbumCard` pasa el **id del álbum** como `trackId` a `Cover` solo para poder usar `src` - (hack). -- Avatar "imagen o inicial" reimplementado en `TopBar`, `MobileHeader`, Explorar (mejor - resultado + fila de usuario) y `ArtistAvatar` (con rayado). -- Cada uso decide su propio radio (`rounded-lg`, `rounded-xl`, `rounded-2xl`, - `rounded-control`, `rounded-art-lg`…) y su tamaño (`h-8.5`, `h-9`, `h-10`, `h-10.5`, - `h-11 md:h-12`, `h-13`, `h-14`, `h-15.5`, `h-22`, `h-34`, `h-36 sm:h-41`). - -Tareas: -1. Crear `Artwork.svelte` y `Avatar.svelte` según B.3 usando tokens B.1.1/B.1.2. - `size` decide dimensión **y** radio; `class` solo para layout (`shrink-0`, márgenes). -2. Migrar **todos** los usos (buscar `Cover`, `PlaylistArt`, `MixArt`, `ArtistAvatar`, - `user.picture`, `profilePictureUrl`) aplicando la tabla B.1.2. -3. `ArtistAvatar` → en `u/[id]/+page.svelte` usar `` (el nombre ya lo - muestra la cabecera; el label bajo el avatar era redundante — **Δ visible**, confirmar). -4. Borrar `Cover.svelte`, `PlaylistArt.svelte`, `MixArt.svelte`, `ArtistAvatar.svelte`. - -Cierre: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` solo en `Artwork`; -visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras. - ---- - -### P4 — Botones: `Button`, `IconButton`, `PlayButton` - -**Objetivo:** que no quede ningún ` @@ -56,7 +56,7 @@ onclick={() => player.toggleRepeat()} aria-pressed={player.repeat} aria-label="Repetir" - class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}" + class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}" > diff --git a/web-player/src/lib/components/ui/GlassMenu.svelte b/web-player/src/lib/components/ui/GlassMenu.svelte deleted file mode 100644 index 9b8652b..0000000 --- a/web-player/src/lib/components/ui/GlassMenu.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
- {@render children()} -
diff --git a/web-player/src/lib/components/ui/AlbumForm.svelte b/web-player/src/lib/components/ui/forms/AlbumForm.svelte similarity index 92% rename from web-player/src/lib/components/ui/AlbumForm.svelte rename to web-player/src/lib/components/ui/forms/AlbumForm.svelte index 79f7691..3442c45 100644 --- a/web-player/src/lib/components/ui/AlbumForm.svelte +++ b/web-player/src/lib/components/ui/forms/AlbumForm.svelte @@ -2,9 +2,9 @@ import { untrack } from 'svelte'; import Disc3 from '@lucide/svelte/icons/disc-3'; import CoverForm from './CoverForm.svelte'; - import Field from './Field.svelte'; - import Input from './Input.svelte'; - import Textarea from './Textarea.svelte'; + import Field from '../primitives/Field.svelte'; + import Input from '../primitives/Input.svelte'; + import Textarea from '../primitives/Textarea.svelte'; import { ALBUM_EARLIEST_YEAR } from '$lib/config'; interface Props { diff --git a/web-player/src/lib/components/ui/CoverForm.svelte b/web-player/src/lib/components/ui/forms/CoverForm.svelte similarity index 90% rename from web-player/src/lib/components/ui/CoverForm.svelte rename to web-player/src/lib/components/ui/forms/CoverForm.svelte index cde587a..2b13fc6 100644 --- a/web-player/src/lib/components/ui/CoverForm.svelte +++ b/web-player/src/lib/components/ui/forms/CoverForm.svelte @@ -3,9 +3,9 @@ import { enhance } from '$app/forms'; import type { LucideIcon } from '@lucide/svelte'; import ImageDropzone from './ImageDropzone.svelte'; - import Button from './Button.svelte'; - import Field from './Field.svelte'; - import Alert from './Alert.svelte'; + import Button from '../primitives/Button.svelte'; + import Field from '../primitives/Field.svelte'; + import Alert from '../primitives/Alert.svelte'; interface Props { action: string; @@ -80,7 +80,7 @@
{#if helperText} -

{helperText}

+

{helperText}

{:else} {/if} diff --git a/web-player/src/lib/components/ui/ImageDropzone.svelte b/web-player/src/lib/components/ui/forms/ImageDropzone.svelte similarity index 98% rename from web-player/src/lib/components/ui/ImageDropzone.svelte rename to web-player/src/lib/components/ui/forms/ImageDropzone.svelte index df5126b..3e41a63 100644 --- a/web-player/src/lib/components/ui/ImageDropzone.svelte +++ b/web-player/src/lib/components/ui/forms/ImageDropzone.svelte @@ -68,6 +68,6 @@ class="h-full w-full object-cover" /> {:else} - + {/if} diff --git a/web-player/src/lib/components/ui/PlaylistForm.svelte b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte similarity index 90% rename from web-player/src/lib/components/ui/PlaylistForm.svelte rename to web-player/src/lib/components/ui/forms/PlaylistForm.svelte index c9944b1..e298e74 100644 --- a/web-player/src/lib/components/ui/PlaylistForm.svelte +++ b/web-player/src/lib/components/ui/forms/PlaylistForm.svelte @@ -2,10 +2,10 @@ import { untrack } from 'svelte'; import Music from '@lucide/svelte/icons/music'; import CoverForm from './CoverForm.svelte'; - import Field from './Field.svelte'; - import Input from './Input.svelte'; - import Textarea from './Textarea.svelte'; - import SegmentedControl from './SegmentedControl.svelte'; + import Field from '../primitives/Field.svelte'; + import Input from '../primitives/Input.svelte'; + import Textarea from '../primitives/Textarea.svelte'; + import SegmentedControl from '../primitives/SegmentedControl.svelte'; const NAME_MAX_LENGTH = 60; const DESCRIPTION_MAX_LENGTH = 300; @@ -88,7 +88,7 @@
Visibilidad
-
+
{visibility === 'private' ? 'Solo tú la ves en tu biblioteca' : 'Cualquiera con el enlace puede verla'} diff --git a/web-player/src/lib/components/ui/Page.svelte b/web-player/src/lib/components/ui/layout/Page.svelte similarity index 100% rename from web-player/src/lib/components/ui/Page.svelte rename to web-player/src/lib/components/ui/layout/Page.svelte diff --git a/web-player/src/lib/components/ui/PageHeader.svelte b/web-player/src/lib/components/ui/layout/PageHeader.svelte similarity index 100% rename from web-player/src/lib/components/ui/PageHeader.svelte rename to web-player/src/lib/components/ui/layout/PageHeader.svelte diff --git a/web-player/src/lib/components/ui/SectionHeading.svelte b/web-player/src/lib/components/ui/layout/SectionHeading.svelte similarity index 83% rename from web-player/src/lib/components/ui/SectionHeading.svelte rename to web-player/src/lib/components/ui/layout/SectionHeading.svelte index 50c79a9..d24a578 100644 --- a/web-player/src/lib/components/ui/SectionHeading.svelte +++ b/web-player/src/lib/components/ui/layout/SectionHeading.svelte @@ -1,6 +1,6 @@ E diff --git a/web-player/src/lib/components/ui/ListRow.svelte b/web-player/src/lib/components/ui/media/ListRow.svelte similarity index 96% rename from web-player/src/lib/components/ui/ListRow.svelte rename to web-player/src/lib/components/ui/media/ListRow.svelte index 818cbbd..8731151 100644 --- a/web-player/src/lib/components/ui/ListRow.svelte +++ b/web-player/src/lib/components/ui/media/ListRow.svelte @@ -50,7 +50,7 @@ const classes = $derived( `flex items-center gap-3 text-left transition-colors ${ variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2' - } ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}` + } ${active ? 'surface-active' : 'hover:bg-hover'} ${klass}` ); diff --git a/web-player/src/lib/components/ui/MediaCard.svelte b/web-player/src/lib/components/ui/media/MediaCard.svelte similarity index 87% rename from web-player/src/lib/components/ui/MediaCard.svelte rename to web-player/src/lib/components/ui/media/MediaCard.svelte index 3472bd2..5d02147 100644 --- a/web-player/src/lib/components/ui/MediaCard.svelte +++ b/web-player/src/lib/components/ui/media/MediaCard.svelte @@ -38,7 +38,7 @@ {trackIds} size="fill" alt={title} - class="rounded-art transition duration-300 group-hover:scale-105" + class="rounded-art transition duration-300 ease-snappy group-hover:scale-105" /> {/if} {#if onPlay} @@ -50,12 +50,12 @@ size="sm" onclick={onPlay} label="Reproducir {title}" - class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100" + class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity group-hover:opacity-100" /> {/if}
{title}
{#if subtitle} -
{subtitle}
+
{subtitle}
{/if} diff --git a/web-player/src/lib/components/ui/MediaIdentity.svelte b/web-player/src/lib/components/ui/media/MediaIdentity.svelte similarity index 99% rename from web-player/src/lib/components/ui/MediaIdentity.svelte rename to web-player/src/lib/components/ui/media/MediaIdentity.svelte index 9d1b76a..90f7165 100644 --- a/web-player/src/lib/components/ui/MediaIdentity.svelte +++ b/web-player/src/lib/components/ui/media/MediaIdentity.svelte @@ -63,6 +63,6 @@ > {@render badge?.()}
- +
diff --git a/web-player/src/lib/components/ui/PlayButton.svelte b/web-player/src/lib/components/ui/media/PlayButton.svelte similarity index 87% rename from web-player/src/lib/components/ui/PlayButton.svelte rename to web-player/src/lib/components/ui/media/PlayButton.svelte index d98d3e8..aa560b7 100644 --- a/web-player/src/lib/components/ui/PlayButton.svelte +++ b/web-player/src/lib/components/ui/media/PlayButton.svelte @@ -26,12 +26,12 @@ }; const VARIANT: Record<'strong' | 'glass', string> = { - strong: 'bg-cta-strong text-ink hover:brightness-95', + strong: 'bg-cta-strong text-ink hover:bg-cta-strong-hover', glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur' }; const classes = $derived( - `grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}` + `grid shrink-0 place-items-center rounded-full transition ${SIZE[size]} ${VARIANT[variant]} ${klass}` ); diff --git a/web-player/src/lib/components/ui/TrackList.svelte b/web-player/src/lib/components/ui/media/TrackList.svelte similarity index 91% rename from web-player/src/lib/components/ui/TrackList.svelte rename to web-player/src/lib/components/ui/media/TrackList.svelte index 2a71729..083b80b 100644 --- a/web-player/src/lib/components/ui/TrackList.svelte +++ b/web-player/src/lib/components/ui/media/TrackList.svelte @@ -32,11 +32,11 @@ import Hash from '@lucide/svelte/icons/hash'; import Play from '@lucide/svelte/icons/play'; import { player } from '$lib/player/player.svelte'; - import { fmtTime, fmtDate } from '$lib/format'; + import { fmtTime, fmtDate } from '$lib/utils/format'; import { pressable } from '$lib/actions/pressable'; import MediaIdentity from './MediaIdentity.svelte'; import EqBars from './EqBars.svelte'; - import IconButton from './IconButton.svelte'; + import IconButton from '../primitives/IconButton.svelte'; interface RowAction { action?: string; @@ -91,7 +91,7 @@
{#if index} @@ -106,13 +106,14 @@ {/if}
-
+
{#each tracks as track, i (track.id)} {@const active = player.current.id === track.id}
oncontextmenu(event, track, i) : undefined} @@ -122,7 +123,7 @@ type="button" onclick={() => onPlay(i)} aria-label={active && player.playing ? 'Pausar' : 'Reproducir'} - class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums" + class="relative grid size-8 place-items-center rounded-tag text-xs text-fg-2 tabular-nums" > {#if active} @@ -138,6 +139,7 @@ tabindex="0" use:pressable={() => onPlay(i)} onclick={(event) => event.stopPropagation()} + onkeydown={(event) => event.stopPropagation()} class="flex min-w-0 flex-1 text-left" >
{#each columns as column (column)} -