commit 81fd9dd717b599f82bf28925a84400f914f12183 Author: Yasser Boulayoune Date: Tue May 12 10:12:00 2026 +0200 Premier jet : gabarit SVG et rendu PNG diff --git a/README.md b/README.md new file mode 100644 index 0000000..10cee32 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# card-generator + +Petite app web auto-hébergée qui produit les visuels (featured images) du portfolio : SVG rendu en PNG avec librsvg. + +## Stack + +- Python 3 (serveur HTTP de la bibliothèque standard) +- JavaScript vanilla pour l'aperçu en direct +- librsvg (`rsvg-convert`) pour le rendu PNG +- Polices Fraunces, Syne et DM Sans + +## Installation + +``` +python3 server.py # puis http://localhost:6789 +``` diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5ae5e0e --- /dev/null +++ b/static/app.js @@ -0,0 +1,197 @@ +/* Card Generator — frontend */ + +const $ = (id) => document.getElementById(id); +const preview = $("preview-frame"); +const status = $("status"); + +let state = { + eyebrow: "RÉALISATION · SYMFONY", + title: "pokedwwmm", + title_2: "", + tagline: "151 Pokémon · Twig Components · JSON", + accent: "#ef4444", + logo_color:"#ef4444", + logo_kind: "preset", // preset | upload | text | none + logo_value:"symfony", + logo_size: 260, +}; + +let renderTimer = null; +let logoLibrary = []; + +/* ── Wire up form ──────────────────────────────────────────────── */ + +const inputBindings = { + eyebrow: "eyebrow", + title: "title", + title_2: "title_2", + tagline: "tagline", + accent: "accent", + logo_color: "logo_color", +}; + +for (const [stateKey, inputId] of Object.entries(inputBindings)) { + $(inputId).addEventListener("input", (e) => { + state[stateKey] = e.target.value; + schedule(); + }); +} + +$("logo_size").addEventListener("input", (e) => { + state.logo_size = +e.target.value; + $("logo_size_val").value = `${state.logo_size}px`; + schedule(); +}); + +/* Swatches */ +document.querySelectorAll(".swatch").forEach(btn => { + const c = btn.dataset.color; + btn.style.background = c; + btn.addEventListener("click", () => { + state.accent = c; + state.logo_color = c; + $("accent").value = c; + $("logo_color").value = c; + schedule(); + }); +}); + +/* Segmented control: logo source */ +document.querySelectorAll(".seg-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + state.logo_kind = btn.dataset.kind; + document.querySelectorAll(".logo-source").forEach(el => { el.hidden = true; }); + if (state.logo_kind === "preset") document.querySelector(".preset-source").hidden = false; + if (state.logo_kind === "upload") document.querySelector(".upload-source").hidden = false; + if (state.logo_kind === "text") document.querySelector(".text-source").hidden = false; + schedule(); + }); +}); + +/* Text logo input */ +$("logo_text").addEventListener("input", (e) => { + if (state.logo_kind === "text") state.logo_value = e.target.value; + schedule(); +}); + +/* Upload */ +$("logo_upload").addEventListener("change", async (e) => { + const file = e.target.files[0]; + if (!file) return; + const dataUrl = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = () => res(r.result); + r.onerror = rej; + r.readAsDataURL(file); + }); + state.logo_kind = "upload"; + state.logo_value = dataUrl; + const preview = $("upload_preview"); + preview.hidden = false; + preview.innerHTML = ``; + schedule(); +}); + +/* ── Logo library ──────────────────────────────────────────────── */ + +async function loadLogoLibrary() { + const res = await fetch("/api/logos"); + logoLibrary = await res.json(); + const grid = $("logo_grid"); + grid.innerHTML = ""; + for (const slug of logoLibrary) { + const tile = document.createElement("div"); + tile.className = "logo-tile"; + tile.title = slug; + if (slug === state.logo_value) tile.classList.add("selected"); + tile.dataset.slug = slug; + const svgRes = await fetch(`/logos/${slug}.svg`); + tile.innerHTML = await svgRes.text(); + tile.addEventListener("click", () => { + grid.querySelectorAll(".logo-tile").forEach(t => t.classList.remove("selected")); + tile.classList.add("selected"); + state.logo_kind = "preset"; + state.logo_value = slug; + // Also update segmented control + document.querySelectorAll(".seg-btn").forEach(b => + b.classList.toggle("active", b.dataset.kind === "preset")); + document.querySelectorAll(".logo-source").forEach(el => { el.hidden = true; }); + document.querySelector(".preset-source").hidden = false; + schedule(); + }); + grid.appendChild(tile); + } +} + +/* ── Render pipeline ───────────────────────────────────────────── */ + +function schedule() { + if (renderTimer) clearTimeout(renderTimer); + status.textContent = "rendu…"; status.className = "status working"; + renderTimer = setTimeout(render, 120); +} + +async function render() { + try { + const res = await fetch("/api/svg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const svgText = await res.text(); + preview.innerHTML = svgText; + status.textContent = "à jour"; status.className = "status ok"; + } catch (e) { + console.error(e); + status.textContent = "erreur"; status.className = "status err"; + } +} + +/* ── Download buttons ──────────────────────────────────────────── */ + +$("btn-svg").addEventListener("click", async () => { + status.textContent = "génération SVG…"; status.className = "status working"; + const res = await fetch("/api/svg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + const blob = await res.blob(); + downloadBlob(blob, `${$("filename").value || "card"}.svg`); + status.textContent = "SVG téléchargé"; status.className = "status ok"; +}); + +$("btn-png").addEventListener("click", async () => { + status.textContent = "rendu PNG (rsvg-convert)…"; status.className = "status working"; + const width = +$("export_width").value; + const height = Math.round(width / 1.6); + try { + const res = await fetch("/api/png", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...state, export_width: width, export_height: height, + filename: $("filename").value || "card" }), + }); + if (!res.ok) throw new Error(await res.text()); + const blob = await res.blob(); + downloadBlob(blob, `${$("filename").value || "card"}.png`); + status.textContent = `PNG ${width}×${height} téléchargé`; status.className = "status ok"; + } catch (e) { + console.error(e); + status.textContent = "erreur PNG"; status.className = "status err"; + } +}); + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); +} + +/* ── Init ──────────────────────────────────────────────────────── */ +loadLogoLibrary().then(render); diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..80f17c6 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..c9f8f6e --- /dev/null +++ b/static/index.html @@ -0,0 +1,163 @@ + + + + + + + + +Card Generator · Yasder + + + + + + + +
+
+ + YB. + Card Generator +
+
Mêmes cartes que sur yasser.boulayoune.com · Fraunces / Syne / DM Sans
+
+ +
+ + + + + +
+
+

Aperçu

+
800 × 500 · preview live
+
+ +
+
+ +
+
+ +
+ prêt +
+
+ +
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..c108b35 --- /dev/null +++ b/static/style.css @@ -0,0 +1,381 @@ +/* ─── Reset + tokens ────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } + +:root { + --bg: #0a0a0e; + --surface: #14141a; + --surface-2: #1a1a22; + --line: #26262f; + --t1: #f5f5f7; + --t2: #c8c8d0; + --t3: #8a8a92; + --accent: #ef4444; + --accent-soft: #ef444433; + --radius: 12px; + --radius-sm: 6px; + --serif: 'Fraunces', Georgia, serif; + --sans: 'Syne', system-ui, sans-serif; + --body: 'DM Sans', system-ui, sans-serif; + --mono: 'JetBrains Mono', ui-monospace, monospace; +} + +body { + font-family: var(--body); + background: radial-gradient(circle at 10% -10%, #1a1a22, var(--bg) 60%); + color: var(--t1); + min-height: 100vh; + font-size: 15px; + line-height: 1.5; +} + +/* ─── Header ────────────────────────────────────────────────────── */ +.app-header { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; + padding: 24px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10, 10, 14, 0.6); + backdrop-filter: blur(8px); +} +.brand { + display: flex; align-items: center; gap: 14px; +} +.brand-logo { + width: 36px; height: 36px; + fill: var(--accent); + filter: drop-shadow(0 4px 12px rgba(239, 68, 68, 0.35)); +} +.brand-mark { + font-family: var(--serif); font-weight: 700; font-size: 28px; + letter-spacing: -0.02em; + line-height: 1; +} +.brand-mark .dot { color: var(--accent); } +.brand-name { + font-family: var(--sans); font-weight: 600; font-size: 14px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--t2); +} +.brand-tagline { + font-size: 13px; color: var(--t3); font-family: var(--mono); +} + +/* ─── Layout ────────────────────────────────────────────────────── */ +.app { + display: grid; + grid-template-columns: 420px 1fr; + gap: 24px; + padding: 24px 32px 48px; + align-items: start; + max-width: 1600px; + margin: 0 auto; +} +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 24px; +} +.form-panel { + position: sticky; + top: 24px; + max-height: calc(100vh - 48px); + overflow-y: auto; +} +.form-panel h2 { + font-family: var(--sans); + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--t3); + margin: 24px 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} +.form-panel h2:first-child { margin-top: 0; } + +/* ─── Form fields ───────────────────────────────────────────────── */ +.field { margin-bottom: 16px; } +.field label { + display: block; margin-bottom: 6px; + font-size: 13px; font-weight: 500; color: var(--t2); +} +.field .hint { + font-size: 11px; color: var(--t3); font-weight: 400; + margin-left: 4px; +} +.field.row { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} +.field.row .row-col { margin: 0; } + +input[type="text"], input[type="file"], select { + width: 100%; + background: var(--bg); + color: var(--t1); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 9px 12px; + font-family: var(--body); + font-size: 14px; + transition: border-color .15s; +} +input[type="text"]:focus, select:focus, input[type="file"]:focus { + outline: none; + border-color: var(--accent); +} +input[type="color"] { + width: 100%; height: 42px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + cursor: pointer; + padding: 4px; +} +input[type="range"] { + width: 100%; accent-color: var(--accent); +} +output { + float: right; font-family: var(--mono); font-size: 12px; color: var(--t3); +} + +/* Swatches */ +.swatches { + display: flex; flex-wrap: wrap; gap: 8px; +} +.swatch { + width: 28px; height: 28px; + border-radius: 50%; + border: 2px solid var(--line); + background: var(--color); + cursor: pointer; + transition: transform .12s, border-color .12s; +} +.swatch:hover { transform: scale(1.1); border-color: var(--t2); } + +/* Segmented control */ +.seg { + display: grid; + grid-template-columns: repeat(4, 1fr); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 3px; + gap: 2px; +} +.seg-btn { + background: transparent; + color: var(--t3); + border: 0; + padding: 8px 6px; + border-radius: 4px; + font-family: var(--body); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background .15s, color .15s; +} +.seg-btn:hover { color: var(--t2); } +.seg-btn.active { + background: var(--accent); + color: white; +} + +/* Logo grid */ +.logo-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 6px; + max-height: 200px; + overflow-y: auto; + padding: 6px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} +.logo-tile { + aspect-ratio: 1; + background: var(--surface-2); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + padding: 6px; + transition: border-color .12s, background .12s; +} +.logo-tile:hover { background: var(--surface); border-color: var(--t3); } +.logo-tile.selected { border-color: var(--accent); background: var(--surface); } +.logo-tile svg { width: 100%; height: 100%; fill: var(--t1); } + +/* Upload preview */ +.upload-preview { + margin-top: 8px; + padding: 12px; + background: var(--bg); + border: 1px dashed var(--line); + border-radius: var(--radius-sm); + display: flex; align-items: center; justify-content: center; + min-height: 80px; +} +.upload-preview img { max-width: 100%; max-height: 80px; } + +/* Actions */ +.actions { + display: flex; gap: 10px; margin-top: 20px; +} +.btn { + flex: 1; + background: var(--surface-2); + color: var(--t1); + border: 1px solid var(--line); + padding: 12px 16px; + border-radius: var(--radius-sm); + font-family: var(--body); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s, transform .08s; +} +.btn:hover { background: var(--line); border-color: var(--t3); } +.btn:active { transform: translateY(1px); } +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: white; +} +.btn-primary:hover { + background: #f87171; + border-color: #f87171; +} + +/* ─── Preview ───────────────────────────────────────────────────── */ +.preview-panel { padding: 16px; } +.preview-header { + display: flex; align-items: baseline; justify-content: space-between; + padding: 8px 8px 16px; +} +.preview-header h2 { + font-family: var(--sans); font-weight: 600; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em; + color: var(--t3); margin: 0; +} +.zoom-info { + font-family: var(--mono); font-size: 11px; color: var(--t3); +} +.preview-wrap { + background: repeating-conic-gradient(#0c0c10 0% 25%, #16161e 0% 50%) 50% / 24px 24px; + border-radius: var(--radius-sm); + padding: 24px; + display: flex; align-items: center; justify-content: center; + min-height: 540px; +} +#preview-frame { + width: 800px; max-width: 100%; + aspect-ratio: 8/5; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 30px 80px rgba(0,0,0,0.5); +} +#preview-frame svg { + width: 100%; height: 100%; + display: block; +} +.preview-footer { + padding: 16px 8px 4px; + display: flex; justify-content: flex-end; +} +.status { + font-family: var(--mono); font-size: 11px; + color: var(--t3); +} +.status.working { color: #f59e0b; } +.status.ok { color: #22c55e; } +.status.err { color: var(--accent); } + +/* ─── Responsive ────────────────────────────────────────────────── */ + +/* Tablette large : on empile form + preview, le form reste large */ +@media (max-width: 1080px) { + .app { + grid-template-columns: 1fr; + padding: 20px 24px 40px; + gap: 20px; + } + .form-panel { + position: static; + max-height: none; + overflow-y: visible; + } + .preview-wrap { padding: 16px; min-height: 0; } + #preview-frame { width: 100%; max-width: 800px; margin: 0 auto; } +} + +/* Tablette : header passe sur 2 lignes, paddings réduits */ +@media (max-width: 720px) { + .app-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 18px 20px; + } + .brand { gap: 10px; } + .brand-logo { width: 28px; height: 28px; } + .brand-mark { font-size: 22px; } + .brand-name { font-size: 12px; letter-spacing: 0.14em; } + .brand-tagline { font-size: 12px; } + + .app { + padding: 16px 16px 32px; + gap: 16px; + } + .panel { padding: 18px; } + .preview-panel { padding: 12px; } + .preview-wrap { padding: 10px; } + + /* Grille des logos plus aérée */ + .logo-grid { grid-template-columns: repeat(5, 1fr); max-height: 240px; } + + /* Le segmented control reste sur une ligne avec un texte plus court */ + .seg-btn { padding: 8px 4px; font-size: 11px; } +} + +/* Mobile : on passe en monocolonne pour la ligne couleurs, output sur sa propre ligne */ +@media (max-width: 520px) { + .app-header { padding: 14px 16px; } + .brand-tagline { display: none; } + + .app { + padding: 12px 12px 28px; + gap: 12px; + } + .panel { padding: 14px; border-radius: 10px; } + .preview-panel { padding: 10px; } + .preview-wrap { + padding: 8px; + background-size: 16px 16px; + } + + .field.row { grid-template-columns: 1fr; gap: 14px; } + + output { float: none; display: block; margin-top: 4px; } + + /* Grille logos : 4 col pour des tuiles plus tappables */ + .logo-grid { grid-template-columns: repeat(4, 1fr); gap: 8px; } + .logo-tile { padding: 10px; } + + /* Boutons d'export empilés pour qu'ils restent lisibles */ + .actions { flex-direction: column; } + .btn { width: 100%; padding: 14px 16px; } + + /* Swatches plus gros, plus faciles au doigt */ + .swatch { width: 32px; height: 32px; } + + .preview-header { flex-direction: column; align-items: flex-start; gap: 4px; } +} + +/* Très petits écrans : on tasse encore un peu */ +@media (max-width: 360px) { + .seg { grid-template-columns: repeat(2, 1fr); } + .logo-grid { grid-template-columns: repeat(3, 1fr); } +}