3d kacheln überarbeitung
Neu aufgesetzt ohne das versehentlich mitcommittete 3,8-GB-Zip (AAOClient-4.8.zip) - GitHub lehnt Dateien ueber 100 MB ab, wodurch jeder Push scheiterte. Inhalt sonst unveraendert.
This commit is contained in:
parent
873b9c2b50
commit
b46042d71d
2 changed files with 376 additions and 4 deletions
|
|
@ -265,7 +265,9 @@ export default function ModelPage() {
|
||||||
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
|
const { package_card_theme: packageCardTheme, package_card_layout: packageCardLayout } = useSiteSettings();
|
||||||
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
|
const TIERS = PACKAGE_THEMES[packageCardTheme] || PACKAGE_THEMES.graphite;
|
||||||
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
|
const layout = ['grid', 'strip', 'coverflow'].includes(packageCardLayout) ? packageCardLayout : 'strip';
|
||||||
const coverflowRef = useRef(null);
|
const scrollerRef = useRef(null);
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const thumbRef = useRef(null);
|
||||||
const formatPrice = (value) =>
|
const formatPrice = (value) =>
|
||||||
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
|
new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }).format(value);
|
||||||
|
|
||||||
|
|
@ -282,7 +284,7 @@ export default function ModelPage() {
|
||||||
// die Nachbarn drehen raeumlich weg. rAF-gedrosselt; der ResizeObserver deckt auch
|
// die Nachbarn drehen raeumlich weg. rAF-gedrosselt; der ResizeObserver deckt auch
|
||||||
// Hoehenaenderungen ab (z. B. wenn eine Karte ihre Stichpunkte aufklappt).
|
// Hoehenaenderungen ab (z. B. wenn eine Karte ihre Stichpunkte aufklappt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = coverflowRef.current;
|
const el = scrollerRef.current;
|
||||||
if (layout !== 'coverflow' || !el) return undefined;
|
if (layout !== 'coverflow' || !el) return undefined;
|
||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
|
|
@ -310,6 +312,160 @@ export default function ModelPage() {
|
||||||
};
|
};
|
||||||
}, [layout, data]);
|
}, [layout, data]);
|
||||||
|
|
||||||
|
// Coverflow-Kacheln mit der Maus greifen und ziehen. Nur fuer echte Maus-Pointer -
|
||||||
|
// Touch behaelt das native Wisch-Scrolling mit Momentum. Waehrend des Ziehens wird
|
||||||
|
// das Scroll-Snapping ausgesetzt (es wuerde gegen scrollLeft ankaempfen); beim
|
||||||
|
// Loslassen faehrt die Reihe weich zur naechstgelegenen Karte. Wer gezogen hat,
|
||||||
|
// soll beim Loslassen ueber einem Button nichts ausloesen - der Capture-Click-
|
||||||
|
// Handler schluckt genau diesen einen Klick.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (layout !== 'coverflow' || !el) return undefined;
|
||||||
|
let dragging = false;
|
||||||
|
let moved = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let startScroll = 0;
|
||||||
|
|
||||||
|
const snapToNearest = () => {
|
||||||
|
const mid = el.scrollLeft + el.clientWidth / 2;
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const card of el.children) {
|
||||||
|
const center = card.offsetLeft + card.offsetWidth / 2;
|
||||||
|
const dist = Math.abs(center - mid);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best !== null) el.scrollTo({ left: best - el.clientWidth / 2, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDown = (e) => {
|
||||||
|
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
||||||
|
dragging = true;
|
||||||
|
moved = 0;
|
||||||
|
startX = e.clientX;
|
||||||
|
startScroll = el.scrollLeft;
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
el.style.scrollSnapType = 'none';
|
||||||
|
el.dataset.dragging = 'true';
|
||||||
|
};
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
moved = Math.max(moved, Math.abs(dx));
|
||||||
|
el.scrollLeft = startScroll - dx;
|
||||||
|
};
|
||||||
|
const onUp = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
el.dataset.dragging = 'false';
|
||||||
|
if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||||||
|
snapToNearest();
|
||||||
|
// Snap erst nach der weichen Fahrt wieder aktivieren, sonst springt der Browser hart.
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.scrollSnapType = '';
|
||||||
|
}, 450);
|
||||||
|
};
|
||||||
|
const onClickCapture = (e) => {
|
||||||
|
if (moved > 6) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
moved = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('pointerdown', onDown);
|
||||||
|
el.addEventListener('pointermove', onMove);
|
||||||
|
el.addEventListener('pointerup', onUp);
|
||||||
|
el.addEventListener('pointercancel', onUp);
|
||||||
|
el.addEventListener('click', onClickCapture, true);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('pointerdown', onDown);
|
||||||
|
el.removeEventListener('pointermove', onMove);
|
||||||
|
el.removeEventListener('pointerup', onUp);
|
||||||
|
el.removeEventListener('pointercancel', onUp);
|
||||||
|
el.removeEventListener('click', onClickCapture, true);
|
||||||
|
};
|
||||||
|
}, [layout, data]);
|
||||||
|
|
||||||
|
// Eigene, zum Design passende Scroll-Leiste unter der Kartenreihe (Strip + Coverflow):
|
||||||
|
// die native Browser-Leiste ist ausgeblendet, stattdessen ein schmaler zentrierter
|
||||||
|
// Track mit markengruenem, leuchtendem Daumen. Daumen ist ziehbar, Klick auf den
|
||||||
|
// Track springt an die Stelle. Bei zu wenig Inhalt (kein Overflow) blendet sie aus.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
const track = trackRef.current;
|
||||||
|
const thumb = thumbRef.current;
|
||||||
|
if (layout === 'grid' || !el || !track || !thumb) return undefined;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
rafId = 0;
|
||||||
|
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||||
|
if (maxScroll <= 1) {
|
||||||
|
track.style.opacity = '0';
|
||||||
|
track.style.pointerEvents = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
track.style.opacity = '1';
|
||||||
|
track.style.pointerEvents = 'auto';
|
||||||
|
const trackW = track.clientWidth;
|
||||||
|
const thumbW = Math.max(32, (el.clientWidth / el.scrollWidth) * trackW);
|
||||||
|
thumb.style.width = `${thumbW}px`;
|
||||||
|
thumb.style.transform = `translateX(${((el.scrollLeft / maxScroll) * (trackW - thumbW)).toFixed(1)}px)`;
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (!rafId) rafId = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
el.addEventListener('scroll', schedule, { passive: true });
|
||||||
|
const ro = new ResizeObserver(schedule);
|
||||||
|
ro.observe(el);
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let grabOffset = 0;
|
||||||
|
const scrollToPointer = (clientX) => {
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const thumbW = thumb.clientWidth;
|
||||||
|
const x = Math.min(Math.max(clientX - rect.left - grabOffset, 0), rect.width - thumbW);
|
||||||
|
el.scrollLeft = (x / (rect.width - thumbW)) * (el.scrollWidth - el.clientWidth);
|
||||||
|
};
|
||||||
|
const onDown = (e) => {
|
||||||
|
dragging = true;
|
||||||
|
track.setPointerCapture(e.pointerId);
|
||||||
|
el.style.scrollSnapType = 'none';
|
||||||
|
const tr = thumb.getBoundingClientRect();
|
||||||
|
// Auf dem Daumen: relative Griffposition merken; daneben: Daumen unter den Zeiger springen.
|
||||||
|
grabOffset = e.clientX >= tr.left && e.clientX <= tr.right ? e.clientX - tr.left : thumb.clientWidth / 2;
|
||||||
|
if (!(e.clientX >= tr.left && e.clientX <= tr.right)) scrollToPointer(e.clientX);
|
||||||
|
};
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (dragging) scrollToPointer(e.clientX);
|
||||||
|
};
|
||||||
|
const onUp = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
if (track.hasPointerCapture?.(e.pointerId)) track.releasePointerCapture(e.pointerId);
|
||||||
|
el.style.scrollSnapType = '';
|
||||||
|
};
|
||||||
|
track.addEventListener('pointerdown', onDown);
|
||||||
|
track.addEventListener('pointermove', onMove);
|
||||||
|
track.addEventListener('pointerup', onUp);
|
||||||
|
track.addEventListener('pointercancel', onUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', schedule);
|
||||||
|
ro.disconnect();
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
track.removeEventListener('pointerdown', onDown);
|
||||||
|
track.removeEventListener('pointermove', onMove);
|
||||||
|
track.removeEventListener('pointerup', onUp);
|
||||||
|
track.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
}, [layout, data]);
|
||||||
|
|
||||||
usePageMeta({
|
usePageMeta({
|
||||||
title: data ? t('modelPage.metaTitle')(data.model.brand_name, data.model.name) : t('modelPage.metaTitleFallback'),
|
title: data ? t('modelPage.metaTitle')(data.model.brand_name, data.model.name) : t('modelPage.metaTitleFallback'),
|
||||||
description: data ? t('modelPage.metaDescription')(data.model.brand_name, data.model.name) : undefined,
|
description: data ? t('modelPage.metaDescription')(data.model.brand_name, data.model.name) : undefined,
|
||||||
|
|
@ -392,13 +548,15 @@ export default function ModelPage() {
|
||||||
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
|
<section className="py-10 sm:py-14" style={{ fontFamily: "'Barlow', sans-serif" }}>
|
||||||
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
<div className={layout === 'grid' ? 'mx-auto max-w-6xl px-4 sm:px-6' : 'mx-auto max-w-[1880px] px-4 sm:px-6'}>
|
||||||
<div
|
<div
|
||||||
ref={layout === 'coverflow' ? coverflowRef : undefined}
|
ref={layout === 'grid' ? undefined : scrollerRef}
|
||||||
className={
|
className={
|
||||||
layout === 'grid'
|
layout === 'grid'
|
||||||
? 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
|
? 'grid items-start gap-[18px] sm:grid-cols-2 xl:grid-cols-3'
|
||||||
: // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
|
: // items-start statt items-stretch: klappt jemand eine Kachel auf, waechst
|
||||||
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
|
// nur DIESE in die Hoehe - die Nachbarn behalten ihre Referenzhoehe.
|
||||||
'flex items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:thin] [scrollbar-color:rgba(0,0,0,0.3)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.25)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/30 dark:[&::-webkit-scrollbar-thumb]:bg-white/25'
|
// Die native Scroll-Leiste ist ausgeblendet - darunter sitzt die eigene
|
||||||
|
// Track/Daumen-Leiste. select-none, damit Drag keinen Text markiert.
|
||||||
|
'flex select-none items-start gap-[18px] overflow-x-auto pb-3 snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||||
}
|
}
|
||||||
style={
|
style={
|
||||||
layout === 'coverflow'
|
layout === 'coverflow'
|
||||||
|
|
@ -426,6 +584,24 @@ export default function ModelPage() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Eigene Scroll-Leiste: zentrierter Track, markengruener leuchtender Daumen.
|
||||||
|
Ziehbar + klickbar (Logik im Scrollbar-Effekt); blendet aus, wenn alle
|
||||||
|
Karten ohne Scrollen passen. touch-action none fuer sauberes Pointer-Ziehen. */}
|
||||||
|
{layout !== 'grid' && (
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="relative mx-auto mt-5 h-2 w-56 max-w-full cursor-pointer rounded-full bg-neutral-900/10 transition-opacity duration-200 dark:bg-white/10"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={thumbRef}
|
||||||
|
className="absolute left-0 top-0 h-full rounded-full bg-brand-500 shadow-[0_0_10px_rgba(107,166,38,0.7)]"
|
||||||
|
style={{ width: '48px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
196
hifi-src/src/referenz/Kacheln.html
Normal file
196
hifi-src/src/referenz/Kacheln.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue