Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf0f5c937 | ||
|
|
a37f12a60f |
No files matched your search
+1
-1
@@ -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.
|
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
|
## Logo
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 5rem;
|
scroll-padding-top: 5.5rem;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
scrollbar-color: var(--color-stone) var(--color-plaster);
|
scrollbar-color: var(--color-stone) var(--color-plaster);
|
||||||
accent-color: var(--color-rosso);
|
accent-color: var(--color-rosso);
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|
||||||
<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]">
|
<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>
|
<span className="sr-only">ARTISTA </span>
|
||||||
{hero.title}
|
{hero.title}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts
|
|||||||
<footer id="site-footer" className="border-t border-line bg-plaster">
|
<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="container-edge grid gap-10 py-14 md:grid-cols-12">
|
||||||
<div className="md:col-span-5">
|
<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} />
|
<Logo src={logo} />
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
<p className="mt-1 text-stone">Modelcamp 2027</p>
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
|
|||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
<LazyMotion features={domAnimation}>
|
<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">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
ref={menuButton}
|
ref={menuButton}
|
||||||
@@ -99,7 +101,12 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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} />
|
<Logo src={solid ? logoDark : logoLight} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,43 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { Media } from "@/content/site";
|
import type { Media } from "@/content/site";
|
||||||
import { MediaFrame } from "./MediaFrame";
|
import { MediaFrame } from "./MediaFrame";
|
||||||
|
|
||||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
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
|
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
|
||||||
* Video laedt erst nach Klick (preload="none") und startet nie von selbst
|
* (quer oder hoch). Es startet nie von selbst; geladen werden vorab nur die
|
||||||
* mit Ton. Kein externer Anbieter, keine Datenuebertragung an Dritte.
|
* 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 }) {
|
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
|
||||||
const label = slot.name ? `Reel von ${slot.name} abspielen` : `Reel ${index + 1} abspielen`;
|
const poster = slot.poster?.kind === "image" ? slot.poster.src : undefined;
|
||||||
const poster: Media = slot.poster ?? { kind: "placeholder", label: `Reel ${index + 1} folgt`, tone: "studio" };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure>
|
<figure>
|
||||||
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl bg-ink">
|
{slot.src ? (
|
||||||
{playing && slot.src ? (
|
<video
|
||||||
<video src={slot.src} controls autoPlay playsInline preload="none" className="h-full w-full object-cover">
|
src={poster ? slot.src : `${slot.src}#t=0.1`}
|
||||||
<track kind="captions" />
|
poster={poster}
|
||||||
</video>
|
controls
|
||||||
) : (
|
playsInline
|
||||||
<>
|
preload="metadata"
|
||||||
<MediaFrame media={poster} aspect="h-full w-full" sizes="(min-width: 640px) 33vw, 100vw" showLabel={showDrafts} />
|
aria-label={label}
|
||||||
{slot.src && (
|
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
|
||||||
<button
|
>
|
||||||
type="button"
|
<track kind="captions" />
|
||||||
onClick={() => setPlaying(true)}
|
</video>
|
||||||
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"
|
<div className="relative aspect-[9/16] overflow-hidden rounded-2xl">
|
||||||
>
|
<MediaFrame
|
||||||
<svg viewBox="0 0 24 24" className="ml-1 h-8 w-8" aria-hidden>
|
media={slot.poster ?? { kind: "placeholder", label: `Video ${index + 1} folgt`, tone: "studio" }}
|
||||||
<path d="M7 4.5v15l12-7.5z" fill="currentColor" />
|
aspect="h-full w-full"
|
||||||
</svg>
|
sizes="(min-width: 640px) 33vw, 100vw"
|
||||||
</button>
|
showLabel={showDrafts}
|
||||||
)}
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{(slot.name || slot.caption || (showDrafts && !slot.src)) && (
|
||||||
{(slot.name || slot.caption || showDrafts) && (
|
|
||||||
<figcaption className="mt-4">
|
<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.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>}
|
{slot.caption && <span className="mt-1 block opacity-80">{slot.caption}</span>}
|
||||||
|
|||||||
@@ -95,14 +95,17 @@ export function AboutSection({ showDrafts }: { showDrafts: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drei Reels im Hochformat. Leere Slots erscheinen nur in Entwicklung und
|
* Bis zu drei Videos (Models ueber ARTISTA). Ein einzelnes Video steht
|
||||||
* Staging; oeffentlich nie ein erfundenes Testimonial als Ersatz.
|
* 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[] }) {
|
export function ReelsSection({ showDrafts, reels }: { showDrafts: boolean; reels: Reel[] }) {
|
||||||
const { reels: content } = site;
|
const { reels: content } = site;
|
||||||
const slots = content.slots.map((slot, index) => ({ ...slot, ...(reels[index] ?? { src: null, poster: null }) }));
|
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;
|
if (visible.length === 0) return null;
|
||||||
|
const single = visible.length === 1;
|
||||||
return (
|
return (
|
||||||
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
|
<section id="reels" aria-labelledby="reels-title" className="on-dark bg-ink py-24 text-paper md:py-32">
|
||||||
<div className="container-edge">
|
<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>
|
<h2 id="reels-title" className="section-title">{content.title}</h2>
|
||||||
<p className="max-w-md text-paper/75">{content.lead}</p>
|
<p className="max-w-md text-paper/75">{content.lead}</p>
|
||||||
</div>
|
</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) => (
|
{visible.map((slot, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
|
<VideoSlot slot={slot} index={index} showDrafts={showDrafts} />
|
||||||
|
|||||||
+2
-2
@@ -246,8 +246,8 @@ export const site = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
reels: {
|
reels: {
|
||||||
title: "Reels",
|
title: "Stimmen",
|
||||||
lead: "Eindrücke und Stimmen in drei kurzen Clips.",
|
lead: "Models erzählen, wie sie ARTISTA erleben.",
|
||||||
// Genau drei Slots. Video und Vorschaubild kommen ueber das
|
// Genau drei Slots. Video und Vorschaubild kommen ueber das
|
||||||
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
|
// Medienverzeichnis (reels/reel-1.mp4 und reels/reel-1.jpg usw.).
|
||||||
// Name und Begleittext nur mit Freigabe der gezeigten Person.
|
// Name und Begleittext nur mit Freigabe der gezeigten Person.
|
||||||
|
|||||||
+23
-5
@@ -10,7 +10,7 @@
|
|||||||
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
|
* Das Aenderungsdatum haengt als ?v=… an der URL: ein ersetztes Bild ist
|
||||||
* sofort sichtbar, obwohl /media/… einen Tag lang gecacht wird.
|
* 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 path from "node:path";
|
||||||
import { site, type Media } from "@/content/site";
|
import { site, type Media } from "@/content/site";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
@@ -21,14 +21,32 @@ const VIDEO_EXTS = [".mp4", ".webm"];
|
|||||||
|
|
||||||
type Found = { src: string; ext: string };
|
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> {
|
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) {
|
for (const ext of exts) {
|
||||||
|
const match = entries.find((entry) => entry.toLowerCase() === `${name}${ext}`);
|
||||||
|
if (!match) continue;
|
||||||
try {
|
try {
|
||||||
const info = await stat(path.join(dir, `${base}${ext}`));
|
const info = await stat(path.join(dir, match));
|
||||||
if (info.isFile()) return { src: `/media/${base}${ext}?v=${Math.floor(info.mtimeMs)}`, ext };
|
if (!info.isFile()) continue;
|
||||||
|
const rel = folder === "." ? match : `${folder}/${match}`;
|
||||||
|
return { src: `/media/${encodeURI(rel)}?v=${Math.floor(info.mtimeMs)}`, ext };
|
||||||
} catch {
|
} catch {
|
||||||
// Datei fehlt: naechste Endung probieren.
|
// Datei zwischenzeitlich entfernt: naechste Endung probieren.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in new issue
Block a user