Brand favicon, link preview image, and zoomable portfolio devices

Replace the Astro placeholder favicon (white L on indigo) with the larpaso
"la" mark from the prototype, in SVG/ICO/PNG sizes plus an Apple touch
icon, and add Open Graph tags with a 1200x630 preview image so shared links
show the logo. Icons are rebuilt with scripts/icons-bauen.mjs.

Clicking or tapping the laptop or phone in the hero and the portfolio now
opens a full-page capture of the example site in a dialog, scrollable and
switchable between desktop and mobile. The scroll videos stay as they were.
New content fields mappe.items[].fullImage / fullMobileImage, also in the
admin schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lara 2026-10-06 11:44:09 +02:00
parent 1c1687dcf3
commit 3ae5f815e8
21 changed files with 523 additions and 12 deletions

View file

@ -173,6 +173,8 @@ module.exports = [
{ key: 'image', label: 'Bildschirmfoto', type: 'image' }, { key: 'image', label: 'Bildschirmfoto', type: 'image' },
{ key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' }, { key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' },
{ key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' }, { key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' },
{ key: 'fullImage', label: 'Ganze Seite, Computer (Pfad, öffnet sich beim Anklicken; leer = nicht vergrößerbar)', type: 'text' },
{ key: 'fullMobileImage', label: 'Ganze Seite, Handy (Pfad, öffnet sich beim Anklicken)', type: 'text' },
{ key: 'kategorie', label: 'Kategorie', type: 'text' }, { key: 'kategorie', label: 'Kategorie', type: 'text' },
{ key: 'titel', label: 'Titel / Name', type: 'text' }, { key: 'titel', label: 'Titel / Name', type: 'text' },
{ key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' }, { key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' },

View file

@ -186,7 +186,9 @@
"nameHidden": false, "nameHidden": false,
"image": "/mappe/elektro-rabe.webp", "image": "/mappe/elektro-rabe.webp",
"mobileImage": "/mappe/elektro-rabe-mobil.webp", "mobileImage": "/mappe/elektro-rabe-mobil.webp",
"video": "/mappe/elektro-rabe.mp4" "video": "/mappe/elektro-rabe.mp4",
"fullImage": "/mappe/elektro-rabe-ganz.webp",
"fullMobileImage": "/mappe/elektro-rabe-mobil-ganz.webp"
}, },
{ {
"kategorie": "Konzeptarbeit · Tanzstudio", "kategorie": "Konzeptarbeit · Tanzstudio",
@ -196,7 +198,9 @@
"nameHidden": false, "nameHidden": false,
"image": "/mappe/maison-verte.webp", "image": "/mappe/maison-verte.webp",
"mobileImage": "/mappe/maison-verte-mobil.webp", "mobileImage": "/mappe/maison-verte-mobil.webp",
"video": "/mappe/maison-verte.mp4" "video": "/mappe/maison-verte.mp4",
"fullImage": "/mappe/maison-verte-ganz.webp",
"fullMobileImage": "/mappe/maison-verte-mobil-ganz.jpg"
}, },
{ {
"kategorie": "Konzeptarbeit · Kosmetikstudio", "kategorie": "Konzeptarbeit · Kosmetikstudio",
@ -206,7 +210,9 @@
"nameHidden": false, "nameHidden": false,
"image": "/mappe/atelier-rosenhain.webp", "image": "/mappe/atelier-rosenhain.webp",
"mobileImage": "/mappe/atelier-rosenhain-mobil.webp", "mobileImage": "/mappe/atelier-rosenhain-mobil.webp",
"video": "" "video": "",
"fullImage": "/mappe/atelier-rosenhain-ganz.webp",
"fullMobileImage": "/mappe/atelier-rosenhain-mobil-ganz.webp"
} }
] ]
}, },

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -1,4 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' width='100' height='100'><path d='M6 52 C6 26 24 8 52 6 C76 4 94 24 94 48 C94 74 76 96 48 94 C24 92 6 76 6 52 Z' fill='#2C4A42'/><path transform='translate(19.44 77.88) scale(0.8022)' d='M5.60 1.40Q4.30 1.40 2.70 0.00Q1.10 -1.40 1.10 -3.00Q1.10 -3.60 1.30 -4.20L20.30 -58.30Q20.80 -59.70 21.05 -61.00Q21.30 -62.30 21.30 -63.60Q21.30 -64.50 20.35 -65.30Q19.40 -66.10 18.00 -66.40Q17.00 -66.60 16.40 -66.95Q15.80 -67.30 15.80 -67.70Q15.80 -68.00 15.85 -68.20Q15.90 -68.40 16.05 -68.60Q16.20 -68.80 16.40 -68.80Q18.30 -69.30 21.00 -69.75Q23.70 -70.20 26.05 -70.55Q28.40 -70.90 29.20 -70.90Q29.70 -70.90 30.05 -70.55Q30.40 -70.20 30.40 -69.40Q30.40 -68.80 30.20 -68.05Q30.00 -67.30 29.80 -66.60L9.60 -9.00Q9.30 -8.20 9.20 -7.45Q9.10 -6.70 9.10 -6.10Q9.10 -4.50 10.10 -4.50Q11.50 -4.50 13.55 -6.05Q15.60 -7.60 17.60 -9.80Q19.60 -12.00 20.80 -13.70Q21.40 -14.50 22.20 -15.25Q23.00 -16.00 23.40 -16.00Q23.80 -16.00 23.85 -15.65Q23.90 -15.30 23.90 -14.70Q23.90 -12.70 22.55 -10.50Q21.20 -8.30 19.00 -6.15Q16.80 -4.00 14.30 -2.30Q11.80 -0.60 9.50 0.40Q7.20 1.40 5.60 1.40Z M36.10 1.40Q34.90 1.40 33.70 0.75Q32.50 0.10 31.75 -0.90Q31.00 -1.90 31.00 -3.00Q31.00 -6.60 33.40 -11.35Q35.80 -16.10 40.10 -21.35Q44.40 -26.60 50.00 -31.50Q55.40 -36.20 58.80 -38.55Q62.20 -40.90 63.80 -40.90Q65.40 -40.90 66.60 -39.50Q68.00 -37.70 67.85 -37.85Q67.70 -38.00 67.80 -38.00Q68.00 -38.00 68.15 -38.15Q68.30 -38.30 68.40 -38.60Q69.80 -41.00 70.10 -41.50Q70.40 -42.00 70.10 -41.60Q70.70 -42.60 71.30 -42.60Q72.20 -42.60 73.10 -42.30Q74.00 -42.00 74.55 -41.50Q75.10 -41.00 75.10 -40.50Q75.10 -40.00 74.40 -38.00L63.20 -8.30Q61.90 -4.90 64.50 -4.90Q66.30 -4.90 68.85 -6.65Q71.40 -8.40 73.30 -10.90Q73.50 -11.10 73.75 -11.25Q74.00 -11.40 74.20 -11.40Q74.60 -11.40 74.75 -11.15Q74.90 -10.90 74.90 -10.30Q74.90 -9.60 73.50 -7.55Q72.10 -5.50 70.60 -4.00Q67.80 -1.40 64.95 0.00Q62.10 1.40 59.70 1.40Q58.70 1.40 57.70 0.80Q56.70 0.20 56.05 -0.70Q55.40 -1.60 55.40 -2.50Q55.40 -3.60 56.00 -4.80Q55.60 -3.90 55.95 -4.80Q56.30 -5.70 57.15 -7.60Q58.00 -9.50 59.00 -11.60Q60.00 -13.70 60.70 -15.30Q61.20 -16.40 61.15 -16.95Q61.10 -17.50 60.50 -16.80Q54.40 -10.00 50.05 -6.00Q45.70 -2.00 42.40 -0.30Q39.10 1.40 36.10 1.40ZM41.70 -4.90Q43.90 -4.90 48.25 -8.55Q52.60 -12.20 58.10 -18.70Q61.90 -23.10 64.10 -26.80Q66.30 -30.50 66.30 -32.30Q66.30 -33.10 65.00 -34.20Q64.40 -34.70 63.70 -35.00Q63.00 -35.30 62.50 -35.30Q60.70 -35.30 57.55 -33.50Q54.40 -31.70 51.80 -29.20Q48.50 -26.00 45.50 -22.00Q42.50 -18.00 40.65 -14.30Q38.80 -10.60 38.80 -8.30Q38.80 -6.80 39.60 -5.85Q40.40 -4.90 41.70 -4.90Z' fill='#FCFAF6'/></svg>
<rect width="32" height="32" rx="8" fill="#4f46e5" />
<text x="16" y="22" font-family="Inter, sans-serif" font-size="16" font-weight="700" fill="#ffffff" text-anchor="middle">L</text>
</svg>

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/icon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

56
scripts/icons-bauen.mjs Normal file
View file

@ -0,0 +1,56 @@
// Baut Favicon, App-Icons und das Vorschaubild für geteilte Links aus den
// Marken-SVGs in public/. Aufruf: node scripts/icons-bauen.mjs
// Quelle des Favicons: das "la" auf Petrol aus dem Prototyp lara-webdesign.html.
import sharp from 'sharp';
import { readFileSync, writeFileSync } from 'node:fs';
const pub = (f) => new URL(`../public/${f}`, import.meta.url);
const papier = { r: 252, g: 250, b: 246, alpha: 1 };
const favicon = readFileSync(pub('favicon.svg'));
const logo = readFileSync(pub('logo.svg'));
const png = (svg, size) =>
sharp(svg, { density: 72 * Math.ceil(size / 100) * 4 }).resize(size, size).png().toBuffer();
// Transparente Icons
for (const size of [48, 96, 192, 512]) {
writeFileSync(pub(`icon-${size}.png`), await png(favicon, size));
}
// Apple-Icon braucht deckenden Grund (iOS füllt Transparenz schwarz)
const appleInner = await png(favicon, 148);
await sharp({ create: { width: 180, height: 180, channels: 4, background: papier } })
.composite([{ input: appleInner, gravity: 'center' }])
.png()
.toFile(new URL('../public/apple-touch-icon.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'));
// favicon.ico mit 16/32/48 als PNG-Einträge
const sizes = [16, 32, 48];
const bufs = await Promise.all(sizes.map((s) => png(favicon, s)));
const header = Buffer.alloc(6 + 16 * sizes.length);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(sizes.length, 4);
let offset = header.length;
sizes.forEach((s, i) => {
const e = 6 + 16 * i;
header.writeUInt8(s, e);
header.writeUInt8(s, e + 1);
header.writeUInt16LE(1, e + 4);
header.writeUInt16LE(32, e + 6);
header.writeUInt32LE(bufs[i].length, e + 8);
header.writeUInt32LE(offset, e + 12);
offset += bufs[i].length;
});
writeFileSync(pub('favicon.ico'), Buffer.concat([header, ...bufs]));
// Vorschaubild für WhatsApp, Signal, Facebook, LinkedIn & Co. (1200 x 630)
const logoPng = await sharp(logo, { density: 300 }).resize({ width: 860 }).png().toBuffer();
await sharp({ create: { width: 1200, height: 630, channels: 4, background: papier } })
.composite([{ input: logoPng, gravity: 'center' }])
.flatten({ background: papier })
.jpeg({ quality: 88 })
.toFile(new URL('../public/og-image.jpg', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'));
console.log('Icons gebaut.');

View file

@ -3,17 +3,36 @@
* Laptop mit optional angelehntem Handy, für Mappe und Hero. * Laptop mit optional angelehntem Handy, für Mappe und Hero.
* Ein Video im Laptop spielt nur, solange es sichtbar ist (Skript unten), * Ein Video im Laptop spielt nur, solange es sichtbar ist (Skript unten),
* und bei "Bewegung reduzieren" gar nicht - dann bleibt das Standbild. * und bei "Bewegung reduzieren" gar nicht - dann bleibt das Standbild.
* Mit `screenZoom`/`phoneZoom` wird das Gerät anklickbar und öffnet die ganze
* Seite in der Großansicht (<Zoom>, einmal pro Seite eingebunden).
*/ */
export interface ZoomSource {
title: string;
desktop?: string;
mobile?: string;
}
interface Props { interface Props {
screen: string; screen: string;
video?: string; video?: string;
phone?: string; phone?: string;
alt?: string; alt?: string;
eager?: boolean; eager?: boolean;
screenZoom?: ZoomSource;
phoneZoom?: ZoomSource;
} }
const { screen, video, phone, alt = '', eager = false } = Astro.props; const { screen, video, phone, alt = '', eager = false, screenZoom, phoneZoom } = Astro.props;
const loading = eager ? 'eager' : 'lazy'; const loading = eager ? 'eager' : 'lazy';
const zoomAttrs = (z: ZoomSource, mode: 'desktop' | 'mobile') => ({
'data-zoom': mode,
'data-zoom-title': z.title,
'data-zoom-desktop': z.desktop || undefined,
'data-zoom-mobile': z.mobile || undefined,
'aria-label': `${z.title} ${mode === 'desktop' ? 'am Computer' : 'auf dem Handy'} vergrößert ansehen`,
});
const laptopZoom = screenZoom && (screenZoom.desktop || screenZoom.mobile) ? screenZoom : undefined;
const handyZoom = phoneZoom && (phoneZoom.mobile || phoneZoom.desktop) ? phoneZoom : undefined;
--- ---
<div class:list={['devices', { 'has-phone': phone }]}> <div class:list={['devices', { 'has-phone': phone }]}>
@ -30,12 +49,21 @@ const loading = eager ? 'eager' : 'lazy';
</div> </div>
</div> </div>
<div class="laptop-base"></div> <div class="laptop-base"></div>
{laptopZoom && (
<button type="button" class="zoom-hit" {...zoomAttrs(laptopZoom, 'desktop')}>
<span class="zoom-badge" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="2.2" /><path d="M15 15l5 5M10.5 7.5v6M7.5 10.5h6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" /></svg>
Vergrößern
</span>
</button>
)}
</div> </div>
{phone && ( {phone && (
<div class="phone" aria-hidden="true"> <div class="phone">
<div class="phone-screen"> <div class="phone-screen" aria-hidden="true">
<img src={phone} alt="" loading={loading} /> <img src={phone} alt="" loading={loading} />
</div> </div>
{handyZoom && <button type="button" class="zoom-hit" {...zoomAttrs(handyZoom, 'mobile')}></button>}
</div> </div>
)} )}
</div> </div>
@ -67,6 +95,77 @@ const loading = eager ? 'eager' : 'lazy';
padding: 0 9% 7% 0; padding: 0 9% 7% 0;
} }
.laptop {
position: relative;
}
.zoom-hit {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: zoom-in;
-webkit-tap-highlight-color: transparent;
}
.zoom-hit:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 4px;
}
.zoom-badge {
position: absolute;
left: 4%;
bottom: 12%;
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.35em 0.75em 0.35em 0.6em;
border-radius: 999px;
background: rgba(28, 24, 19, 0.82);
color: #fcfaf6;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
box-shadow: 0 6px 16px -6px rgba(0, 0, 0, 0.5);
transition: background 0.2s, transform 0.2s;
}
.laptop-lid,
.phone {
transition: transform 0.25s ease;
}
@media (hover: hover) {
.zoom-badge {
opacity: 0.85;
}
.laptop:hover .zoom-badge,
.zoom-hit:focus-visible .zoom-badge {
opacity: 1;
background: rgba(28, 24, 19, 0.94);
transform: translateY(-2px);
}
.laptop:has(.zoom-hit):hover .laptop-lid,
.phone:has(.zoom-hit):hover {
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.laptop-lid,
.phone,
.zoom-badge {
transition: none;
}
}
.laptop-lid { .laptop-lid {
background: #1f1c19; background: #1f1c19;
border-radius: clamp(8px, 1.4vw, 16px) clamp(8px, 1.4vw, 16px) 3px 3px; border-radius: clamp(8px, 1.4vw, 16px) clamp(8px, 1.4vw, 16px) 3px 3px;
@ -116,6 +215,7 @@ const loading = eager ? 'eager' : 'lazy';
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 2;
width: 23%; width: 23%;
background: #1f1c19; background: #1f1c19;
padding: 1.1%; padding: 1.1%;

310
src/components/Zoom.astro Normal file
View file

@ -0,0 +1,310 @@
---
/**
* Großansicht für die Beispielseiten in den Geräterahmen.
* Ein Klick (oder Tippen) auf Laptop oder Handy in <Devices> öffnet die ganze
* Seite als Aufnahme zum Durchscrollen, umschaltbar zwischen Computer und Handy.
* Einmal pro Seite einbinden; die Auslöser finden sich über [data-zoom].
*/
---
<dialog class="zoom" aria-labelledby="zoom-title">
<div class="zoom-bar">
<p class="zoom-title" id="zoom-title"></p>
<div class="zoom-switch" role="group" aria-label="Ansicht">
<button type="button" data-mode="desktop" aria-pressed="true">Computer</button>
<button type="button" data-mode="mobile" aria-pressed="false">Handy</button>
</div>
<button type="button" class="zoom-close" aria-label="Großansicht schließen">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="zoom-stage" data-mode="desktop">
<div class="zoom-browser">
<div class="zoom-browser-top" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="zoom-scroll" tabindex="0" aria-label="Seite am Computer, zum Durchscrollen">
<img alt="" decoding="async" data-slot="desktop" />
</div>
</div>
<div class="zoom-phone">
<div class="zoom-scroll" tabindex="0" aria-label="Seite auf dem Handy, zum Durchscrollen">
<img alt="" decoding="async" data-slot="mobile" />
</div>
</div>
</div>
<p class="zoom-hint">Konzeptentwurf · zum Durchscrollen</p>
</dialog>
<script>
const dialog = document.querySelector<HTMLDialogElement>('dialog.zoom');
if (dialog && typeof dialog.showModal === 'function') {
const title = dialog.querySelector<HTMLElement>('.zoom-title')!;
const stage = dialog.querySelector<HTMLElement>('.zoom-stage')!;
const switcher = dialog.querySelector<HTMLElement>('.zoom-switch')!;
const imgs = {
desktop: dialog.querySelector<HTMLImageElement>('img[data-slot="desktop"]')!,
mobile: dialog.querySelector<HTMLImageElement>('img[data-slot="mobile"]')!,
};
type Mode = keyof typeof imgs;
let sources: Record<Mode, string> = { desktop: '', mobile: '' };
const show = (mode: Mode) => {
stage.dataset.mode = mode;
switcher.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.mode === mode));
});
const img = imgs[mode];
if (img.getAttribute('src') !== sources[mode]) {
img.classList.remove('is-loaded');
img.src = sources[mode];
}
img.closest('.zoom-scroll')!.scrollTop = 0;
};
Object.values(imgs).forEach((img) => {
img.addEventListener('load', () => img.classList.add('is-loaded'));
});
document.addEventListener('click', (event) => {
const trigger = (event.target as Element).closest<HTMLElement>('[data-zoom]');
if (!trigger) return;
sources = {
desktop: trigger.dataset.zoomDesktop || '',
mobile: trigger.dataset.zoomMobile || '',
};
title.textContent = trigger.dataset.zoomTitle || '';
switcher.hidden = !(sources.desktop && sources.mobile);
const wanted = trigger.dataset.zoom as Mode;
show(sources[wanted] ? wanted : sources.desktop ? 'desktop' : 'mobile');
dialog.showModal();
document.documentElement.classList.add('zoom-open');
});
switcher.addEventListener('click', (event) => {
const b = (event.target as Element).closest<HTMLButtonElement>('button[data-mode]');
if (b) show(b.dataset.mode as Mode);
});
dialog.querySelector('.zoom-close')!.addEventListener('click', () => dialog.close());
// Klick auf den abgedunkelten Rand schließt
dialog.addEventListener('click', (event) => {
const t = event.target as Element;
if (t === dialog || t === stage || t.classList.contains('zoom-hint')) dialog.close();
});
dialog.addEventListener('close', () => {
document.documentElement.classList.remove('zoom-open');
});
}
</script>
<style>
:global(html.zoom-open) {
overflow: hidden;
}
.zoom {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #fcfaf6;
overflow: hidden;
}
.zoom[open] {
display: grid;
grid-template-rows: auto 1fr auto;
}
.zoom::backdrop {
background: rgba(24, 20, 15, 0.96);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.zoom-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem clamp(1rem, 3vw, 2rem);
}
.zoom-title {
margin: 0;
color: #fcfaf6;
flex: 1;
min-width: 0;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.zoom-switch {
display: flex;
padding: 3px;
border-radius: 999px;
background: rgba(252, 250, 246, 0.12);
}
.zoom-switch button {
border: 0;
border-radius: 999px;
padding: 0.4rem 0.9rem;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.zoom-switch button[aria-pressed='true'] {
background: #fcfaf6;
color: #1c1813;
}
.zoom-close {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 0;
border-radius: 50%;
background: rgba(252, 250, 246, 0.12);
color: inherit;
cursor: pointer;
}
.zoom-close:hover,
.zoom-switch button:hover:not([aria-pressed='true']) {
background: rgba(252, 250, 246, 0.22);
}
.zoom button:focus-visible,
.zoom-scroll:focus-visible {
outline: 2px solid #d8b87e;
outline-offset: 2px;
}
.zoom-stage {
min-height: 0;
display: flex;
justify-content: center;
padding: 0 clamp(0.5rem, 3vw, 2rem);
}
.zoom-browser,
.zoom-phone {
display: none;
min-height: 0;
}
.zoom-stage[data-mode='desktop'] .zoom-browser,
.zoom-stage[data-mode='mobile'] .zoom-phone {
display: flex;
flex-direction: column;
}
.zoom-browser {
width: min(100%, 1240px);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7);
}
.zoom-browser-top {
flex: none;
display: flex;
gap: 6px;
padding: 10px 14px;
background: #e9e4da;
}
.zoom-browser-top span {
width: 10px;
height: 10px;
border-radius: 50%;
background: #c9c1b3;
}
.zoom-phone {
height: min(100%, 860px);
aspect-ratio: 390 / 844;
max-width: 100%;
padding: 10px;
border-radius: 38px;
background: #1f1c19;
box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7);
}
.zoom-phone .zoom-scroll {
border-radius: 28px;
}
.zoom-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
background: #fff;
}
.zoom-scroll img {
display: block;
width: 100%;
height: auto;
opacity: 0;
transition: opacity 0.25s;
}
.zoom-scroll img.is-loaded {
opacity: 1;
}
.zoom-hint {
margin: 0;
padding: 0.6rem 1rem 0.9rem;
text-align: center;
font-size: 0.8125rem;
color: rgba(252, 250, 246, 0.7);
}
@media (max-width: 600px) {
.zoom-bar {
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.zoom-title {
flex-basis: calc(100% - 3.5rem);
order: 0;
}
.zoom-close {
order: 1;
}
.zoom-switch {
order: 2;
margin: 0 auto;
}
.zoom-browser {
border-radius: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.zoom-scroll img {
transition: none;
}
}
</style>

View file

@ -10,6 +10,8 @@ interface Props {
} }
const { title, description = site.meta.description } = Astro.props; const { title, description = site.meta.description } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).href;
const ogImage = new URL('/og-image.jpg', Astro.site).href;
--- ---
<!doctype html> <!doctype html>
@ -17,9 +19,28 @@ const { title, description = site.meta.description } = Astro.props;
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="96x96" href="/icon-96.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#FCFAF6" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<title>{title} · Larpaso</title> <title>{title} · Larpaso</title>
<link rel="canonical" href={canonical} />
<!-- Vorschau beim Teilen (WhatsApp, Signal, Facebook, LinkedIn, Mail) -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Larpaso" />
<meta property="og:locale" content="de_DE" />
<meta property="og:title" content={`${title} · Larpaso`} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="larpaso Webdesign" />
<meta name="twitter:card" content="summary_large_image" />
<style set:html={fontFacesCss(theme)} /> <style set:html={fontFacesCss(theme)} />
<style set:html={themeCss(theme)} /> <style set:html={themeCss(theme)} />

View file

@ -1,9 +1,19 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Devices from '../components/Devices.astro'; import Devices from '../components/Devices.astro';
import Zoom from '../components/Zoom.astro';
import { site, rich } from '../lib/content'; import { site, rich } from '../lib/content';
const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site; const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site;
// Großansicht: zu jedem Bild die ganze Seite aus der Mappe suchen
type MappeItem = (typeof mappe.items)[number];
const zoomFor = (item?: MappeItem) =>
item && !item.nameHidden && (item.fullImage || item.fullMobileImage)
? { title: item.titel, desktop: item.fullImage, mobile: item.fullMobileImage }
: undefined;
const findItem = (src: string) =>
mappe.items.find((i) => src && (i.image === src || i.mobileImage === src));
--- ---
<Layout title="Startseite"> <Layout title="Startseite">
@ -22,11 +32,17 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
</div> </div>
</div> </div>
<div class="hero-visual" aria-hidden="true"> <div class="hero-visual">
{hero.visual.customImage ? ( {hero.visual.customImage ? (
<img class="hero-visual-image" src={hero.visual.customImage} alt="" loading="lazy" /> <img class="hero-visual-image" src={hero.visual.customImage} alt="" loading="lazy" />
) : ( ) : (
<Devices screen={hero.visual.laptop} phone={hero.visual.phone} eager /> <Devices
screen={hero.visual.laptop}
phone={hero.visual.phone}
eager
screenZoom={zoomFor(findItem(hero.visual.laptop))}
phoneZoom={zoomFor(findItem(hero.visual.phone))}
/>
)} )}
</div> </div>
</div> </div>
@ -146,6 +162,8 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
video={item.video} video={item.video}
phone={item.mobileImage} phone={item.mobileImage}
alt={item.titel || item.kategorie} alt={item.titel || item.kategorie}
screenZoom={zoomFor(item)}
phoneZoom={zoomFor(item)}
/> />
</div> </div>
) : ( ) : (
@ -340,6 +358,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
window.location.href = `mailto:${contactEmail}?subject=${subject}&body=${body}`; window.location.href = `mailto:${contactEmail}?subject=${subject}&body=${body}`;
}); });
</script> </script>
<Zoom />
</Layout> </Layout>
<style> <style>