feat: Team, Villa-Bilder und bestaetigte Camp-Eckdaten
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 2m49s

- Team-Abschnitt mit neun Personen, Eric Heitz als Head of Modelcamp,
  Portraets im Rundbogen mit Initialen-Platzhaltern bis Fotos vorliegen
- Unterkunftsbilder der Villa La Chiesina lokal in public/images/unterkunft
  (vom Auftraggeber freigegeben), Hero-Leitbild, Ausstattung, Booking-Link
- Migration: Termin 08.05.-15.05.2027, Ort Arcidosso (Toskana), eigenes
  Bett im geteilten Zimmer, 13 Plaetze; Admin-Werte bleiben erhalten
- Reveal: kein Hydration-Fehler mehr bei reduzierter Bewegung
- Doku zu offenen Punkten aktualisiert

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:39:27 +02:00
1 parent 28663cda7a
commit 804bb832ea
28 files changed
+220 -89

No files matched your search

+8 -6
View File
@@ -31,17 +31,19 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
};
const step = (delta: number) => setCurrent((c) => (c === null ? c : (c + delta + images.length) % images.length));
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein Panorama.
const layout = ["md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "md:col-span-12"];
// Grosses Bild links ueber zwei Reihen, rechts zwei Bilder, darunter ein
// Panorama, danach Dreierreihen. Auf dem Smartphone zwei Spalten, damit
// die Galerie nicht endlos lang wird; Bild 1 und das Panorama volle Breite.
const layout = ["col-span-2 md:col-span-7 md:row-span-2", "md:col-span-5", "md:col-span-5", "col-span-2 md:col-span-12"];
const aspect = (index: number) =>
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-[3/2]";
index === 0 ? "aspect-[4/3] md:aspect-auto md:h-full" : index === 3 ? "aspect-[3/2] md:aspect-[3/1]" : "aspect-square md:aspect-[3/2]";
return (
<>
<ul className="mt-12 grid gap-3 md:grid-cols-12 md:gap-4">
<ul className="mt-12 grid grid-cols-2 gap-3 md:grid-cols-12 md:gap-4">
{images.map((media, index) => (
<li key={index} className={layout[index] ?? "md:col-span-4"}>
<Reveal delay={index * 0.08} className={index === 0 ? "md:h-full" : ""}>
<Reveal delay={Math.min(index, 4) * 0.08} className={index === 0 ? "md:h-full" : ""}>
<button
ref={(el) => {
triggers.current[index] = el;
@@ -54,7 +56,7 @@ export function Gallery({ images, showLabels }: { images: readonly Media[]; show
<MediaFrame
media={media}
aspect={aspect(index)}
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 100vw"}
sizes={index === 0 ? "(min-width: 768px) 58vw, 100vw" : index === 3 ? "100vw" : "(min-width: 768px) 40vw, 50vw"}
showLabel={showLabels}
className="w-full"
/>
+15 -7
View File
@@ -21,6 +21,9 @@ const TONES: Record<PlaceholderTone, Plate> = {
studio: { background: "#d2c9bd", glyph: "A", italic: true, style: { fontSize: "96cqh", right: "-16cqw", bottom: "-16cqh" } },
};
// Initialen auf Portraet-Platzhaltern: gross, angeschnitten am unteren Rand.
const INITIALS_STYLE: React.CSSProperties = { fontSize: "34cqh", left: "8cqw", bottom: "-4cqh", letterSpacing: "-0.03em" };
type Props = {
media: Media;
/** Tailwind-Klasse fuer das Seitenverhaeltnis, z. B. "aspect-[4/5]". */
@@ -29,17 +32,22 @@ type Props = {
showLabel: boolean;
priority?: boolean;
className?: string;
/** "arch": oben rund wie ein Rundbogenfenster (Team-Portraets). */
shape?: "rect" | "arch";
};
const ARCH = "rounded-t-[50vw]";
/**
* Bild oder erkennbarer Platzhalter. Platzhalter tragen in Entwicklung und
* Staging einen sichtbaren Hinweis, damit fehlende Freigaben nicht
* uebersehen werden.
*/
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "" }: Props) {
export function MediaFrame({ media, aspect, sizes, showLabel, priority, className = "", shape = "rect" }: Props) {
const shapeClass = shape === "arch" ? ARCH : "";
if (media.kind === "image") {
return (
<div className={`relative overflow-hidden bg-sand ${aspect} ${className}`}>
<div className={`relative overflow-hidden bg-sand ${aspect} ${shapeClass} ${className}`}>
<Image src={media.src} alt={media.alt} fill sizes={sizes} priority={priority} className="object-cover" />
</div>
);
@@ -49,17 +57,17 @@ export function MediaFrame({ media, aspect, sizes, showLabel, priority, classNam
<div
role="img"
aria-label={`Platzhalter: ${media.label}`}
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${className}`}
className={`placeholder-plate relative isolate overflow-hidden ${aspect} ${shapeClass} ${className}`}
style={{ backgroundColor: tone.background, containerType: "size" }}
>
<span
aria-hidden
className={`display pointer-events-none absolute select-none leading-none text-ink/[0.07] ${tone.italic ? "italic" : ""}`}
style={tone.style}
className={`display pointer-events-none absolute select-none leading-none ${media.glyph ? "text-ink/[0.13]" : "text-ink/[0.07]"} ${tone.italic && !media.glyph ? "italic" : ""}`}
style={media.glyph ? INITIALS_STYLE : tone.style}
>
{tone.glyph}
{media.glyph ?? tone.glyph}
</span>
<span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />
{shape === "rect" && <span aria-hidden className="pointer-events-none absolute inset-3 border border-ink/10 md:inset-4" />}
{showLabel && (
<span className="absolute bottom-6 left-6 bg-paper/90 px-2.5 py-1 text-xs font-semibold text-ink md:bottom-7 md:left-7">
Platzhalter: {media.label}
+4 -2
View File
@@ -8,8 +8,10 @@ import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
* Animation warten muss.
*/
export function Reveal({ children, className = "", delay = 0 }: { children: React.ReactNode; className?: string; delay?: number }) {
// Immer dasselbe Element rendern: der Server kennt die Bewegungs-
// einstellung nicht, ein anderer Elementbaum fuehrt zu Hydration-Fehlern.
// Bei reduzierter Bewegung wird ohne Dauer sofort aufgedeckt.
const reduce = useReducedMotion();
if (reduce) return <div className={className}>{children}</div>;
return (
<LazyMotion features={domAnimation}>
<m.div
@@ -17,7 +19,7 @@ export function Reveal({ children, className = "", delay = 0 }: { children: Reac
initial={{ clipPath: "inset(12% 0 0 0)", opacity: 0.4 }}
whileInView={{ clipPath: "inset(0% 0 0 0)", opacity: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
transition={reduce ? { duration: 0 } : { duration: 0.9, ease: [0.22, 1, 0.36, 1], delay }}
>
{children}
</m.div>
+11 -6
View File
@@ -100,26 +100,31 @@ export function AccommodationSection({ facts, showDrafts }: { facts: PublicCampF
return (
<section id="unterkunft" aria-labelledby="unterkunft-title" className="py-24 md:py-36">
<div className="container-edge">
<h2 id="unterkunft-title" className="section-title">{accommodation.title}</h2>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{accommodation.name} · {accommodation.place}</p>
<h2 id="unterkunft-title" className="section-title mt-3">{accommodation.title}</h2>
<p className="display mt-6 max-w-2xl text-[1.5rem] leading-[1.25] text-ink/85 md:text-[1.875rem]">{accommodation.lead}</p>
<Gallery images={accommodation.images} showLabels={showDrafts} />
<div className="mt-12 grid gap-8 md:grid-cols-12">
<div className="md:col-span-7">
<h3 className="text-lg font-semibold">Zimmer und Unterbringung</h3>
<p className="mt-3 max-w-2xl">
Gebucht wird ein Platz pro Person mit geteilter Unterbringung. Einzelbelegung und Zimmer-Upgrades sind nicht buchbar.
</p>
{facts.accommodationText ? (
<p className="mt-3 max-w-2xl whitespace-pre-line">{facts.accommodationText}</p>
) : (
<p className="mt-3 max-w-2xl text-stone">
Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
Gebucht wird ein Platz pro Person mit geteilter Unterbringung; Einzelzimmer sind nicht buchbar. Die genaue Zimmer- und Bettaufteilung veröffentlichen wir, sobald sie verbindlich feststeht – in jedem Fall bevor du buchst.
</p>
)}
</div>
<div className="md:col-span-4 md:col-start-9">
<h3 className="text-lg font-semibold">Ausstattung</h3>
<ul className="mt-3 divide-y divide-line border-y border-line">
{accommodation.features.map((feature) => (
<li key={feature} className="py-2.5">{feature}</li>
))}
</ul>
<p className="mt-2 text-sm text-stone">Laut Beschreibung der Unterkunft.</p>
{accommodation.bookingUrl ? (
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet">
<a href={accommodation.bookingUrl} target="_blank" rel="noopener noreferrer" className="btn btn-quiet mt-6">
Unterkunft ansehen (externe Seite)
</a>
) : (
+59 -26
View File
@@ -1,36 +1,69 @@
import { site } from "@/content/site";
import { site, type Media } from "@/content/site";
import { MediaFrame } from "../MediaFrame";
import { Reveal } from "../Reveal";
import { VideoSlot } from "../VideoSlot";
export function TeachersSection({ showDrafts }: { showDrafts: boolean }) {
const { teachers } = site;
type TeamMember = { name: string; role: string; bio: string | null; portrait: Media };
function Bio({ bio, showDrafts }: { bio: string | null; showDrafts: boolean }) {
if (bio) return <p className="mt-3 max-w-md">{bio}</p>;
return showDrafts ? <p className="mt-3"><span className="draft-flag">Kurzbeschreibung folgt</span></p> : null;
}
/**
* Team: Eric Heitz als Head of Modelcamp gross vorangestellt, darunter alle
* weiteren gleichwertig im Raster. Portraets im Rundbogen – ein Motiv aus
* der Architektur der Villa. Die Spalten sind auf breiten Bildschirmen
* leicht versetzt, damit das Raster wie eine Magazinseite wirkt.
*/
export function TeamSection({ showDrafts }: { showDrafts: boolean }) {
const { team } = site;
const { head } = team;
return (
<section id="dozentinnen" aria-labelledby="dozentinnen-title" className="bg-paper py-24 md:py-36">
<section id="team" aria-labelledby="team-title" className="bg-paper py-24 md:py-36">
<div className="container-edge">
<h2 id="dozentinnen-title" className="section-title">{teachers.title}</h2>
<div className="mt-14 grid gap-16 md:grid-cols-12 md:gap-10">
{teachers.people.map((person, index) => (
<article
key={index}
aria-label={person.name ?? `Dozentin ${index + 1}`}
className={index === 0 ? "md:col-span-5" : "md:col-span-5 md:col-start-8 md:mt-40"}
>
<Reveal>
<MediaFrame media={person.portrait} aspect="aspect-[3/4]" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
</Reveal>
<h3 className="display mt-6 text-3xl">
{person.name ?? (showDrafts ? `Dozentin ${index + 1}: Name folgt` : "Wird in Kürze vorgestellt")}
</h3>
{person.role && <p className="mt-1 text-stone">{person.role}</p>}
{person.bio ? (
<p className="mt-4 max-w-md">{person.bio}</p>
) : (
showDrafts && <p className="mt-4"><span className="draft-flag">Kurzbeschreibung folgt</span></p>
)}
</article>
))}
<div className="grid gap-6 md:grid-cols-12 md:gap-10">
<h2 id="team-title" className="section-title md:col-span-5">{team.title}</h2>
<p className="max-w-xl text-lg text-ink/85 md:col-span-6 md:col-start-7 md:pt-3">{team.lead}</p>
</div>
<article aria-label={head.name} className="mt-14 grid items-end gap-8 md:mt-20 md:grid-cols-12 md:gap-10">
<Reveal className="md:col-span-5">
<MediaFrame media={head.portrait} aspect="aspect-[4/5]" shape="arch" sizes="(min-width: 768px) 40vw, 100vw" showLabel={showDrafts} />
</Reveal>
<div className="md:col-span-6 md:col-start-7 md:pb-10">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-rosso">{head.title}</p>
<h3 className="display mt-4 text-[3rem] leading-[0.95] md:text-[5.5rem]">{head.name}</h3>
<p className="mt-5 border-t border-ink pt-4 text-lg">{head.role}</p>
<Bio bio={head.bio} showDrafts={showDrafts} />
</div>
</article>
<ul className="mt-20 grid grid-cols-2 gap-x-4 gap-y-12 md:mt-28 md:grid-cols-4 md:gap-x-8 md:gap-y-16">
{team.people.map((person: TeamMember, index) => (
<li key={person.name} className={index % 2 === 1 ? "md:mt-16" : ""}>
<article aria-label={person.name}>
<Reveal delay={(index % 4) * 0.06}>
<MediaFrame
media={person.portrait}
aspect="aspect-[3/4]"
shape="arch"
sizes="(min-width: 768px) 22vw, 50vw"
showLabel={false}
/>
</Reveal>
<h3 className="display mt-4 text-[1.375rem] leading-tight md:text-[1.75rem]">{person.name}</h3>
<p className="mt-1 text-sm text-stone md:text-[0.9375rem]">{person.role}</p>
<Bio bio={person.bio} showDrafts={showDrafts} />
</article>
</li>
))}
</ul>
{showDrafts && (
<p className="mt-12">
<span className="draft-flag">Porträts folgen</span> Platzhalter mit Initialen, bis freigegebene Fotos vorliegen.
</p>
)}
</div>
</section>
);