refonte: portfolio statique Astro (design moderne développeur)
Some checks failed
Deploy production / build-deploy (push) Failing after 56s
Some checks failed
Deploy production / build-deploy (push) Failing after 56s
- Remplace l'ancien Astro hybrid + admin par un site one-page statique (sortie static) - Thème clair/sombre sans flash, contenu typé (src/data/content.ts), zéro admin - Icônes simple-icons (SVG monochrome), polices auto-hébergées (RGPD) - SEO complet : canonical, Open Graph, Twitter Card, JSON-LD Person, robots.txt, sitemap.xml, image OG 1200x630 - CI Gitea de déploiement FTPS vers Plesk (.gitea/workflows/prod.yml)
This commit is contained in:
119
src/scripts/app.ts
Normal file
119
src/scripts/app.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/* ============================================================
|
||||
Comportements client (bundlés par Astro).
|
||||
Le thème initial est posé AVANT le paint par le script inline
|
||||
du <head> (cf. Base.astro) pour éviter tout flash.
|
||||
Tout respecte prefers-reduced-motion.
|
||||
============================================================ */
|
||||
|
||||
const reduced =
|
||||
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
/* ---------- Bascule de thème (persistée) ---------- */
|
||||
function applyTheme(theme: "light" | "dark"): void {
|
||||
if (theme === "light") document.documentElement.setAttribute("data-theme", "light");
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", theme === "light" ? "#fafafa" : "#09090b");
|
||||
}
|
||||
function setupThemeToggle(): void {
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
const isLight = document.documentElement.getAttribute("data-theme") === "light";
|
||||
const next = isLight ? "dark" : "light";
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem("jl-theme", next); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Effet machine à écrire (mockup terminal) ---------- */
|
||||
function setupTyping(): void {
|
||||
const el = document.getElementById("typed");
|
||||
if (!el) return;
|
||||
const full = el.textContent ?? ""; // le contenu HTML sert d'état final (no-JS friendly)
|
||||
if (reduced) return; // mouvement réduit : on garde l'état final
|
||||
el.textContent = "";
|
||||
let i = 0;
|
||||
const tick = () => {
|
||||
i += 1;
|
||||
el.textContent = full.slice(0, i);
|
||||
if (i < full.length) {
|
||||
const ch = full.charAt(i - 1);
|
||||
let d = 16;
|
||||
if (ch === "\n") d = 80;
|
||||
else if (ch === "{" || ch === "}") d = 40;
|
||||
window.setTimeout(tick, d);
|
||||
}
|
||||
};
|
||||
window.setTimeout(tick, 480);
|
||||
}
|
||||
|
||||
/* ---------- Apparition des sections au scroll ---------- */
|
||||
function setupReveal(): void {
|
||||
const els = document.querySelectorAll<HTMLElement>(".reveal");
|
||||
if (reduced || !("IntersectionObserver" in window)) {
|
||||
els.forEach((el) => el.classList.add("is-visible"));
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("is-visible");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -7% 0px" }
|
||||
);
|
||||
els.forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
/* ---------- Surlignage de la nav selon la section visible ---------- */
|
||||
function setupActiveNav(): void {
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
const sections = document.querySelectorAll<HTMLElement>("section[id]");
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (!e.isIntersecting) return;
|
||||
const id = e.target.id;
|
||||
document.querySelectorAll<HTMLElement>("[data-nav]").forEach((link) => {
|
||||
link.classList.toggle("is-active", link.getAttribute("data-nav") === id);
|
||||
});
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-45% 0px -50% 0px" }
|
||||
);
|
||||
sections.forEach((s) => io.observe(s));
|
||||
}
|
||||
|
||||
/* ---------- Menu mobile ---------- */
|
||||
function setupMobileMenu(): void {
|
||||
const burger = document.getElementById("burger");
|
||||
const menu = document.getElementById("mobile-menu");
|
||||
if (!burger || !menu) return;
|
||||
const close = () => {
|
||||
menu.classList.remove("is-open");
|
||||
burger.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
burger.addEventListener("click", () => {
|
||||
const open = menu.classList.toggle("is-open");
|
||||
burger.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
menu.querySelectorAll("a").forEach((a) => a.addEventListener("click", close));
|
||||
window.addEventListener("resize", () => {
|
||||
if (window.innerWidth >= 820) close();
|
||||
});
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
setupThemeToggle();
|
||||
setupTyping();
|
||||
setupReveal();
|
||||
setupActiveNav();
|
||||
setupMobileMenu();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
Reference in New Issue
Block a user