fix: image Docker glibc + UI plus douce + placeholders génériques #4
9 changed files with 182 additions and 56 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
90
client/src/styles/base.scss
Normal file
90
client/src/styles/base.scss
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue