fix: image Docker glibc + UI plus douce + placeholders génériques
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-16 22:22:44 +02:00
parent 96460965bd
commit a73ead75a0
9 changed files with 182 additions and 56 deletions

View file

@ -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';

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -37,7 +37,7 @@
<form class="download-form__form" data-form="channel">
<label class="download-form__field">
Chaîne (@handle ou URL)
<input type="text" name="channel" placeholder="@worldclimbing" required />
<input type="text" name="channel" placeholder="@nomdelachaine" required />
</label>
<label class="download-form__field">
Section
@ -48,7 +48,7 @@
</label>
<label class="download-form__field">
Mots-clés (optionnel, séparés par des virgules)
<input type="text" name="keywords" placeholder="speed, 2026" />
<input type="text" name="keywords" placeholder="mot-clé1, mot-clé2" />
</label>
<button type="submit">Rechercher</button>
</form>

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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) => {

View file

@ -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 \

View file

@ -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