From a73ead75a0c952d1520a066b8bb75029ee56a747 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 22:22:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20image=20Docker=20glibc=20+=20UI=20plus?= =?UTF-8?q?=20douce=20+=20placeholders=20g=C3=A9n=C3=A9riques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/Dockerfile : base node:22-bookworm-slim au lieu d'alpine. Le binaire autonome yt-dlp_linux est lié à la glibc ; sur musl (Alpine), spawn échouait en prod avec ENOENT bien que le fichier soit présent et exécutable (interpréteur ELF introuvable). Repéré via un rapport de bug en conditions réelles. Même choix que mediaDrop pour un souci similaire. - client/src/styles/base.scss : palette partagée (custom properties CSS), fond/texte/bordures adoucis, boutons/inputs cohérents, variante "secondary" pour les actions non-principales (Annuler). Remplace le rendu par défaut trop contrasté (fond très sombre côté navigateur non compensé, rouge vif sur les erreurs). - Placeholders des champs chaîne/mots-clés rendus génériques (@nomdelachaine, mot-clé1/mot-clé2) au lieu d'un exemple spécifique. Co-Authored-By: Claude Sonnet 5 --- client/src/app.ts | 1 + client/src/shell/Shell.scss | 15 ++-- client/src/styles/base.scss | 90 +++++++++++++++++++ .../src/views/downloadForm/DownloadForm.html | 4 +- .../src/views/downloadForm/DownloadForm.scss | 48 +++++----- client/src/views/queuePanel/QueuePanel.scss | 44 +++++---- client/src/views/queuePanel/QueuePanel.ts | 1 + server/Dockerfile | 31 ++++--- spec/architecture.md | 4 +- 9 files changed, 182 insertions(+), 56 deletions(-) create mode 100644 client/src/styles/base.scss 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