feat: Logo deutlich größer und auf jedem Untergrund sichtbar

Das hochgeladene logo.png ist die helle Fassung und war auf der hellen Kopfzeile und im Fuß bis auf das goldene A unsichtbar. Die Seite misst jetzt die Helligkeit (sharp) und ordnet die Datei selbst zu; fehlt eine passende Fassung, wird per CSS abgedunkelt bzw. aufgehellt. Über dem Foto bis 176 px hoch mit weichem Schatten, nach dem Scrollen 80 px. Raster-Logos laufen über die Bildoptimierung (Original 5378 px, 313 KB).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 07:15:09 +02:00
1 parent 33f5096bf1
commit 1ca09cbaef
8 files changed
+104 -27

No files matched your search

+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-36 text-center md:pt-44">
<div className="container-edge flex flex-col items-center pb-20 pt-44 text-center md:pt-60">
<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}
+26 -7
View File
@@ -1,12 +1,31 @@
import type { LogoImage } from "@/lib/site-media";
/**
* ARTISTA-Logo aus dem Medienverzeichnis (logo.svg/png bzw. logo-hell.*).
* Ohne Datei erscheint ein Schriftzug. Bewusst <img> statt next/image:
* SVG-Logos laufen nicht durch die Bildoptimierung.
* Umfaerbung, wenn keine passende Fassung hochgeladen wurde (siehe
* resolveLogos in site-media.ts): ein helles Logo wird auf heller Flaeche
* dunkel, ein dunkles auf Foto weiss.
*/
export function Logo({ src, className = "" }: { src: string | null; className?: string }) {
if (src) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt="ARTISTA" className={`h-full w-auto ${className}`} />;
const TONE_FILTER: Record<LogoImage["tone"], string> = {
"as-is": "",
darken: "brightness(0.28) contrast(1.2)",
lighten: "brightness(0) invert(1)",
};
/** Ueber dem Foto hebt ein weicher Schatten das Logo vom Bild ab. */
const PHOTO_SHADOW = "drop-shadow(0 2px 14px rgb(0 0 0 / 0.55))";
/**
* ARTISTA-Logo aus dem Medienverzeichnis (logo.* bzw. logo-hell.*). Ohne
* Datei erscheint ein Schriftzug. Bewusst <img> statt next/image: die URL
* ist bereits optimiert (Raster) bzw. ein SVG, und die Hoehe kommt vom
* Elternelement.
*/
export function Logo({ logo, onPhoto = false, className = "" }: { logo: LogoImage | null; onPhoto?: boolean; className?: string }) {
if (logo) {
const filter = [TONE_FILTER[logo.tone], onPhoto ? PHOTO_SHADOW : ""].filter(Boolean).join(" ");
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo.src} alt="ARTISTA Modelcamp 2027" style={filter ? { filter } : undefined} className={`h-full w-auto ${className}`} />
);
}
return <span className={`font-bold uppercase tracking-[0.32em] ${className}`}>Artista</span>;
}
+4 -3
View File
@@ -1,14 +1,15 @@
import type { PublicCampFacts } from "@/lib/camp";
import type { LogoImage } from "@/lib/site-media";
import { Logo } from "./Logo";
export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: string | null }) {
export function SiteFooter({ facts, showDrafts, logo }: { facts: PublicCampFacts; showDrafts: boolean; logo: LogoImage | null }) {
const organizer = facts.organizer;
return (
<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-20 items-center text-2xl">
<Logo src={logo} />
<p className="flex h-32 items-center text-2xl">
<Logo logo={logo} />
</p>
<p className="mt-1 text-stone">Modelcamp 2027</p>
</div>
+5 -4
View File
@@ -3,6 +3,7 @@
import { LazyMotion, domAnimation, m } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { NAV_ITEMS } from "@/content/site";
import type { LogoImage } from "@/lib/site-media";
import { Logo } from "./Logo";
const LEFT = NAV_ITEMS.slice(0, 3);
@@ -15,7 +16,7 @@ type NavItem = (typeof NAV_ITEMS)[number];
* rechts. Ueber dem Headerbild transparent mit heller Schrift, nach dem
* Scrollen auf hellem Grund.
*/
export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null; logoDark: string | null }) {
export function SiteHeader({ logoLight, logoDark }: { logoLight: LogoImage | null; logoDark: LogoImage | null }) {
const [active, setActive] = useState<string | null>(null);
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
@@ -83,7 +84,7 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
</a>
<LazyMotion features={domAnimation}>
<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"}`}
className={`container-edge grid grid-cols-[1fr_auto_1fr] items-center gap-4 transition-[height] duration-300 ${solid ? "h-24" : "h-32 md:h-48"}`}
>
<div className="flex items-center">
<button
@@ -105,9 +106,9 @@ export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null;
<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"}`}
className={`flex items-center text-xl transition-[height] duration-300 md:text-2xl ${solid ? "h-20" : "h-28 md:h-44"}`}
>
<Logo src={solid ? logoDark : logoLight} />
<Logo logo={solid ? logoDark : logoLight} onPhoto={!solid} />
</a>
<div className="flex items-center justify-end gap-7">
+62 -8
View File
@@ -12,14 +12,16 @@
*/
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { site, type Media } from "@/content/site";
import { env } from "./env";
import { optimizedImageUrl } from "./image-url";
const IMAGE_EXTS = [".jpg", ".jpeg", ".webp", ".png", ".avif"];
const LOGO_EXTS = [".svg", ".png", ".webp"];
const VIDEO_EXTS = [".mp4", ".webm"];
type Found = { src: string; ext: string };
type Found = { src: string; ext: string; file: string };
/**
* Sucht "<ordner>/<name>.<endung>" ohne Ruecksicht auf Gross- und
@@ -44,7 +46,7 @@ async function find(base: string, exts: readonly string[]): Promise<Found | null
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 };
return { src: `/media/${encodeURI(rel)}?v=${Math.floor(info.mtimeMs)}`, ext, file: path.join(dir, match) };
} catch {
// Datei zwischenzeitlich entfernt: naechste Endung probieren.
}
@@ -65,14 +67,65 @@ async function image(base: string, alt: string): Promise<Media | null> {
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
}
/**
* Logo samt Darstellung: "darken" bzw. "lighten" faerbt ein Logo per CSS um,
* wenn fuer den Untergrund keine passende Fassung hochgeladen wurde (z. B.
* nur ein helles Logo, das auf der hellen Kopfzeile sonst unsichtbar waere).
*/
export type LogoImage = { src: string; tone: "as-is" | "darken" | "lighten" };
type Tone = "light" | "dark";
/** Mittlere Helligkeit (0–255) der sichtbaren Pixel, ab der ein Logo als hell gilt. */
const LIGHT_LOGO_LUMINANCE = 140;
/** Breite der ausgelieferten Rastergrafik: scharf bis ~400 px Anzeigebreite auf 2x-Displays. */
const LOGO_DELIVERY_WIDTH = 828;
const toneCache = new Map<string, Tone>();
/**
* Hell oder dunkel? Gemessen statt am Dateinamen festgemacht: Wird das helle
* Logo als logo.png hochgeladen, landet es trotzdem auf dem dunklen Grund.
*/
async function logoTone(found: Found): Promise<Tone> {
const cached = toneCache.get(found.src);
if (cached) return cached;
const { data } = await sharp(found.file).resize(64, 64, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
let luminance = 0;
let weight = 0;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]! / 255;
if (alpha < 0.2) continue;
luminance += (0.2126 * data[i]! + 0.7152 * data[i + 1]! + 0.0722 * data[i + 2]!) * alpha;
weight += alpha;
}
const tone: Tone = weight > 0 && luminance / weight >= LIGHT_LOGO_LUMINANCE ? "light" : "dark";
toneCache.set(found.src, tone);
return tone;
}
function deliver(found: Found): string {
// SVG direkt (laeuft nicht durch die Bildoptimierung), Raster verkleinert:
// das Original-PNG hatte 5378 px Breite.
return found.ext === ".svg" ? found.src : optimizedImageUrl(found.src, LOGO_DELIVERY_WIDTH);
}
async function resolveLogos(candidates: (Found | null)[]): Promise<{ onLight: LogoImage | null; onDark: LogoImage | null }> {
const found = candidates.filter((c): c is Found => c !== null);
const toned = await Promise.all(found.map(async (f) => ({ f, tone: await logoTone(f).catch((): Tone => "dark") })));
const dark = toned.find((t) => t.tone === "dark")?.f;
const light = toned.find((t) => t.tone === "light")?.f;
const onLight: LogoImage | null = dark ? { src: deliver(dark), tone: "as-is" } : light ? { src: deliver(light), tone: "darken" } : null;
const onDark: LogoImage | null = light ? { src: deliver(light), tone: "as-is" } : dark ? { src: deliver(dark), tone: "lighten" } : null;
return { onLight, onDark };
}
export type Reel = { src: string | null; poster: Media | null };
export type IntroVideo = { src: string | null; poster: Media | null };
export type SiteMedia = {
/** Logo fuer helle Flaechen (dunkles Logo). */
logoDark: string | null;
/** Logo auf Foto oder dunklem Grund (helles Logo). */
logoLight: string | null;
/** Logo fuer helle Flaechen (Kopfzeile nach dem Scrollen, Fussbereich). */
logoDark: LogoImage | null;
/** Logo auf Foto oder dunklem Grund. */
logoLight: LogoImage | null;
header: Media;
headerVideo: string | null;
/** Video direkt unter dem Headerbild (intro.mp4, Vorschaubild intro.jpg). */
@@ -104,9 +157,10 @@ export async function loadSiteMedia(): Promise<SiteMedia> {
portraits[p.slug] = portraitList[index] ?? p.portrait;
});
const logos = await resolveLogos([logoDark, logoLight]);
return {
logoDark: logoDark?.src ?? logoLight?.src ?? null,
logoLight: logoLight?.src ?? logoDark?.src ?? null,
logoDark: logos.onLight,
logoLight: logos.onDark,
header: header ?? site.hero.media,
headerVideo: headerVideo?.src ?? null,
intro: { src: introVideo?.src ?? null, poster: introPoster },