feat(landing): 소개 영상 버튼과 재생 모달 추가
- 사용 가이드 옆 '▶ 소개 영상 1분' 버튼, 네이티브 dialog에서 재생 - 자체 호스팅 mp4(56초·1920×1080·H.264, 무음), 열 때만 로드(preload none), 무음 자동재생 - Esc·배경·닫기로 종료 시 일시정지·처음으로·포커스 복귀, 모션 감소 시 자동재생 안 함 - 자막 원본(.srt)은 보관만, 화면 미연결 - verify-intro-video.cjs: Chrome 17개 검사 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc520ec03d
commit
ef596650cc
6 files changed
+285
-1
No files matched your search
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
1
|
||||
00:00:00,200 --> 00:00:01,700
|
||||
더 빠른 설계를 넘어,
|
||||
|
||||
2
|
||||
00:00:01,700 --> 00:00:03,200
|
||||
더 나은 설계 경험으로,
|
||||
|
||||
3
|
||||
00:00:03,200 --> 00:00:05,000
|
||||
그리고 새로운 가치로.
|
||||
|
||||
4
|
||||
00:00:04,650 --> 00:00:09,150
|
||||
Pierzainer는 교량 설계 플랫폼의 하부구조 설계 모듈입니다
|
||||
|
||||
5
|
||||
00:00:08,800 --> 00:00:13,800
|
||||
하부구조 설계 흐름
|
||||
|
||||
6
|
||||
00:00:13,450 --> 00:00:17,850
|
||||
기설계 교량 라이브러리에서 시작
|
||||
|
||||
7
|
||||
00:00:17,500 --> 00:00:20,500
|
||||
시트 1장 = 교각 1기
|
||||
|
||||
8
|
||||
00:00:20,500 --> 00:00:23,700
|
||||
지형 위에 3D 모델 자동 생성
|
||||
|
||||
9
|
||||
00:00:23,350 --> 00:00:28,150
|
||||
형상은 고르고, 값만 수정
|
||||
|
||||
10
|
||||
00:00:27,800 --> 00:00:31,500
|
||||
철근표가 자동으로 계산됩니다
|
||||
|
||||
11
|
||||
00:00:31,500 --> 00:00:33,800
|
||||
고른 철근만 모델에 표시
|
||||
|
||||
12
|
||||
00:00:33,800 --> 00:00:36,800
|
||||
근거와 함께 알려주는 알림
|
||||
|
||||
13
|
||||
00:00:36,450 --> 00:00:40,050
|
||||
일반도·단면도 자동 작성
|
||||
|
||||
14
|
||||
00:00:40,050 --> 00:00:42,650
|
||||
DWG·DXF로 바로 내보내기
|
||||
|
||||
15
|
||||
00:00:42,300 --> 00:00:47,500
|
||||
교각 전체 재료표·수량 집계
|
||||
|
||||
16
|
||||
00:00:47,150 --> 00:00:53,750
|
||||
설계 자동화에서, 새로운 가치를 만드는 플랫폼으로
|
||||
|
||||
17
|
||||
00:00:53,400 --> 00:00:56,000
|
||||
Pierzainer
|
||||
@@ -2,6 +2,7 @@ import '../pierzainer_ground/ground-link';
|
||||
import { loadSampleIntoApp } from './sample-loader';
|
||||
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
|
||||
import { createLandingGuide } from './landing-guide';
|
||||
import { createIntroVideo, introVideoTriggerMarkup } from './landing-video';
|
||||
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
|
||||
import { heroCapture } from './assets/okseong-hero-capture';
|
||||
import './landing-white.css';
|
||||
@@ -44,7 +45,7 @@ const landingPanelMarkup = `
|
||||
<p class="landing-subtitle">
|
||||
설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
|
||||
</p>
|
||||
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>
|
||||
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>${introVideoTriggerMarkup}
|
||||
</div>
|
||||
</div>
|
||||
<div class="landing-hero-visual" aria-hidden="true">
|
||||
@@ -123,6 +124,7 @@ const landingPanelMarkup = `
|
||||
landingOverlay.innerHTML = landingPanelMarkup;
|
||||
document.body.prepend(landingOverlay);
|
||||
const landingGuide = createLandingGuide(landingOverlay);
|
||||
const introVideo = createIntroVideo(landingOverlay, promoImageUrl);
|
||||
createHeroMotion(landingOverlay);
|
||||
|
||||
const selectPanel = (panelId: string): void => {
|
||||
@@ -143,6 +145,7 @@ const closePanels = (): void => {
|
||||
const showApp = (): void => {
|
||||
landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')?.close();
|
||||
landingGuide.close(false);
|
||||
introVideo.close(false);
|
||||
if (appRoot) {
|
||||
appRoot.hidden = false;
|
||||
appRoot.setAttribute('aria-hidden', 'false');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Intro video trigger + modal. Shares the guide trigger/dialog visual language (landing-white). */
|
||||
.landing-video-trigger {
|
||||
display: inline-flex; align-items: center; gap: 9px; min-height: 36px; margin: 13px 0 0 8px; padding: 7px 12px;
|
||||
border: 1px solid #d6e3ed; border-radius: 999px; background: #ffffffeb; color: #203b54;
|
||||
font-size: 11px !important; box-shadow: 0 3px 10px #27496207; cursor: pointer;
|
||||
transition: box-shadow .25s, background .25s, border-color .25s; animation: landing-enter .7s 1.45s both;
|
||||
}
|
||||
.landing-video-trigger > span:first-child {
|
||||
display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #087fcb; color: #fff; font-size: 7px; padding-left: 1px;
|
||||
}
|
||||
.landing-video-trigger-time { color: #64748b; font-variant-numeric: tabular-nums; }
|
||||
.landing-video-trigger:hover { background: #eff6ff; border-color: #9dc6e2; box-shadow: 0 4px 16px #267daa10; }
|
||||
.landing-video-trigger:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||
|
||||
.landing-video {
|
||||
width: min(1120px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 18px 20px 16px;
|
||||
border: 1px solid #d1e2ee; border-radius: 18px; color-scheme: light; color: #14263d;
|
||||
background: linear-gradient(145deg, #fff, #f8fbfe); box-shadow: 0 24px 90px #2247652b, inset 0 1px #fff;
|
||||
overflow: auto;
|
||||
}
|
||||
.landing-video::backdrop { background: #253f5966; backdrop-filter: blur(5px); }
|
||||
.landing-video-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.landing-video-kicker { display: block; color: #36779d; font-size: 11px; letter-spacing: .14em; font-weight: 600; }
|
||||
.landing-video-head h2 { margin: 4px 0 0; font-size: 18px; color: #14263d; }
|
||||
.landing-video-close {
|
||||
flex: none; width: 44px; height: 44px; border: 1px solid #d5e5ef; border-radius: 10px;
|
||||
background: #f5f9fc; color: #456478; font-size: 22px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.landing-video-close:hover { background: #eaf3f9; }
|
||||
.landing-video-close:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||
.landing-video-frame {
|
||||
position: relative; aspect-ratio: 16 / 9; max-height: calc(100dvh - 190px); margin: 0 auto;
|
||||
border-radius: 12px; overflow: hidden; background: #f4f6f8; border: 1px solid #e2ebf2;
|
||||
}
|
||||
.landing-video-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #f4f6f8; }
|
||||
.landing-video-desc { margin: 10px 2px 0; color: #64748b; font-size: 12.5px; line-height: 1.5; }
|
||||
|
||||
/* Different icon glyphs give the two inline-flex pills different baselines; align their boxes instead. */
|
||||
.landing-brand-meta .landing-guide-trigger, .landing-video-trigger { vertical-align: middle; }
|
||||
/* Mirror the guide trigger's responsive margins (landing-page.css). */
|
||||
@media (max-width: 1100px) and (min-width: 761px) { .landing-video-trigger { margin-top: 8px; } }
|
||||
@media (max-height: 560px) { .landing-brand-meta .landing-video-trigger { margin-top: 6px; } }
|
||||
@media (max-width: 760px) {
|
||||
.landing-video-trigger { min-height: 30px; font-size: 9px !important; margin: 8px 0 0 6px; padding: 5px 9px; }
|
||||
.landing-video { padding: 12px; border-radius: 14px; }
|
||||
.landing-video-head h2 { font-size: 15px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-video-trigger { animation: none; transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import './landing-video.css';
|
||||
|
||||
/** Self-hosted intro video (56 s, 1920×1080 H.264, no audio track). Fetched only when opened. */
|
||||
export const introVideoUrl = new URL('./assets/pierzainer-intro.mp4', import.meta.url).href;
|
||||
|
||||
export const introVideoTriggerMarkup = `<button class="landing-video-trigger" type="button" aria-haspopup="dialog" aria-controls="landing-video"><span aria-hidden="true">▶</span>소개 영상 <span class="landing-video-trigger-time">1분</span></button>`;
|
||||
|
||||
/** Native dialog: Esc/backdrop close, pause + rewind on close, focus returns to the opener. */
|
||||
export function createIntroVideo(root: HTMLElement, poster: string): { open: () => void; close: (restoreFocus?: boolean) => void } {
|
||||
const trigger = root.querySelector<HTMLButtonElement>('.landing-video-trigger')!;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'landing-video';
|
||||
dialog.id = 'landing-video';
|
||||
dialog.setAttribute('aria-labelledby', 'landing-video-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="landing-video-head">
|
||||
<div><span class="landing-video-kicker">PRODUCT TOUR / PIER & ABUT</span><h2 id="landing-video-title">Pierzainer 소개 영상</h2></div>
|
||||
<button type="button" class="landing-video-close" aria-label="소개 영상 닫기">×</button>
|
||||
</div>
|
||||
<div class="landing-video-frame">
|
||||
<video controls playsinline muted preload="none" poster="${poster}" aria-describedby="landing-video-desc"></video>
|
||||
</div>
|
||||
<p id="landing-video-desc" class="landing-video-desc">설계정보 입력부터 3D 모델링, 철근배근, 도면작성, 수량산출까지의 흐름을 1분 동안 소개합니다. (음성 없음)</p>`;
|
||||
root.append(dialog);
|
||||
const video = dialog.querySelector('video')!;
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
|
||||
const open = (): void => {
|
||||
if (dialog.open) return;
|
||||
if (!video.currentSrc && !video.querySelector('source')) {
|
||||
const source = document.createElement('source');
|
||||
source.src = introVideoUrl;
|
||||
source.type = 'video/mp4';
|
||||
video.append(source);
|
||||
video.load();
|
||||
}
|
||||
dialog.showModal();
|
||||
dialog.querySelector<HTMLButtonElement>('.landing-video-close')!.focus();
|
||||
if (!reducedMotion?.matches) void video.play().catch(() => undefined);
|
||||
};
|
||||
const stop = (): void => {
|
||||
video.pause();
|
||||
if (video.readyState > 0) video.currentTime = 0;
|
||||
};
|
||||
const close = (restoreFocus = true): void => {
|
||||
if (!dialog.open) return;
|
||||
dialog.close();
|
||||
if (restoreFocus && trigger.isConnected && !root.hidden) trigger.focus();
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', open);
|
||||
dialog.querySelector('.landing-video-close')!.addEventListener('click', () => close());
|
||||
dialog.addEventListener('close', stop);
|
||||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(); });
|
||||
// Clicking the backdrop (outside the panel box) closes the dialog.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom;
|
||||
if (!inside) close();
|
||||
});
|
||||
return { open, close };
|
||||
}
|
||||
Reference in new issue
Block a user