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:
1 parent
33f5096bf1
commit
1ca09cbaef
8 files changed
+104
-27
No files matched your search
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user