Files
o-k-i.net/src/lib/components/sections/HostingVillage.svelte
T
sucupira d0d80a105e feat: migre le site vers SvelteKit (Svelte 5, statique, zéro tiers)
- Rebuild complet SvelteKit 2 + Svelte 5 runes + TypeScript, adapter-static
- Images self-hébergées optimisées au build (vite-imagetools), fonts Archivo/Inter
  en woff2 local — BunnyCDN et Google Fonts supprimés (corrige les 403 d'images)
- Tokens charte OKI, thème sombre par défaut / clair opt-in, zéro emoji (set SVG)
- Motion : KineticText (stagger syncopé gwoka), ScrollProgressBar, View Transitions
- Village SVG isométrique pour les services hébergés + fallback accessible
- PWA (manifest + SW hors-ligne), 404 en KA, CSP nettoyée (.htaccess + _headers)
- Lighthouse mobile : 91/100/100/100 — JS initial 67 Ko gzip
2026-07-21 08:00:16 -04:00

541 lines
15 KiB
Svelte

<script lang="ts">
import type { Bundle, Locale } from '$lib/i18n';
/**
* HostingVillage — scène SVG isométrique « village créole ».
* Chaque bâtiment est une instance hébergée OKI (lien externe) :
* BOKANTE (Mastodon) → case créole à ka
* GADE (PeerTube) → karbay
* NIYAJ (Nextcloud) → silo nuage
* KUTE (Castopod) → case au panneau « KUTE » + antenne
* Fallback accessible : grille de cards HTML (miroir sémantique)
* affichée à la place de la scène si prefers-reduced-motion ou ≤ 640 px.
* DOM/SVG uniquement, aucun WebGL, aucune dépendance (playbook P3).
*/
let { t, locale }: { t: Bundle; locale: Locale } = $props();
interface HostedService {
name: string;
platform: string;
description: string;
url: string;
linkText: string;
}
/** Secours si le bundle ne fournit pas l'entrée (contenu legacy stable). */
const DEFAULTS: Record<string, HostedService> = {
BOKANTE: {
name: 'BOKANTE',
platform: 'Mastodon',
description: '',
url: 'https://bokante.o-k-i.net',
linkText: 'BOKANTE'
},
GADE: {
name: 'GADE',
platform: 'PeerTube',
description: '',
url: 'https://gade.o-k-i.net',
linkText: 'GADE'
},
NIYAJ: {
name: 'NIYAJ',
platform: 'Nextcloud',
description: '',
url: 'https://niyaj.o-k-i.net',
linkText: 'NIYAJ'
},
KUTE: {
name: 'KUTE',
platform: 'Castopod',
description: '',
url: 'https://kute.o-k-i.net/',
linkText: 'KUTE'
}
};
// Les instances de la section « Services hébergés » vivent dans t.services.items
let items = $derived((t.services?.items ?? []) as HostedService[]);
function svc(name: string): HostedService {
return items.find((i) => i.name?.toUpperCase() === name) ?? DEFAULTS[name];
}
let bokante = $derived(svc('BOKANTE'));
let gade = $derived(svc('GADE'));
let niyaj = $derived(svc('NIYAJ'));
let kute = $derived(svc('KUTE'));
let sceneLabel = $derived(
locale === 'en'
? 'Isometric Creole village of OKI hosted services: each building is a link to an instance.'
: 'Village créole isométrique des services hébergés OKI : chaque bâtiment est un lien vers une instance.'
);
</script>
<div class="hosting-village">
<!-- ── Scène isométrique (masquée si reduced-motion ou ≤ 640 px) ── -->
<div class="village-scene">
<svg viewBox="0 0 960 540" width="100%" role="group" aria-label={sceneLabel}>
<!-- Cadre nuit -->
<rect class="sky" x="0" y="0" width="960" height="540" rx="6" />
<!-- Étoiles angulaires -->
<rect class="star" x="120" y="60" width="3" height="3" />
<rect class="star" x="210" y="118" width="3" height="3" />
<rect class="star" x="700" y="58" width="3" height="3" />
<rect class="star" x="822" y="138" width="3" height="3" />
<rect class="star" x="902" y="66" width="3" height="3" />
<rect class="star" x="64" y="182" width="3" height="3" />
<rect class="star" x="770" y="206" width="3" height="3" />
<rect class="star" x="420" y="64" width="3" height="3" />
<!-- Rivage puis sol en losange (arêtes à 30°) -->
<polygon class="shore" points="480,133 860,352 480,571 100,352" />
<polygon class="ground" points="480,138 830,340 480,542 130,340" />
<!-- Place centrale + flambeaux -->
<polygon class="plaza" points="480,321 600,390 480,459 360,390" />
<polygon class="win" points="420,386.5 423.5,390 420,393.5 416.5,390" />
<polygon class="win win-delay" points="540,386.5 543.5,390 540,393.5 536.5,390" />
<polygon class="win" points="480,344.5 483.5,348 480,351.5 476.5,348" />
<!-- ══ NIYAJ — Nextcloud : silo nuage (arrière-plan, gauche) ══ -->
<a
class="bldg"
href={niyaj.url}
target="_blank"
rel="noopener noreferrer"
aria-label="{niyaj.name}{niyaj.platform}"
>
<g class="bldg-art">
<path
class="silo-body"
d="M234,290 L234,190 L306,190 L306,290 A36,20.8 0 0 1 234,290 Z"
/>
<path class="roof-or" d="M234,190 A36,32 0 0 1 306,190 Z" />
<line class="roof-line" x1="234" y1="190" x2="306" y2="190" />
<rect class="win" x="250" y="218" width="10" height="10" />
<rect class="win win-delay" x="272" y="246" width="10" height="10" />
<rect class="door" x="260" y="258" width="18" height="32" />
<!-- Nuage angulaire au-dessus du silo -->
<rect class="cloud" x="244" y="134" width="52" height="12" />
<rect class="cloud" x="254" y="122" width="30" height="12" />
<rect class="cloud" x="268" y="112" width="18" height="10" />
<rect class="cloud" x="258" y="150" width="3" height="5" />
<rect class="cloud" x="276" y="148" width="3" height="5" />
<path
class="bldg-outline"
d="M234,190 A36,32 0 0 1 306,190 L306,290 A36,20.8 0 0 1 234,290 Z"
/>
</g>
<g class="bldg-label">
<rect class="label-plaque" x="186" y="70" width="168" height="24" rx="3" />
<text class="label-text" x="270" y="87">{niyaj.name}{niyaj.platform}</text>
</g>
</a>
<!-- ══ GADE — PeerTube : karbay (arrière-plan, droite) ══ -->
<a
class="bldg"
href={gade.url}
target="_blank"
rel="noopener noreferrer"
aria-label="{gade.name}{gade.platform}"
>
<g class="bldg-art">
<rect class="post" x="657" y="188" width="6" height="72" />
<polygon class="deck-top" points="660,262 729,302 660,342 591,302" />
<polygon class="deck-left" points="591,302 660,342 660,350 591,310" />
<polygon class="deck-right" points="660,342 729,302 729,310 660,350" />
<rect class="post" x="570" y="238" width="6" height="72" />
<rect class="post" x="744" y="238" width="6" height="72" />
<rect class="post" x="657" y="288" width="6" height="72" />
<polygon class="roof-vert-dark" points="566,238 660,292 660,132" />
<polygon class="roof-vert" points="660,292 754,238 660,132" />
<line class="roof-line" x1="660" y1="132" x2="660" y2="292" />
<!-- Lanterne suspendue -->
<line class="lantern-cord" x1="640" y1="262" x2="640" y2="274" />
<rect class="win" x="635" y="274" width="10" height="12" />
<path
class="bldg-outline"
d="M660,132 L754,238 L750,238 L750,310 L729,310 L660,350 L591,310 L570,310 L570,238 L566,238 Z"
/>
</g>
<g class="bldg-label">
<rect class="label-plaque" x="576" y="92" width="168" height="24" rx="3" />
<text class="label-text" x="660" y="109">{gade.name}{gade.platform}</text>
</g>
</a>
<!-- ══ BOKANTE — Mastodon : case créole à ka (avant-plan, gauche) ══ -->
<a
class="bldg"
href={bokante.url}
target="_blank"
rel="noopener noreferrer"
aria-label="{bokante.name}{bokante.platform}"
>
<g class="bldg-art">
<polygon class="wall-left" points="312,430 390,475 390,420 312,375" />
<polygon class="wall-right" points="390,475 468,430 468,375 390,420" />
<line class="lamella" x1="338" y1="390" x2="338" y2="445" />
<line class="lamella" x1="364" y1="405" x2="364" y2="460" />
<line class="lamella" x1="410" y1="409" x2="410" y2="464" />
<line class="lamella" x1="429" y1="397.5" x2="429" y2="452.5" />
<line class="lamella" x1="448" y1="386" x2="448" y2="441" />
<polygon class="win" points="330,404 348,414 348,428 330,418" />
<polygon class="win win-delay" points="430,410 446,401 446,415 430,424" />
<polygon class="door" points="398,470 416,460 416,430 398,440" />
<rect class="door-knob" x="410" y="444" width="3" height="3" />
<polygon class="roof-rouge-dark" points="293,375 390,431 390,283" />
<polygon class="roof-rouge" points="390,431 487,375 390,283" />
<line class="roof-line" x1="390" y1="283" x2="390" y2="431" />
<!-- Ka (tambour gwoka) devant la case -->
<path class="drum" d="M405,468 L405,490 A10,5.8 0 0 0 425,490 L425,468 Z" />
<ellipse class="drum-top" cx="415" cy="468" rx="10" ry="5.8" />
<path
class="bldg-outline"
d="M390,283 L487,375 L468,375 L468,430 L390,475 L312,430 L312,375 L293,375 Z"
/>
</g>
<g class="bldg-label">
<rect class="label-plaque" x="316" y="232" width="168" height="24" rx="3" />
<text class="label-text" x="400" y="249">{bokante.name}{bokante.platform}</text>
</g>
</a>
<!-- ══ KUTE — Castopod : case au panneau « KUTE » + antenne (avant-plan, droite) ══ -->
<a
class="bldg"
href={kute.url}
target="_blank"
rel="noopener noreferrer"
aria-label="{kute.name}{kute.platform}"
>
<g class="bldg-art">
<polygon class="wall-left" points="544,440 600,478 600,432 544,394" />
<polygon class="wall-right" points="600,478 676,440 676,394 600,432" />
<line class="lamella" x1="572" y1="413" x2="572" y2="459" />
<line class="lamella" x1="625" y1="419" x2="625" y2="465" />
<line class="lamella" x1="650" y1="407" x2="650" y2="453" />
<polygon class="win" points="560,414 574,422 574,434 560,426" />
<polygon class="door" points="608,474 624,465 624,431 608,440" />
<!-- Panneau « KUTE » sur la façade gauche -->
<polygon class="sign" points="548,404 592,429 592,443 548,418" />
<text
class="sign-text"
transform="matrix(1,0.577,0,1,552,416)"
x="0"
y="0">KUTE</text
>
<polygon class="roof-rouge-dark" points="517,384 600,432 600,304" />
<polygon class="roof-rouge" points="600,432 683,384 600,304" />
<line class="roof-line" x1="600" y1="304" x2="600" y2="432" />
<!-- Antenne d'émission (podcast) -->
<line class="antenna" x1="600" y1="304" x2="600" y2="284" />
<line class="antenna-tick" x1="600" y1="284" x2="593" y2="276" />
<line class="antenna-tick" x1="600" y1="284" x2="607" y2="276" />
<line class="antenna-tick" x1="588" y1="270" x2="582" y2="262" />
<line class="antenna-tick" x1="612" y1="270" x2="618" y2="262" />
<path
class="bldg-outline"
d="M600,304 L683,384 L676,394 L676,440 L600,478 L544,440 L544,394 L517,384 Z"
/>
</g>
<g class="bldg-label">
<rect class="label-plaque" x="516" y="496" width="168" height="24" rx="3" />
<text class="label-text" x="600" y="513">{kute.name}{kute.platform}</text>
</g>
</a>
</svg>
</div>
<!-- ── Fallback : grille de cards (miroir sémantique de la scène) ── -->
<ul class="village-fallback" role="list">
{#each items as item (item.name)}
<li class="card vf-card">
<div class="vf-head">
<h3>{item.name}</h3>
<span class="tag">{item.platform}</span>
</div>
<p class="vf-desc">{item.description}</p>
<a class="vf-link" href={item.url} target="_blank" rel="noopener noreferrer"
>{item.linkText}</a
>
</li>
{/each}
</ul>
</div>
<style>
.hosting-village {
margin-top: var(--space-4);
}
.village-scene svg {
display: block;
width: 100%;
height: auto;
}
/* ── Nuit, sol, végétation minérale ── */
.sky {
fill: color-mix(in srgb, var(--noir-profond) 45%, var(--noir-oki));
stroke: var(--line);
stroke-width: 1;
}
.star {
fill: var(--blanc-creme);
opacity: 0.25;
}
.shore {
fill: var(--gris-sombre);
opacity: 0.35;
}
.ground {
fill: var(--noir-profond);
stroke: var(--line);
stroke-width: 1;
}
.plaza {
fill: rgba(255, 255, 255, 0.04);
stroke: var(--line);
stroke-width: 1;
}
/* ── Matériaux des bâtiments ── */
.wall-left {
fill: color-mix(in srgb, var(--gris-sombre) 60%, var(--noir-oki));
}
.wall-right {
fill: var(--gris-sombre);
}
.lamella {
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 1;
}
.door {
fill: var(--noir-oki);
}
.door-knob {
fill: var(--or-oki);
opacity: 0.7;
}
.silo-body {
fill: color-mix(in srgb, var(--gris-sombre) 75%, var(--noir-oki));
}
.deck-top {
fill: var(--gris-sombre);
}
.deck-left {
fill: color-mix(in srgb, var(--gris-sombre) 60%, var(--noir-oki));
}
.deck-right {
fill: color-mix(in srgb, var(--gris-sombre) 80%, var(--noir-oki));
}
.post {
fill: color-mix(in srgb, var(--gris-sombre) 50%, var(--noir-oki));
}
.drum {
fill: color-mix(in srgb, var(--gris-sombre) 55%, var(--noir-oki));
}
.drum-top {
fill: var(--noir-profond);
stroke: color-mix(in srgb, var(--or-oki) 55%, transparent);
stroke-width: 1;
}
.cloud {
fill: var(--blanc-creme);
opacity: 0.15;
}
.sign {
fill: var(--noir-oki);
stroke: color-mix(in srgb, var(--or-oki) 45%, transparent);
stroke-width: 1;
}
.sign-text {
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
fill: var(--blanc-creme);
}
.antenna {
stroke: var(--blanc-creme);
stroke-width: 2;
opacity: 0.5;
}
.antenna-tick {
stroke: var(--or-oki);
stroke-width: 1.5;
opacity: 0.75;
}
.lantern-cord {
stroke: var(--line);
stroke-width: 1;
}
/* ── Toits : couleurs du drapeau, un accent par bâtiment ── */
.roof-rouge {
fill: var(--rouge-oki);
}
.roof-rouge-dark {
fill: color-mix(in srgb, var(--rouge-oki) 55%, var(--noir-oki));
}
.roof-vert {
fill: var(--vert-oki);
}
.roof-vert-dark {
fill: color-mix(in srgb, var(--vert-oki) 55%, var(--noir-oki));
}
.roof-or {
fill: var(--or-oki);
}
.roof-line {
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 1;
}
/* ── Fenêtres lumineuses : scintillement subtil,
coupé par la gate reduced-motion globale de base.css ── */
.win {
fill: var(--or-clair);
opacity: 0.82;
animation: win-glow 4.5s ease-in-out infinite alternate;
}
.win-delay {
animation-delay: -2.2s;
}
@keyframes win-glow {
from {
opacity: 0.5;
}
to {
opacity: 0.95;
}
}
/* ── Interaction : liseré or + label + léger soulèvement ── */
.bldg {
cursor: pointer;
}
.bldg-art {
transition: transform var(--dur-tanbou) var(--ease-ka);
}
.bldg:hover .bldg-art,
.bldg:focus-visible .bldg-art {
transform: translateY(-5px);
}
.bldg-outline {
fill: none;
stroke: var(--or-oki);
stroke-width: 2.5;
stroke-linejoin: miter;
opacity: 0;
transition: opacity var(--dur-tanbou) var(--ease-ka);
}
.bldg:hover .bldg-outline,
.bldg:focus-visible .bldg-outline {
opacity: 1;
}
.bldg-label {
opacity: 0;
transition: opacity var(--dur-tanbou) var(--ease-ka);
}
.bldg:hover .bldg-label,
.bldg:focus-visible .bldg-label {
opacity: 1;
}
.label-plaque {
fill: rgba(13, 13, 13, 0.88);
stroke: var(--or-oki);
stroke-width: 1;
}
.label-text {
font-family: var(--font-display);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
text-anchor: middle;
fill: var(--or-oki);
}
/* ── Fallback : miroir sémantique, un seul actif à la fois ── */
.village-fallback {
display: none;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-3);
}
.vf-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-2);
}
.vf-head h3 {
font-size: 1.1rem;
}
.vf-desc {
color: var(--muted);
font-size: 0.95rem;
margin-bottom: var(--space-2);
}
.vf-link {
font-weight: 600;
}
@media (prefers-reduced-motion: reduce), (max-width: 640px) {
.village-scene {
display: none;
}
.village-fallback {
display: grid;
}
}
</style>