feat(public): rotating 5-slide hero backdrop with real photos on public tracks
Add PublicHeroSlides cross-fade component (5 real JPGs) wired into gateway/visitor/business/agency hero backdrops. Rotation handled by usePublicMotion ([data-hero-slides], reduced-motion aware); .pub-hero__slides layering + is-active transition in public.css. Graceful gradient fallback on image load error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c595560d82
commit
7316cd99c5
BIN
src/frontend/public/media/hero/hero-1.jpg
Normal file
BIN
src/frontend/public/media/hero/hero-1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
BIN
src/frontend/public/media/hero/hero-2.jpg
Normal file
BIN
src/frontend/public/media/hero/hero-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
src/frontend/public/media/hero/hero-3.jpg
Normal file
BIN
src/frontend/public/media/hero/hero-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
BIN
src/frontend/public/media/hero/hero-4.jpg
Normal file
BIN
src/frontend/public/media/hero/hero-4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
BIN
src/frontend/public/media/hero/hero-5.jpg
Normal file
BIN
src/frontend/public/media/hero/hero-5.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@ -7,6 +7,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicShell } from './PublicShell';
|
||||
import { PublicHeroSlides } from './PublicHeroSlides';
|
||||
import {
|
||||
IconFactory,
|
||||
IconShield,
|
||||
@ -33,7 +34,9 @@ export function AgencyTrackPage() {
|
||||
className="kxp-hero__bg"
|
||||
style={{ background: 'linear-gradient(135deg, #1a1330 0%, #3a2a6b 55%, #0066b3 130%)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
>
|
||||
<PublicHeroSlides />
|
||||
</div>
|
||||
<div className="kxp-hero__scrim" aria-hidden />
|
||||
<div className="kxp-hero__inner">
|
||||
<div className="kxp-hero__content">
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicShell } from './PublicShell';
|
||||
import { PublicHeroSlides } from './PublicHeroSlides';
|
||||
import {
|
||||
IconVenue,
|
||||
IconBuyer,
|
||||
@ -45,7 +46,9 @@ export function BusinessTrackPage() {
|
||||
className="kxp-hero__bg"
|
||||
style={{ background: 'linear-gradient(135deg, #12233b 0%, #23346b 60%, #0066b3 130%)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
>
|
||||
<PublicHeroSlides />
|
||||
</div>
|
||||
<div className="kxp-hero__scrim" aria-hidden />
|
||||
<div className="kxp-hero__inner">
|
||||
<div className="kxp-hero__content">
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicShell } from './PublicShell';
|
||||
import { PublicHeroSlides } from './PublicHeroSlides';
|
||||
import { usePublicEvents, EventGrid } from './trackShared';
|
||||
import {
|
||||
IconSparkles,
|
||||
@ -32,13 +33,15 @@ export function PublicGatewayPage() {
|
||||
|
||||
return (
|
||||
<PublicShell active="events" cta={t('track.gateway.ctaPreReg')}>
|
||||
{/* 제품 히어로 — 배경 실사진은 후속(별도). 현재 V-4 그라디언트+패턴 폴백(CLS 0). */}
|
||||
{/* 제품 히어로 — 배경 실사진 5장 크로스페이드 회전(그라디언트+패턴은 로드 실패 폴백). */}
|
||||
<section className="kxp-hero pub-hero" aria-label={t('track.gateway.heroAria')}>
|
||||
<div
|
||||
className="kxp-hero__bg pub-fallback-gradient pub-pattern-grid"
|
||||
data-parallax="0.18"
|
||||
aria-hidden
|
||||
/>
|
||||
>
|
||||
<PublicHeroSlides />
|
||||
</div>
|
||||
<div className="kxp-hero__scrim" aria-hidden />
|
||||
<span className="kxp-aiwm">{t('track.gateway.aiWatermark')}</span>
|
||||
<div className="kxp-hero__inner">
|
||||
|
||||
39
src/frontend/src/screens/public/PublicHeroSlides.tsx
Normal file
39
src/frontend/src/screens/public/PublicHeroSlides.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 공개 히어로 배경 회전 슬라이드 (§3C-V 확장) — 실사진 5장 크로스페이드.
|
||||
* 각 히어로 배경 레이어(.kxp-hero__bg / .kxp-vhero__bg) 내부에 절대배치 자식으로 삽입한다.
|
||||
* 회전 로직은 usePublicMotion.ts([data-hero-slides])가 담당(간격·정지조건·prefers-reduced-motion).
|
||||
* 장식 배경이므로 aria-hidden + alt="" (헤드라인/스크림이 의미 전달). 로드 실패 시 그라디언트 폴백 노출.
|
||||
* 이미지 alt(감사용): _workspace/hero_images.md — hero-1 곡면유리 파사드 … hero-5 제2전시장 돔.
|
||||
*/
|
||||
const HERO_COUNT = 5;
|
||||
|
||||
export function PublicHeroSlides() {
|
||||
return (
|
||||
<div className="pub-hero__slides" data-hero-slides aria-hidden>
|
||||
{Array.from({ length: HERO_COUNT }, (_, i) => {
|
||||
const n = i + 1;
|
||||
const first = i === 0;
|
||||
return (
|
||||
<img
|
||||
key={n}
|
||||
className={`pub-hero__slide${first ? ' is-active' : ''}`}
|
||||
src={`/media/hero/hero-${n}.jpg`}
|
||||
alt=""
|
||||
data-slide={i}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={first ? 'eager' : 'lazy'}
|
||||
fetchPriority={first ? 'high' : 'low'}
|
||||
onError={(e) => {
|
||||
// 로드 실패: 회전 대상에서 제외하고 그라디언트 폴백이 비치도록 숨김(비파괴).
|
||||
const img = e.currentTarget;
|
||||
img.dataset.failed = '1';
|
||||
img.classList.remove('is-active');
|
||||
img.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicShell } from './PublicShell';
|
||||
import { PublicHeroSlides } from './PublicHeroSlides';
|
||||
import { usePublicEvents, EventGrid } from './trackShared';
|
||||
import { AiAssistant } from './AiAssistant';
|
||||
import { AiPlanningBriefing } from './AiPlanningBriefing';
|
||||
@ -149,9 +150,11 @@ export function VisitorTrackPage() {
|
||||
return (
|
||||
<PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}>
|
||||
{/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */}
|
||||
{/* 히어로 배경 실사진은 후속(별도) — 현재 V-4 그라디언트+패턴 폴백 렌더(CLS 0). */}
|
||||
{/* 히어로 배경 실사진 5장 크로스페이드 회전(그라디언트+도트 패턴은 로드 실패 폴백). */}
|
||||
<section className="kxp-vhero pub-hero" id="hero" aria-label={t('track.visitor.heroAria')}>
|
||||
<div className="kxp-vhero__bg pub-pattern-dots" data-parallax="0.18" aria-hidden />
|
||||
<div className="kxp-vhero__bg pub-pattern-dots" data-parallax="0.18" aria-hidden>
|
||||
<PublicHeroSlides />
|
||||
</div>
|
||||
<div className="kxp-vhero__scrim" aria-hidden />
|
||||
<span className="kxp-vhero__wm" aria-hidden>
|
||||
{t('track.visitor.aiWatermark')}
|
||||
|
||||
@ -4113,6 +4113,31 @@ a.kxp-ai__cite:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 히어로 배경 회전 슬라이드(크로스페이드) — .kxp-hero__bg/.kxp-vhero__bg 내부 절대배치.
|
||||
폴백(그라디언트+패턴)은 부모 bg 레이어가 유지 → 이미지 위에 겹친다(로드 실패 시 비침). */
|
||||
.pub-hero__slides {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
.pub-hero__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
opacity: 0;
|
||||
transition: opacity 1200ms var(--ease-standard);
|
||||
will-change: opacity;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.pub-hero__slide.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── ④ 포스터 카드 리프트 + 이미지 줌 ──────────────────────────────────────── */
|
||||
.pub-card--lift {
|
||||
position: relative;
|
||||
@ -4222,6 +4247,11 @@ a.kxp-ai__cite:hover {
|
||||
bottom: 0;
|
||||
will-change: auto;
|
||||
}
|
||||
/* 회전 정지(첫 장 고정) + 크로스페이드 전환 무효화 */
|
||||
.kxp .pub-hero__slide {
|
||||
transition: none !important;
|
||||
will-change: auto;
|
||||
}
|
||||
.kxp .pub-card--lift,
|
||||
.kxp .pub-card__media img {
|
||||
transition: none;
|
||||
|
||||
@ -191,6 +191,80 @@ export function usePublicMotion(rootRef: RefObject<HTMLElement | null>): void {
|
||||
cleanups.push(() => cancelAnimationFrame(rafId));
|
||||
}
|
||||
|
||||
// ── ⑥ 히어로 배경 회전 슬라이드(크로스페이드) ──────────────────────────────────
|
||||
// prefers-reduced-motion 시 첫 장 고정(회전 미배선). 정지: hover/focus·문서 숨김·오프스크린.
|
||||
if (!reduce) {
|
||||
root.querySelectorAll<HTMLElement>('[data-hero-slides]').forEach((box) => {
|
||||
const slides = Array.from(box.querySelectorAll<HTMLElement>('img[data-slide]'));
|
||||
if (slides.length < 2) return; // 폴백/단일 이미지는 회전 없음
|
||||
|
||||
const interval = 5500; // ~5.5s 노출 후 다음 장
|
||||
let idx = slides.findIndex((s) => s.classList.contains('is-active'));
|
||||
if (idx < 0) {
|
||||
idx = 0;
|
||||
slides[0].classList.add('is-active');
|
||||
}
|
||||
|
||||
let hovered = false;
|
||||
let offscreen = false;
|
||||
const isPaused = () => hovered || offscreen || document.hidden;
|
||||
|
||||
const nextVisible = (from: number): number => {
|
||||
for (let step = 1; step <= slides.length; step++) {
|
||||
const j = (from + step) % slides.length;
|
||||
if (slides[j].dataset.failed !== '1') return j;
|
||||
}
|
||||
return from;
|
||||
};
|
||||
const tick = () => {
|
||||
if (isPaused()) return;
|
||||
const nx = nextVisible(idx);
|
||||
if (nx === idx) return; // 표시 가능한 다음 장 없음
|
||||
slides[idx].classList.remove('is-active');
|
||||
slides[nx].classList.add('is-active');
|
||||
idx = nx;
|
||||
};
|
||||
const timer = window.setInterval(tick, interval);
|
||||
|
||||
const hero =
|
||||
(box.closest('.pub-hero, .kxp-hero, .kxp-vhero') as HTMLElement | null) ??
|
||||
(box.closest('section') as HTMLElement | null) ??
|
||||
box;
|
||||
const onEnter = () => {
|
||||
hovered = true;
|
||||
};
|
||||
const onLeave = () => {
|
||||
hovered = false;
|
||||
};
|
||||
hero.addEventListener('mouseenter', onEnter);
|
||||
hero.addEventListener('mouseleave', onLeave);
|
||||
hero.addEventListener('focusin', onEnter);
|
||||
hero.addEventListener('focusout', onLeave);
|
||||
|
||||
let io: IntersectionObserver | null = null;
|
||||
if (supportsIO) {
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((e) => {
|
||||
offscreen = !e.isIntersecting;
|
||||
});
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
io.observe(box);
|
||||
}
|
||||
|
||||
cleanups.push(() => {
|
||||
window.clearInterval(timer);
|
||||
hero.removeEventListener('mouseenter', onEnter);
|
||||
hero.removeEventListener('mouseleave', onLeave);
|
||||
hero.removeEventListener('focusin', onEnter);
|
||||
hero.removeEventListener('focusout', onLeave);
|
||||
io?.disconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, [rootRef]);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user