fix: image Docker glibc + UI plus douce + placeholders génériques #4

Merged
kyuno merged 1 commit from fix/docker-glibc-and-ui-polish into main 2026-09-16 22:26:48 +02:00
9 changed files with 182 additions and 56 deletions

View file

@ -1,4 +1,5 @@
import { mountShell } from './shell/Shell'; import { mountShell } from './shell/Shell';
import './styles/base.scss';
import { mountDownloadForm } from './views/downloadForm/DownloadForm'; import { mountDownloadForm } from './views/downloadForm/DownloadForm';
import { mountQueuePanel } from './views/queuePanel/QueuePanel'; import { mountQueuePanel } from './views/queuePanel/QueuePanel';

View file

@ -3,16 +3,17 @@
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
font-family: system-ui, sans-serif;
color: #222;
&__header { &__header {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid #eee; background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
h1 { h1 {
font-size: 1.25rem; font-size: 1.15rem;
font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text);
} }
} }
@ -20,7 +21,7 @@
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1.5rem; gap: 1.25rem;
padding: 1.5rem; padding: 1.5rem;
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
@ -37,5 +38,9 @@
&__panel { &__panel {
min-width: 0; 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"> <form class="download-form__form" data-form="channel">
<label class="download-form__field"> <label class="download-form__field">
Chaîne (@handle ou URL) Chaîne (@handle ou URL)
<input type="text" name="channel" placeholder="@worldclimbing" required /> <input type="text" name="channel" placeholder="@nomdelachaine" required />
</label> </label>
<label class="download-form__field"> <label class="download-form__field">
Section Section
@ -48,7 +48,7 @@
</label> </label>
<label class="download-form__field"> <label class="download-form__field">
Mots-clés (optionnel, séparés par des virgules) 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> </label>
<button type="submit">Rechercher</button> <button type="submit">Rechercher</button>
</form> </form>

View file

@ -1,24 +1,32 @@
.download-form { .download-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.85rem;
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.05rem;
font-weight: 600;
} }
&__mode { &__mode {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin: 0;
legend {
font-size: 0.8rem;
color: var(--color-text-muted);
padding: 0 0.25rem;
}
label { label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.3rem;
font-weight: normal; font-weight: normal;
} }
} }
@ -35,38 +43,36 @@
label { label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.3rem;
font-size: 0.9rem;
color: var(--color-text);
} }
} }
&__form { &__form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.6rem;
align-items: end; align-items: end;
} }
&__field { &__field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.3rem;
font-size: 0.85rem; font-size: 0.8rem;
color: var(--color-text-muted);
flex: 1 1 160px; flex: 1 1 160px;
input,
select {
padding: 0.4rem;
}
} }
&__error { &__error {
color: #b00020; color: var(--color-danger-text);
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
&__status { &__status {
color: #555; color: var(--color-text-muted);
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
@ -86,16 +92,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
border: 1px solid #eee; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius);
padding: 0.4rem; padding: 0.5rem;
} }
&__result-thumb { &__result-thumb {
width: 96px; width: 96px;
height: 54px; height: 54px;
object-fit: cover; object-fit: cover;
border-radius: 2px; border-radius: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -115,6 +121,6 @@
&__result-meta { &__result-meta {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--color-text-muted);
} }
} }

View file

@ -5,11 +5,12 @@
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.05rem;
font-weight: 600;
} }
&__empty { &__empty {
color: #666; color: var(--color-text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
@ -23,25 +24,25 @@
} }
&__item { &__item {
border: 1px solid #eee; border: 1px solid var(--color-border);
border-left-width: 4px; border-left-width: 4px;
border-radius: 4px; border-radius: var(--radius);
padding: 0.5rem 0.75rem; padding: 0.55rem 0.75rem;
background: var(--color-surface);
&--completed { &--completed {
border-left-color: #2e7d32; border-left-color: var(--color-success-border);
} }
&--failed { &--failed {
border-left-color: #b00020; border-left-color: var(--color-danger-border);
background: var(--color-danger-bg);
} }
&--running { &--running {
border-left-color: #1565c0; border-left-color: var(--color-primary);
}
&--cancelled {
border-left-color: #999;
} }
&--cancelled,
&--pending { &--pending {
border-left-color: #999; border-left-color: var(--color-neutral-border);
} }
} }
@ -56,27 +57,38 @@
&__item-meta { &__item-meta {
margin: 0.15rem 0 0; margin: 0.15rem 0 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--color-text-muted);
} }
&__item-progress { &__item-progress {
width: 100%; width: 100%;
margin-top: 0.35rem; margin-top: 0.4rem;
accent-color: var(--color-primary);
} }
&__item-error { &__item-error {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #b00020; color: var(--color-danger-text);
} }
&__item-actions { &__item-actions {
margin-top: 0.35rem; margin-top: 0.45rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
&__item-download { &__item-download {
font-size: 0.85rem; 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') { if (item.status === 'pending' || item.status === 'running') {
const cancelButton = document.createElement('button'); const cancelButton = document.createElement('button');
cancelButton.type = 'button'; cancelButton.type = 'button';
cancelButton.className = 'secondary';
cancelButton.textContent = 'Annuler'; cancelButton.textContent = 'Annuler';
cancelButton.addEventListener('click', () => { cancelButton.addEventListener('click', () => {
handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => { handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => {

View file

@ -4,10 +4,16 @@
# Trois étapes : build client (esbuild + sass -> client/dist), build # Trois étapes : build client (esbuild + sass -> client/dist), build
# serveur (tsc -> server/dist), puis l'image runtime, qui ne contient que # serveur (tsc -> server/dist), puis l'image runtime, qui ne contient que
# les deux dossiers dist/ compilés, les dépendances de production du # les deux dossiers dist/ compilés, les dépendances de production du
# serveur, et yt-dlp + ffmpeg (moteur d'extraction, utilisé par le futur # serveur, et yt-dlp + ffmpeg (moteur d'extraction).
# service de téléchargement). #
# 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 WORKDIR /app/client
COPY client/package.json client/package-lock.json* ./ COPY client/package.json client/package-lock.json* ./
RUN npm install RUN npm install
@ -15,7 +21,7 @@ COPY client/build.mjs client/tsconfig.json ./
COPY client/src ./src COPY client/src ./src
RUN npm run build RUN npm run build
FROM node:22-alpine AS server-build FROM node:22-bookworm-slim AS server-build
WORKDIR /app/server WORKDIR /app/server
COPY server/package.json server/package-lock.json* ./ COPY server/package.json server/package-lock.json* ./
RUN npm install RUN npm install
@ -24,14 +30,17 @@ COPY server/src ./src
RUN npm run build RUN npm run build
FROM node:22-alpine FROM node:22-bookworm-slim
# yt-dlp (binaire autonome, sans dépendance Python) + ffmpeg : dépendances # yt-dlp (binaire autonome, sans dépendance Python) + ffmpeg : dépendances
# d'exécution du futur service de téléchargement. # d'exécution du service de téléchargement. ca-certificates reste dans
RUN apk add --no-cache ffmpeg curl && \ # 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 && \ 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 && \ chmod +x /usr/local/bin/yt-dlp
apk del curl
WORKDIR /app/server WORKDIR /app/server
@ -41,8 +50,8 @@ RUN npm install --omit=dev
COPY --from=server-build /app/server/dist ./dist COPY --from=server-build /app/server/dist ./dist
COPY --from=client-build /app/client/dist ../client/dist COPY --from=client-build /app/client/dist ../client/dist
# Dossier de travail temporaire du futur service de téléchargement, # Dossier de travail temporaire du service de téléchargement, inscriptible
# inscriptible par l'utilisateur non-root `node`. # 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 RUN mkdir -p /app/server/tmp && chown -R node:node /app/server
ENV NODE_ENV=production \ 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. - `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. - 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. - 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 ## API
@ -55,8 +56,9 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `
## Docker / CI-CD ## 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). - `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). - `.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 ## Statut