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.
|
||||
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user