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
+103 -26

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Dateinamen bitte so schreiben: ohne Leerzeichen und Umlaute. Gross- und Kleinsch
| `logo.svg` (oder `.png`, `.webp`) | Dunkles Logo für helle Flächen: Kopfzeile nach dem Scrollen, Fußbereich | SVG bevorzugt, PNG mit transparentem Hintergrund |
| `logo-hell.svg` (oder `.png`, `.webp`) | Helles bzw. weißes Logo über dem Headerbild | Fehlt es, wird `logo.*` verwendet |
Das Logo wird 32 bis 40 px hoch angezeigt. Ein querformatiges Logo ohne Rand wirkt am besten.
Das Logo wird über dem Headerbild bis 176 px hoch angezeigt, nach dem Scrollen 80 px. Die Seite misst selbst, ob eine Datei hell oder dunkel ist, und setzt sie auf den passenden Untergrund. Liegt nur eine helle Fassung vor, wird sie auf hellen Flächen automatisch abgedunkelt (und umgekehrt). Große PNGs werden verkleinert ausgeliefert.
## Headerbild
+1
View File
@@ -33,6 +33,7 @@
"react": "19.3.0",
"react-dom": "19.3.0",
"server-only": "0.0.1",
"sharp": "0.35.4",
"stripe": "22.6.2",
"zod": "4.6.5"
},
+4 -3
View File
@@ -199,6 +199,9 @@ importers:
server-only:
specifier: 0.0.1
version: 0.0.1
sharp:
specifier: 0.35.4
version: 0.35.4(@types/[email protected])
stripe:
specifier: 22.6.2
version: 22.6.2(@types/[email protected])
@@ -4094,8 +4097,7 @@ snapshots:
'@humanwhocodes/[email protected]': {}
'@img/[email protected]':
optional: true
'@img/[email protected]': {}
'@img/[email protected]':
optionalDependencies:
@@ -6755,7 +6757,6 @@ snapshots:
'@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.4
'@types/node': 22.20.4
optional: true
[email protected]:
dependencies:
+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}
+25 -6
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) {
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
return <img src={src} alt="ARTISTA" className={`h-full w-auto ${className}`} />;
<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 },