Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 bcf0f5c937 fix: Medien unabhaengig von Gross- und Kleinschreibung finden
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
Im Container (Linux) wurde z. B. reels/Reel-1.JPG nicht als Vorschaubild
erkannt, weil nur exakt reel-1.jpg gesucht wurde. Die Suche vergleicht
Dateinamen jetzt ohne Ruecksicht auf die Schreibweise und liefert den
echten Dateinamen aus. Gilt fuer Logo, Headerbild, Portraets und Videos.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:37:00 +02:00
Nick SzaunigandClaude Opus 5.5 a37f12a60f fix: Logo deutlich groesser, Video im eigenen Seitenverhaeltnis
- Logo ueber dem Headerbild bis 112 px hoch, nach dem Scrollen 64 px,
  im Fussbereich 80 px; Kopfzeile waechst mit
- Videos aus dem Medienverzeichnis direkt mit Steuerleiste, quer oder
  hoch, erstes Bild als Vorschau; ein einzelnes Video breit und mittig
- Abschnitt heisst "Stimmen": Models erzaehlen, wie sie ARTISTA erleben

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 06:30:08 +02:00
9 changed files with 74 additions and 50 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ Logo, Headerbild, Porträts und Reels liegen im Medienverzeichnis. Im Cluster is
Lege eine Datei unter dem passenden Namen dort ab, dann erscheint sie beim nächsten Seitenaufruf. Neustart und Deployment sind nicht nötig. Fehlt eine Datei, bleibt der Platzhalter oder das Standardbild sichtbar.
Dateinamen bitte genau so schreiben: alles klein, ohne Leerzeichen und Umlaute. Bei mehreren Endungen nimmt die Seite die erste, die sie findet.
Dateinamen bitte so schreiben: ohne Leerzeichen und Umlaute. Gross- und Kleinschreibung ist egal (`Reel-1.JPG` wird auch erkannt). Bei mehreren Endungen nimmt die Seite die erste, die sie findet.
## Logo
+1 -1
View File
@@ -25,7 +25,7 @@
@layer base {
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
scroll-padding-top: 5.5rem;
-webkit-text-size-adjust: 100%;
scrollbar-color: var(--color-stone) var(--color-plaster);
accent-color: var(--color-rosso);
+1 -1
View File
@@ -53,7 +53,7 @@ export function Hero({ pricing, media, video }: { pricing: PublicPricing; media:
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgb(35_33_31/0.6)_0%,rgb(35_33_31/0.45)_45%,rgb(35_33_31/0.75)_100%)]" />
</div>
<div className="container-edge flex flex-col items-center pb-20 pt-32 text-center">
<div className="container-edge flex flex-col items-center pb-20 pt-36 text-center md:pt-44">
<h1 id="hero-title" className="hero-fade max-w-4xl text-[clamp(2.75rem,8vw,6.25rem)] font-extrabold leading-[0.98] tracking-[-0.03em]">
<span className="sr-only">ARTISTA </span>
{hero.title}
+1 -1
View File
@@ -7,7 +7,7 @@ export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts
<footer id="site-footer" className="border-t border-line bg-plaster">
<div className="container-edge grid gap-10 py-14 md:grid-cols-12">
<div className="md:col-span-5">
<p className="flex h-10 items-center text-2xl">
<p className="flex h-20 items-center text-2xl">
<Logo src={logo} />
</p>
<p className="mt-1 text-stone">Modelcamp 2027</p>
+9 -2
View File
@@ -82,7 +82,9 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
Zum Inhalt springen
</a>
<LazyMotion features={domAnimation}>
<div className="container-edge grid h-[4.5rem] grid-cols-[1fr_auto_1fr] items-center gap-4">
<div
className={`container-edge grid grid-cols-[1fr_auto_1fr] items-center gap-4 transition-[height] duration-300 ${solid ? "h-20" : "h-24 md:h-32"}`}
>
<div className="flex items-center">
<button
ref={menuButton}
@@ -99,7 +101,12 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
</nav>
</div>
<a href="#top" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang" className="flex h-8 items-center text-xl md:h-10 md:text-2xl">
{/* Logo ueber dem Headerbild gross, nach dem Scrollen kompakter. */}
<a
href="#top"
aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang"
className={`flex items-center text-xl transition-[height] duration-300 md:text-2xl ${solid ? "h-16" : "h-20 md:h-28"}`}
>
<Logo src={solid ? logoDark : logoLight} />
</a>
+29 -33
View File
@@ -1,47 +1,43 @@
"use client";
import { useState } from "react";
import type { Media } from "@/content/site";
import { MediaFrame } from "./MediaFrame";
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
/**
* Ein Reel im Hochformat (9:16) aus dem eigenen Medienverzeichnis. Das
* Video laedt erst nach Klick (preload="none") und startet nie von selbst
* mit Ton. Kein externer Anbieter, keine Datenuebertragung an Dritte.
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
* (quer oder hoch). Es startet nie von selbst; geladen werden vorab nur die
* Metadaten und das erste Bild als Vorschau (#t=0.1), falls kein
* Vorschaubild hinterlegt ist. Kein externer Anbieter.
*/
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
const [playing, setPlaying] = useState(false);
const label = slot.name ? `Reel von ${slot.name} abspielen` : `Reel ${index + 1} abspielen`;
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Reel ${index + 1} folgt`, tone: "studio" };
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
const poster = slot.poster?.kind === "image" ? slot.poster.src : undefined;
return (
<figure>
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl bg-ink">
{playing && slot.src ? (
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
<track kind="captions" />
</video>
) : (
<>
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 640px) 33vw, 100vw" showLabel={showDrafts} />
{slot.src && (
<button
type="button"
onClick={() => setPlaying(true)}
aria-label={label}
className="absolute inset-0 m-auto flex h-20 w-20 items-center justify-center rounded-full bg-paper/90 text-ink transition-transform hover:scale-105"
>
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
</svg>
</button>
)}
</>
)}
</div>
{(slot.name || slot.caption || showDrafts) && (
{slot.src ? (
<video
src={poster ? slot.src : `${slot.src}#t=0.1`}
poster={poster}
controls
playsInline
preload="metadata"
aria-label={label}
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
>
<track kind="captions" />
</video>
) : (
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl">
<MediaFrame
media={slot.poster ?? { kind: "placeholder", label: `Video ${index + 1} folgt`, tone: "studio" }}
aspect="h-full w-full"
sizes="(min-width: 640px) 33vw, 100vw"
showLabel={showDrafts}
/>
</div>
)}
{(slot.name || slot.caption || (showDrafts && !slot.src)) && (
<figcaption className="mt-4">
{slot.name ? <span className="block font-semibold">{slot.name}</span> : showDrafts && <span className="draft-flag">Name und Freigabe folgen</span>}
{slot.caption && <span className="mt-1 block opacity-80">{slot.caption}</span>}
+7 -4
View File
@@ -95,14 +95,17 @@ export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
}
/**
* Drei Reels im Hochformat. Leere Slots erscheinen nur in Entwicklung und
* Staging; oeffentlich nie ein erfundenes Testimonial als Ersatz.
* Bis zu drei Videos (Models ueber ARTISTA). Ein einzelnes Video steht
* breit in der Mitte, mehrere im Raster. Leere Slots erscheinen nur in
* Entwicklung und Staging; oeffentlich nie ein erfundenes Testimonial.
*/
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
const { reels: content } = site;
const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
const visible = showDrafts ? slots : slots.filter((s) => s.src);
const filled = slots.filter((s) => s.src);
const visible = showDrafts ? [...filled, ...slots.filter((s) => !s.src)] : filled;
if (visible.length === 0) return null;
const single = visible.length === 1;
return (
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
<div className="container-edge">
@@ -110,7 +113,7 @@ export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels
<h2 id="reels-title" className="section-title">{content.title}</h2>
<p className="max-w-md text-paper/75">{content.lead}</p>
</div>
<ul className="mx-auto mt-12 grid max-w-5xl gap-6 sm:grid-cols-3 md:gap-8">
<ul className={`mx-auto mt-12 grid gap-6 md:gap-8 ${single ? "max-w-4xl" : "max-w-5xl sm:grid-cols-3"}`}>
{visible.map((slot, index) => (
<li key={index}>
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
+2 -2
View File
@@ -246,8 +246,8 @@ export const site = {
},
reels: {
title: "Reels",
lead: "Eindrücke und Stimmen in drei kurzen Clips.",
title: "Stimmen",
lead: "Models erzählen, wie sie ARTISTA erleben.",
// Genau drei Slots. Video und Vorschaubild kommen ueber das
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
// Name und Begleittext nur mit Freigabe der gezeigten Person.
+23 -5
View File
@@ -10,7 +10,7 @@
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
*/
import { stat } from "node:fs/promises";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { site, type Media } from "@/content/site";
import { env } from "./env";
@@ -21,14 +21,32 @@ const VIDEO_EXTS = [".mp4", ".webm"];
type Found = { src: string; ext: string };
/**
* Sucht "<ordner>/<name>.<endung>" ohne Ruecksicht auf Gross- und
* Kleinschreibung: Im Container (Linux) waere "Reel-1.JPG" sonst eine
* andere Datei als "reel-1.jpg". Geliefert wird der echte Dateiname.
*/
async function find(base: string, exts: readonly string[]): Promise<Found | null> {
const dir = path.resolve(env().MEDIA_DIR);
const root = path.resolve(env().MEDIA_DIR);
const folder = path.posix.dirname(base);
const name = path.posix.basename(base).toLowerCase();
const dir = folder === "." ? root : path.join(root, folder);
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return null; // Ordner fehlt: Platzhalter bleibt.
}
for (const ext of exts) {
const match = entries.find((entry) => entry.toLowerCase() === `${name}${ext}`);
if (!match) continue;
try {
const info = await stat(path.join(dir, `${base}${ext}`));
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
const info = await stat(path.join(dir, match));
if (!info.isFile()) continue;
const rel = folder === "." ? match : `${folder}/${match}`;
return { src: `/media/${encodeURI(rel)}?v=${Math.floor(info.mtimeMs)}`, ext };
} catch {
// Datei fehlt: naechste Endung probieren.
// Datei zwischenzeitlich entfernt: naechste Endung probieren.
}
}
return null;