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>
|
|
@ -173,6 +173,8 @@ module.exports = [
|
|||
{ key: 'image', label: 'Bildschirmfoto', 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: '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: 'titel', label: 'Titel / Name', type: 'text' },
|
||||
{ key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' },
|
||||
|
|
|
|||
|
|
@ -186,7 +186,9 @@
|
|||
"nameHidden": false,
|
||||
"image": "/mappe/elektro-rabe.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",
|
||||
|
|
@ -196,7 +198,9 @@
|
|||
"nameHidden": false,
|
||||
"image": "/mappe/maison-verte.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",
|
||||
|
|
@ -206,7 +210,9 @@
|
|||
"nameHidden": false,
|
||||
"image": "/mappe/atelier-rosenhain.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
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -1,4 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 256 B After Width: | Height: | Size: 2.6 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/icon-48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/icon-96.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/mappe/atelier-rosenhain-ganz.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/mappe/atelier-rosenhain-mobil-ganz.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/mappe/elektro-rabe-ganz.webp
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
public/mappe/elektro-rabe-mobil-ganz.webp
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
public/mappe/maison-verte-ganz.webp
Normal file
|
After Width: | Height: | Size: 485 KiB |
BIN
public/mappe/maison-verte-mobil-ganz.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
56
scripts/icons-bauen.mjs
Normal 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.');
|
||||
|
|
@ -3,17 +3,36 @@
|
|||
* Laptop mit optional angelehntem Handy, für Mappe und Hero.
|
||||
* Ein Video im Laptop spielt nur, solange es sichtbar ist (Skript unten),
|
||||
* 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 {
|
||||
screen: string;
|
||||
video?: string;
|
||||
phone?: string;
|
||||
alt?: string;
|
||||
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 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 }]}>
|
||||
|
|
@ -30,12 +49,21 @@ const loading = eager ? 'eager' : 'lazy';
|
|||
</div>
|
||||
</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>
|
||||
{phone && (
|
||||
<div class="phone" aria-hidden="true">
|
||||
<div class="phone-screen">
|
||||
<div class="phone">
|
||||
<div class="phone-screen" aria-hidden="true">
|
||||
<img src={phone} alt="" loading={loading} />
|
||||
</div>
|
||||
{handyZoom && <button type="button" class="zoom-hit" {...zoomAttrs(handyZoom, 'mobile')}></button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -67,6 +95,77 @@ const loading = eager ? 'eager' : 'lazy';
|
|||
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 {
|
||||
background: #1f1c19;
|
||||
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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 23%;
|
||||
background: #1f1c19;
|
||||
padding: 1.1%;
|
||||
|
|
|
|||
310
src/components/Zoom.astro
Normal 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>
|
||||
|
|
@ -10,6 +10,8 @@ interface 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>
|
||||
|
|
@ -17,9 +19,28 @@ const { title, description = site.meta.description } = Astro.props;
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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/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} />
|
||||
<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={themeCss(theme)} />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Devices from '../components/Devices.astro';
|
||||
import Zoom from '../components/Zoom.astro';
|
||||
import { site, rich } from '../lib/content';
|
||||
|
||||
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">
|
||||
|
|
@ -22,11 +32,17 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<div class="hero-visual">
|
||||
{hero.visual.customImage ? (
|
||||
<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>
|
||||
|
|
@ -146,6 +162,8 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
|
|||
video={item.video}
|
||||
phone={item.mobileImage}
|
||||
alt={item.titel || item.kategorie}
|
||||
screenZoom={zoomFor(item)}
|
||||
phoneZoom={zoomFor(item)}
|
||||
/>
|
||||
</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}`;
|
||||
});
|
||||
</script>
|
||||
<Zoom />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
|
|
|
|||