Replace project with current server release and Windows setup

Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+49
View File
@@ -0,0 +1,49 @@
import {driver,type Driver,type DriveStep} from 'driver.js';
const GUIDE_KEY='behind-glass.first-guide.v1',RECENTER_KEY='behind-glass.recenter-guide.v1';
/** A nonmodal marker preserves the app's existing blocking/cancellation contract.
* Driver owns the visible spotlight; underlying app stays inert, never auto-clicked. */
export function initFirstGuide(root:Document,signal:AbortSignal,mobile:boolean,eligible:()=>boolean,onChange:()=>void,createTour:typeof driver=driver){
const get=<T extends HTMLElement>(id:string)=>root.querySelector<T>(`#${id}`)!;
const marker=get<HTMLDialogElement>('first-guide'),app=get('experience'),view=root.defaultView;
let done=false,recenterDone=false,tour:Driver|null=null,active=false,ending=false,priorInert=false,kind:'main'|'recenter'='main';
try{done=view?.localStorage.getItem(GUIDE_KEY)==='1';recenterDone=view?.localStorage.getItem(RECENTER_KEY)==='1';}catch{/* session-only state */}
const visible=(id:string):HTMLElement|undefined=>{const node=root.querySelector<HTMLElement>(`#${id}`);if(!node||node.closest('[hidden]'))return;const r=node.getBoundingClientRect(),style=view?.getComputedStyle?.(node);return r.width>0&&r.height>0&&style?.visibility!=='hidden'&&style?.display!=='none'?node:undefined;};
const cleanup=()=>{if(!active)return;active=false;tour=null;app.inert=priorInert;if(marker.open)marker.close();onChange();};
const stop=(completed=false)=>{
if(!active&&!tour)return;
ending=true;
if(completed){if(kind==='main')done=true;else recenterDone=true;try{view?.localStorage.setItem(kind==='main'?GUIDE_KEY:RECENTER_KEY,'1');}catch{/* keep session state */}}
const current=tour;tour=null;
try{current?.destroy();}finally{cleanup();ending=false;onChange();}
};
const start=(steps:DriveStep[],type:'main'|'recenter')=>{
if(signal.aborted||active||ending||!eligible())return;
kind=type;priorInert=app.inert;active=true;marker.show();app.inert=true;onChange();
try{
tour=createTour({steps,animate:!view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches,overlayColor:'#201a13',overlayOpacity:.62,stagePadding:9,stageRadius:9,popoverOffset:12,
allowScroll:false,smoothScroll:false,disableActiveInteraction:true,skipMissingElement:true,overlayClickBehavior:'none',allowClose:true,showProgress:type==='main',progressText:'{{current}} / {{total}}',
nextBtnText:'다음',prevBtnText:'이전',doneBtnText:type==='main'?'시작하기':'알겠어요',closeBtnLabel:'안내 건너뛰기',popoverClass:'behind-glass-tour',
onNextClick:()=>{if(tour?.isLastStep())stop(true);else tour?.moveNext();},onDoneClick:()=>stop(true),onCloseClick:()=>stop(true),onDestroyStarted:()=>stop(true),onDestroyed:cleanup,
onPopoverRender:popover=>{popover.closeButton.textContent='건너뛰기';const content=root.createElement('div');content.className='guide-popover-content';content.append(popover.closeButton,popover.title,popover.description,popover.footer);popover.wrapper.append(content);},
});
tour.drive();
}catch{stop(false);}
};
marker.addEventListener('close',()=>{if(!marker.open)stop(false);},{signal});
marker.addEventListener('cancel',event=>{event.preventDefault();stop(true);},{signal});
const refresh=()=>{if(!active)return;const target=tour?.getActiveElement?.();if(target&&target.id&&!visible(target.id)){stop(false);return;}tour?.refresh();};
view?.visualViewport?.addEventListener('resize',refresh,{signal});view?.visualViewport?.addEventListener('scroll',refresh,{signal});
signal.addEventListener('abort',()=>stop(false),{once:true});
return {
start(force=false){
if((done&&!force)||signal.aborted)return;
const steps:DriveStep[]=[
{element:()=>visible('guide-scene-target')!,popover:{title:'유리 너머를 둘러보세요',description:mobile?'손가락으로 드래그해서 시점을 바꾸고, 두 손가락으로 확대해 보세요.':'드래그해서 시점을 바꾸고, 마우스 휠로 확대해 보세요.',side:'bottom',align:'center'}},
{element:()=>visible('door-toggle')!,popover:{title:'문도 열어볼까요?',description:'이 버튼으로 문을 열고 닫을 수 있어요. 문 손잡이를 좌우로 밀어도 돼요.',side:'top',align:'start'}},
...(mobile?[{element:()=>visible('gyro')!,popover:{title:'휴대폰을 기울여 보세요',description:'안내를 마친 뒤 이 버튼을 눌러 연결해 주세요. 기울기를 쓰지 않아도 드래그로 둘러볼 수 있어요.',side:'bottom' as const,align:'end' as const}}]:[]),
];
start(steps,'main');
},
recenter(){if(!mobile||!done||recenterDone||!visible('gyro-recenter'))return;start([{element:()=>visible('gyro-recenter')!,popover:{title:'편한 자세로 중앙 맞추기',description:'휴대폰을 편하게 든 뒤 이 버튼을 누르면 지금 자세가 새로운 기준이 돼요. 확대 정도는 그대로 유지돼요.',side:'top',align:'end'}}],'recenter');},
};
}