diff --git a/client/src/app.ts b/client/src/app.ts index 825b10b..5bc985f 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -1,4 +1,5 @@ import { mountShell } from './shell/Shell'; +import './styles/base.scss'; import { mountDownloadForm } from './views/downloadForm/DownloadForm'; import { mountQueuePanel } from './views/queuePanel/QueuePanel'; diff --git a/client/src/shell/Shell.scss b/client/src/shell/Shell.scss index 43f0f06..06e181b 100644 --- a/client/src/shell/Shell.scss +++ b/client/src/shell/Shell.scss @@ -3,16 +3,17 @@ flex-direction: column; min-height: 100vh; margin: 0; - font-family: system-ui, sans-serif; - color: #222; &__header { padding: 1rem 1.5rem; - border-bottom: 1px solid #eee; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); h1 { - font-size: 1.25rem; + font-size: 1.15rem; + font-weight: 600; margin: 0; + color: var(--color-text); } } @@ -20,7 +21,7 @@ flex: 1; display: grid; grid-template-columns: 1fr; - gap: 1.5rem; + gap: 1.25rem; padding: 1.5rem; max-width: 960px; margin: 0 auto; @@ -37,5 +38,9 @@ &__panel { min-width: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.25rem; } } diff --git a/client/src/styles/base.scss b/client/src/styles/base.scss new file mode 100644 index 0000000..cb285af --- /dev/null +++ b/client/src/styles/base.scss @@ -0,0 +1,90 @@ +// Palette et styles de base partagés par toutes les vues. Variables CSS +// natives (pas des $variables Sass) : chaque vue compile son propre +// fichier .scss indépendamment (voir build.mjs), seules les custom +// properties CSS traversent correctement ce découpage à l'exécution. +:root { + --color-bg: #f6f7f9; + --color-surface: #ffffff; + --color-border: #e3e5ea; + --color-text: #2b2d33; + --color-text-muted: #767a85; + --color-primary: #4f7cff; + --color-primary-hover: #3d64e0; + --color-danger-bg: #fdf2f2; + --color-danger-text: #b3423f; + --color-danger-border: #eab8b6; + --color-success-border: #5fae84; + --color-neutral-border: #c7cad1; + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + sans-serif; +} + +input, +select, +button { + font: inherit; + color: inherit; +} + +input[type='text'], +input[type='url'], +select { + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem 0.6rem; + background: var(--color-surface); +} + +input:focus, +select:focus, +button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + +button { + border: 1px solid var(--color-primary); + background: var(--color-primary); + color: white; + border-radius: var(--radius); + padding: 0.5rem 0.9rem; + cursor: pointer; + transition: background-color 0.15s ease; +} + +button:hover { + background: var(--color-primary-hover); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button.secondary { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-border); +} + +button.secondary:hover { + background: var(--color-bg); +} + +a { + color: var(--color-primary); +} diff --git a/client/src/views/downloadForm/DownloadForm.html b/client/src/views/downloadForm/DownloadForm.html index 461c838..56fe1ee 100644 --- a/client/src/views/downloadForm/DownloadForm.html +++ b/client/src/views/downloadForm/DownloadForm.html @@ -37,7 +37,7 @@
diff --git a/client/src/views/downloadForm/DownloadForm.scss b/client/src/views/downloadForm/DownloadForm.scss index e3d58eb..19035b7 100644 --- a/client/src/views/downloadForm/DownloadForm.scss +++ b/client/src/views/downloadForm/DownloadForm.scss @@ -1,24 +1,32 @@ .download-form { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.85rem; h2 { margin: 0; - font-size: 1.1rem; + font-size: 1.05rem; + font-weight: 600; } &__mode { display: flex; gap: 1rem; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius); padding: 0.5rem 0.75rem; + margin: 0; + + legend { + font-size: 0.8rem; + color: var(--color-text-muted); + padding: 0 0.25rem; + } label { display: flex; align-items: center; - gap: 0.25rem; + gap: 0.3rem; font-weight: normal; } } @@ -35,38 +43,36 @@ label { display: flex; align-items: center; - gap: 0.25rem; + gap: 0.3rem; + font-size: 0.9rem; + color: var(--color-text); } } &__form { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.6rem; align-items: end; } &__field { display: flex; flex-direction: column; - gap: 0.25rem; - font-size: 0.85rem; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--color-text-muted); flex: 1 1 160px; - - input, - select { - padding: 0.4rem; - } } &__error { - color: #b00020; + color: var(--color-danger-text); font-size: 0.85rem; margin: 0; } &__status { - color: #555; + color: var(--color-text-muted); font-size: 0.85rem; margin: 0; } @@ -86,16 +92,16 @@ display: flex; align-items: center; gap: 0.75rem; - border: 1px solid #eee; - border-radius: 4px; - padding: 0.4rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem; } &__result-thumb { width: 96px; height: 54px; object-fit: cover; - border-radius: 2px; + border-radius: 4px; flex-shrink: 0; } @@ -115,6 +121,6 @@ &__result-meta { margin: 0; font-size: 0.75rem; - color: #666; + color: var(--color-text-muted); } } diff --git a/client/src/views/queuePanel/QueuePanel.scss b/client/src/views/queuePanel/QueuePanel.scss index 4256cc0..ccb1ce5 100644 --- a/client/src/views/queuePanel/QueuePanel.scss +++ b/client/src/views/queuePanel/QueuePanel.scss @@ -5,11 +5,12 @@ h2 { margin: 0; - font-size: 1.1rem; + font-size: 1.05rem; + font-weight: 600; } &__empty { - color: #666; + color: var(--color-text-muted); font-size: 0.85rem; } @@ -23,25 +24,25 @@ } &__item { - border: 1px solid #eee; + border: 1px solid var(--color-border); border-left-width: 4px; - border-radius: 4px; - padding: 0.5rem 0.75rem; + border-radius: var(--radius); + padding: 0.55rem 0.75rem; + background: var(--color-surface); &--completed { - border-left-color: #2e7d32; + border-left-color: var(--color-success-border); } &--failed { - border-left-color: #b00020; + border-left-color: var(--color-danger-border); + background: var(--color-danger-bg); } &--running { - border-left-color: #1565c0; - } - &--cancelled { - border-left-color: #999; + border-left-color: var(--color-primary); } + &--cancelled, &--pending { - border-left-color: #999; + border-left-color: var(--color-neutral-border); } } @@ -56,27 +57,38 @@ &__item-meta { margin: 0.15rem 0 0; font-size: 0.75rem; - color: #666; + color: var(--color-text-muted); } &__item-progress { width: 100%; - margin-top: 0.35rem; + margin-top: 0.4rem; + accent-color: var(--color-primary); } &__item-error { margin: 0.35rem 0 0; font-size: 0.75rem; - color: #b00020; + color: var(--color-danger-text); } &__item-actions { - margin-top: 0.35rem; + margin-top: 0.45rem; display: flex; gap: 0.5rem; } &__item-download { font-size: 0.85rem; + padding: 0.35rem 0.7rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + text-decoration: none; + color: var(--color-text); + background: var(--color-surface); + } + + &__item-download:hover { + background: var(--color-bg); } } diff --git a/client/src/views/queuePanel/QueuePanel.ts b/client/src/views/queuePanel/QueuePanel.ts index 2fec7e2..bbc9013 100644 --- a/client/src/views/queuePanel/QueuePanel.ts +++ b/client/src/views/queuePanel/QueuePanel.ts @@ -94,6 +94,7 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { if (item.status === 'pending' || item.status === 'running') { const cancelButton = document.createElement('button'); cancelButton.type = 'button'; + cancelButton.className = 'secondary'; cancelButton.textContent = 'Annuler'; cancelButton.addEventListener('click', () => { handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => { diff --git a/server/Dockerfile b/server/Dockerfile index d75af59..d9c688c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -4,10 +4,16 @@ # Trois étapes : build client (esbuild + sass -> client/dist), build # serveur (tsc -> server/dist), puis l'image runtime, qui ne contient que # les deux dossiers dist/ compilés, les dépendances de production du -# serveur, et yt-dlp + ffmpeg (moteur d'extraction, utilisé par le futur -# service de téléchargement). +# serveur, et yt-dlp + ffmpeg (moteur d'extraction). +# +# Debian slim plutôt qu'Alpine : le binaire autonome yt-dlp_linux est lié à +# la glibc. Sur une base musl (Alpine), l'exécution échouait en prod avec +# `spawn yt-dlp ENOENT` bien que le fichier soit présent et exécutable - le +# noyau ne trouve pas l'interpréteur ELF (ld-linux) que le binaire attend. +# Même choix que mediaDrop, qui a le même souci avec une autre dépendance +# native (voir son Dockerfile). -FROM node:22-alpine AS client-build +FROM node:22-bookworm-slim AS client-build WORKDIR /app/client COPY client/package.json client/package-lock.json* ./ RUN npm install @@ -15,7 +21,7 @@ COPY client/build.mjs client/tsconfig.json ./ COPY client/src ./src RUN npm run build -FROM node:22-alpine AS server-build +FROM node:22-bookworm-slim AS server-build WORKDIR /app/server COPY server/package.json server/package-lock.json* ./ RUN npm install @@ -24,14 +30,17 @@ COPY server/src ./src RUN npm run build -FROM node:22-alpine +FROM node:22-bookworm-slim # yt-dlp (binaire autonome, sans dépendance Python) + ffmpeg : dépendances -# d'exécution du futur service de téléchargement. -RUN apk add --no-cache ffmpeg curl && \ +# d'exécution du service de téléchargement. ca-certificates reste dans +# l'image finale (pas seulement pour le téléchargement du binaire) : yt-dlp +# en a besoin à l'exécution pour valider le TLS de youtube.com. +RUN apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg curl ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o /usr/local/bin/yt-dlp && \ - chmod +x /usr/local/bin/yt-dlp && \ - apk del curl + chmod +x /usr/local/bin/yt-dlp WORKDIR /app/server @@ -41,8 +50,8 @@ RUN npm install --omit=dev COPY --from=server-build /app/server/dist ./dist COPY --from=client-build /app/client/dist ../client/dist -# Dossier de travail temporaire du futur service de téléchargement, -# inscriptible par l'utilisateur non-root `node`. +# Dossier de travail temporaire du service de téléchargement, inscriptible +# par l'utilisateur non-root `node` (déjà présent dans l'image officielle). RUN mkdir -p /app/server/tmp && chown -R node:node /app/server ENV NODE_ENV=production \ diff --git a/spec/architecture.md b/spec/architecture.md index 818c18b..2b61d88 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -39,6 +39,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur, bouton annuler/lien de téléchargement selon le statut). Expose `refresh()` pour un rafraîchissement immédiat après un ajout. - Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite. - Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement. +- `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`. ## API @@ -55,8 +56,9 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, ` ## Docker / CI-CD -- `server/Dockerfile` : image multi-étapes construite avec pour contexte la racine du dépôt (`docker build -f server/Dockerfile .`), car elle a besoin à la fois de `server/` et `client/`. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 Alpine + `ffmpeg` + binaire autonome `yt-dlp`, utilisateur non-root `node`). +- `server/Dockerfile` : image multi-étapes construite avec pour contexte la racine du dépôt (`docker build -f server/Dockerfile .`), car elle a besoin à la fois de `server/` et `client/`. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 **bookworm-slim** + `ffmpeg` + binaire autonome `yt-dlp`, utilisateur non-root `node`). - `yt-dlp` et `ffmpeg` sont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts `.cmd` d'origine qui embarquaient `yt-dlp.exe` et un build ffmpeg complet). +- Debian slim plutôt qu'Alpine (changé après coup) : le binaire autonome `yt-dlp_linux` est lié à la glibc. Sur Alpine (musl), `spawn('yt-dlp', ...)` échouait en prod avec `ENOENT` bien que le fichier soit présent et exécutable - le noyau ne trouve pas l'interpréteur ELF (`ld-linux`) que le binaire attend. Repéré via un rapport de bug en conditions réelles (pas en local, où `YTDLP_PATH` pointe vers un exécutable Windows). Même choix que mediaDrop pour un souci similaire avec une autre dépendance native. - `.forgejo/workflows/publish-docker.yml` : job `test` (lint racine, typecheck/tests/build serveur, typecheck/tests/build client) puis job `publish` (build + push de l'image vers `git.dev.kyuno.fr/kyuno/youtubedownloader`, seulement si `test` passe et hors PR). Tags : `latest` + sha court sur push `main`, `X.Y.Z`/`X.Y`/`latest` sur tag `vX.Y.Z`. Nécessite le secret de dépôt `REGISTRY_TOKEN` (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets). ## Statut