parent
08a69db4ab
commit
260f439b23
19 changed files with 1044 additions and 64 deletions
@ -0,0 +1,46 @@ |
|||||||
|
'use server'; |
||||||
|
|
||||||
|
import { uploadBuffer } from '@/lib/ftps'; |
||||||
|
|
||||||
|
const MIME_EXT: Record<string, string> = { |
||||||
|
'image/png': '.png', |
||||||
|
'image/jpeg': '.jpg', |
||||||
|
'image/webp': '.webp', |
||||||
|
}; |
||||||
|
|
||||||
|
export interface UploadResult { |
||||||
|
ok: boolean; |
||||||
|
url?: string; |
||||||
|
error?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Receives a captured result image and uploads it to the customer FTPS server. |
||||||
|
* Invoked from the client (ResultStep) via an event handler, not a form. |
||||||
|
* |
||||||
|
* Runs on the Node.js runtime (standalone server) — required by `basic-ftp`. |
||||||
|
*/ |
||||||
|
export async function uploadResultImage( |
||||||
|
formData: FormData, |
||||||
|
): Promise<UploadResult> { |
||||||
|
try { |
||||||
|
const image = formData.get('image'); |
||||||
|
if (!(image instanceof File)) { |
||||||
|
return { ok: false, error: 'missing image' }; |
||||||
|
} |
||||||
|
|
||||||
|
const ext = MIME_EXT[image.type] ?? '.png'; |
||||||
|
const buffer = Buffer.from(await image.arrayBuffer()); |
||||||
|
const url = await uploadBuffer(buffer, ext); |
||||||
|
|
||||||
|
// TODO: HEAD-verify the public URL is reachable before returning, so the QR
|
||||||
|
// never points at a not-yet-served file (architecture.md §5.1).
|
||||||
|
|
||||||
|
return { ok: true, url }; |
||||||
|
} catch (err) { |
||||||
|
return { |
||||||
|
ok: false, |
||||||
|
error: err instanceof Error ? err.message : 'upload failed', |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -1,65 +1,74 @@ |
|||||||
import Image from 'next/image'; |
'use client'; |
||||||
|
|
||||||
|
import { useState } from 'react'; |
||||||
|
import { CharacterStep } from '@/components/flow/character-step'; |
||||||
|
import { LearnStep } from '@/components/flow/learn-step'; |
||||||
|
import { RemodelStep } from '@/components/flow/remodel-step'; |
||||||
|
import { ResultStep } from '@/components/flow/result-step'; |
||||||
|
import { RidingStep } from '@/components/flow/riding-step'; |
||||||
|
import { SettingsPanel } from '@/components/flow/settings-panel'; |
||||||
|
import { StandbyStep } from '@/components/flow/standby-step'; |
||||||
|
import { useFlowSync } from '@/hooks/use-flow-sync'; |
||||||
|
import { useIdle } from '@/hooks/use-idle'; |
||||||
|
import { STEP, STEP_NAMES, type Step } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
import { useSettingsStore } from '@/stores/settings-store'; |
||||||
|
|
||||||
|
function StepView({ |
||||||
|
step, |
||||||
|
onOpenSettings, |
||||||
|
}: { |
||||||
|
step: Step; |
||||||
|
onOpenSettings: () => void; |
||||||
|
}) { |
||||||
|
switch (step) { |
||||||
|
case STEP.STANDBY: |
||||||
|
return <StandbyStep onOpenSettings={onOpenSettings} />; |
||||||
|
case STEP.CHARACTER: |
||||||
|
return <CharacterStep />; |
||||||
|
case STEP.LEARN: |
||||||
|
return <LearnStep />; |
||||||
|
case STEP.REMODEL: |
||||||
|
return <RemodelStep />; |
||||||
|
case STEP.RIDING: |
||||||
|
return <RidingStep />; |
||||||
|
case STEP.RESULT: |
||||||
|
return <ResultStep />; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
export default function Home() { |
export default function Home() { |
||||||
|
const hasHydrated = useSettingsStore((s) => s.hasHydrated); |
||||||
|
const devMode = useSettingsStore((s) => s.devMode); |
||||||
|
const idleSeconds = useSettingsStore((s) => s.idleSeconds); |
||||||
|
|
||||||
|
const step = useFlowStore((s) => s.step); |
||||||
|
const reset = useFlowStore((s) => s.reset); |
||||||
|
|
||||||
|
const { connectionStatus } = useFlowSync(); |
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false); |
||||||
|
|
||||||
|
// Idle timeout only on the interactive steps (1-3) → back to standby.
|
||||||
|
const idleActive = step >= STEP.CHARACTER && step <= STEP.REMODEL; |
||||||
|
useIdle(idleActive ? idleSeconds * 1000 : 0, reset); |
||||||
|
|
||||||
|
if (!hasHydrated) { |
||||||
|
return <div className="flex h-dvh items-center justify-center" />; |
||||||
|
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> |
<div className="relative flex h-dvh w-full flex-col bg-background text-foreground"> |
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> |
<main className="min-h-0 flex-1"> |
||||||
<Image |
<StepView step={step} onOpenSettings={() => setSettingsOpen(true)} /> |
||||||
className="dark:invert" |
|
||||||
src="/next.svg" |
|
||||||
alt="Next.js logo" |
|
||||||
width={100} |
|
||||||
height={20} |
|
||||||
priority |
|
||||||
/> |
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> |
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> |
|
||||||
To get started, edit the page.tsx file. |
|
||||||
</h1> |
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> |
|
||||||
Looking for a starting point or more instructions? Head over to{' '} |
|
||||||
<a |
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" |
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50" |
|
||||||
> |
|
||||||
Templates |
|
||||||
</a>{' '} |
|
||||||
or the{' '} |
|
||||||
<a |
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" |
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50" |
|
||||||
> |
|
||||||
Learning |
|
||||||
</a>{' '} |
|
||||||
center. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> |
|
||||||
<a |
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" |
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" |
|
||||||
target="_blank" |
|
||||||
rel="noopener noreferrer" |
|
||||||
> |
|
||||||
<Image |
|
||||||
className="dark:invert" |
|
||||||
src="/vercel.svg" |
|
||||||
alt="Vercel logomark" |
|
||||||
width={16} |
|
||||||
height={16} |
|
||||||
/> |
|
||||||
Deploy Now |
|
||||||
</a> |
|
||||||
<a |
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" |
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" |
|
||||||
target="_blank" |
|
||||||
rel="noopener noreferrer" |
|
||||||
> |
|
||||||
Documentation |
|
||||||
</a> |
|
||||||
</div> |
|
||||||
</main> |
</main> |
||||||
|
|
||||||
|
{devMode && ( |
||||||
|
<footer className="border-t border-border px-4 py-1 text-xs text-muted-foreground"> |
||||||
|
step {step} · {STEP_NAMES[step]} · mqtt {connectionStatus} |
||||||
|
</footer> |
||||||
|
)} |
||||||
|
|
||||||
|
{settingsOpen && <SettingsPanel onClose={() => setSettingsOpen(false)} />} |
||||||
</div> |
</div> |
||||||
); |
); |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,52 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { CHARACTERS, STEP } from '@/lib/flow'; |
||||||
|
import { cn } from '@/lib/utils'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
|
||||||
|
export function CharacterStep() { |
||||||
|
const characterId = useFlowStore((s) => s.characterId); |
||||||
|
const selectCharacter = useFlowStore((s) => s.selectCharacter); |
||||||
|
const setStep = useFlowStore((s) => s.setStep); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col gap-8 px-8 py-10"> |
||||||
|
<h2 className="text-center text-xl font-medium"> |
||||||
|
選一位想幫他規劃道路的角色 |
||||||
|
</h2> |
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-2 content-center gap-4"> |
||||||
|
{CHARACTERS.map((c) => { |
||||||
|
const active = characterId === c.id; |
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={c.id} |
||||||
|
type="button" |
||||||
|
onClick={() => selectCharacter(c.id)} |
||||||
|
className={cn( |
||||||
|
'flex flex-col items-center gap-2 rounded-2xl border p-6 transition-colors', |
||||||
|
active |
||||||
|
? 'border-primary bg-primary/5' |
||||||
|
: 'border-border bg-card', |
||||||
|
)} |
||||||
|
> |
||||||
|
<span className="text-lg font-medium">{c.name}</span> |
||||||
|
<span className="text-sm text-muted-foreground"> |
||||||
|
要去{c.destination} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
disabled={!characterId} |
||||||
|
onClick={() => setStep(STEP.LEARN)} |
||||||
|
className="rounded-full bg-primary py-4 text-lg font-medium text-primary-foreground transition-transform active:scale-95 disabled:opacity-40" |
||||||
|
> |
||||||
|
下一步 |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { STEP } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
|
||||||
|
export function LearnStep() { |
||||||
|
const setStep = useFlowStore((s) => s.setStep); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-10 px-10 text-center"> |
||||||
|
<p className="text-2xl font-medium">請看上方螢幕</p> |
||||||
|
<p className="max-w-sm text-base text-muted-foreground"> |
||||||
|
上方螢幕正在播放街道介紹,看完後就開始改造這條街吧。 |
||||||
|
</p> |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => setStep(STEP.REMODEL)} |
||||||
|
className="rounded-full bg-primary px-14 py-5 text-xl font-medium text-primary-foreground transition-transform active:scale-95" |
||||||
|
> |
||||||
|
開始改造 |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
data: string; |
||||||
|
size?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Renders a styled QR code for `data`. `qr-code-styling` touches the DOM, so it |
||||||
|
* is imported lazily inside the effect to stay clear of SSR. |
||||||
|
*/ |
||||||
|
export function QrCode({ data, size = 240 }: Props) { |
||||||
|
const containerRef = useRef<HTMLDivElement>(null); |
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: qr-code-styling instance type loaded lazily
|
||||||
|
const instanceRef = useRef<any>(null); |
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: initial data is read once here; later changes go through update() in the next effect
|
||||||
|
useEffect(() => { |
||||||
|
let cancelled = false; |
||||||
|
(async () => { |
||||||
|
const { default: QRCodeStyling } = await import('qr-code-styling'); |
||||||
|
if (cancelled) return; |
||||||
|
instanceRef.current = new QRCodeStyling({ |
||||||
|
width: size, |
||||||
|
height: size, |
||||||
|
type: 'svg', |
||||||
|
data, |
||||||
|
dotsOptions: { type: 'rounded' }, |
||||||
|
}); |
||||||
|
if (containerRef.current) { |
||||||
|
containerRef.current.replaceChildren(); |
||||||
|
instanceRef.current.append(containerRef.current); |
||||||
|
} |
||||||
|
})(); |
||||||
|
return () => { |
||||||
|
cancelled = true; |
||||||
|
}; |
||||||
|
}, [size]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
instanceRef.current?.update({ data }); |
||||||
|
}, [data]); |
||||||
|
|
||||||
|
return <div ref={containerRef} role="img" aria-label="QR code" />; |
||||||
|
} |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { |
||||||
|
COMPONENTS, |
||||||
|
getComponent, |
||||||
|
MIN_COMPONENT_TYPES, |
||||||
|
STEP, |
||||||
|
WIDTH_MAX, |
||||||
|
WIDTH_MIN, |
||||||
|
} from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
|
||||||
|
export function RemodelStep() { |
||||||
|
const placed = useFlowStore((s) => s.placed); |
||||||
|
const addComponent = useFlowStore((s) => s.addComponent); |
||||||
|
const removeComponent = useFlowStore((s) => s.removeComponent); |
||||||
|
const moveComponent = useFlowStore((s) => s.moveComponent); |
||||||
|
const setWidth = useFlowStore((s) => s.setWidth); |
||||||
|
const setStep = useFlowStore((s) => s.setStep); |
||||||
|
|
||||||
|
const typeCount = new Set(placed.map((p) => p.key)).size; |
||||||
|
const ready = typeCount >= MIN_COMPONENT_TYPES; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col gap-5 px-6 py-8"> |
||||||
|
<section> |
||||||
|
<h2 className="mb-3 text-sm font-medium text-muted-foreground"> |
||||||
|
街道元件 |
||||||
|
</h2> |
||||||
|
<div className="grid grid-cols-4 gap-2"> |
||||||
|
{COMPONENTS.map((c) => ( |
||||||
|
<button |
||||||
|
key={c.key} |
||||||
|
type="button" |
||||||
|
onClick={() => addComponent(c.key)} |
||||||
|
className="flex min-h-16 items-center justify-center rounded-xl border border-border bg-card p-2 text-center text-xs leading-tight transition-transform active:scale-95" |
||||||
|
> |
||||||
|
{c.name} |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<section className="flex min-h-0 flex-1 flex-col"> |
||||||
|
<h2 className="mb-3 text-sm font-medium text-muted-foreground"> |
||||||
|
你的街道({placed.length} 個 · {typeCount} 種) |
||||||
|
</h2> |
||||||
|
<div className="flex flex-1 flex-col gap-2 overflow-y-auto"> |
||||||
|
{placed.length === 0 ? ( |
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground"> |
||||||
|
點上方元件加入街道 |
||||||
|
</p> |
||||||
|
) : ( |
||||||
|
placed.map((p, i) => { |
||||||
|
const meta = getComponent(p.key); |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={p.uid} |
||||||
|
className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2" |
||||||
|
> |
||||||
|
<span className="w-5 text-center text-xs text-muted-foreground"> |
||||||
|
{i + 1} |
||||||
|
</span> |
||||||
|
<div className="flex flex-col gap-1"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
aria-label="上移" |
||||||
|
onClick={() => moveComponent(p.uid, -1)} |
||||||
|
className="text-xs text-muted-foreground" |
||||||
|
> |
||||||
|
▲ |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
aria-label="下移" |
||||||
|
onClick={() => moveComponent(p.uid, 1)} |
||||||
|
className="text-xs text-muted-foreground" |
||||||
|
> |
||||||
|
▼ |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<span className="text-sm">{meta.name}</span> |
||||||
|
|
||||||
|
{meta.isRoad ? ( |
||||||
|
<label className="ml-auto flex items-center gap-2 text-xs text-muted-foreground"> |
||||||
|
寬 |
||||||
|
<input |
||||||
|
type="range" |
||||||
|
min={WIDTH_MIN} |
||||||
|
max={WIDTH_MAX} |
||||||
|
step={1} |
||||||
|
value={p.width} |
||||||
|
onChange={(e) => |
||||||
|
setWidth(p.uid, Number(e.target.value)) |
||||||
|
} |
||||||
|
className="w-24" |
||||||
|
/> |
||||||
|
<span className="w-3 text-center">{p.width}</span> |
||||||
|
</label> |
||||||
|
) : ( |
||||||
|
<span className="ml-auto text-xs text-muted-foreground"> |
||||||
|
非道路 |
||||||
|
</span> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
aria-label="移除" |
||||||
|
onClick={() => removeComponent(p.uid)} |
||||||
|
className="ml-2 text-muted-foreground" |
||||||
|
> |
||||||
|
✕ |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}) |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
{ready ? ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => setStep(STEP.RIDING)} |
||||||
|
className="rounded-full bg-primary py-4 text-lg font-medium text-primary-foreground transition-transform active:scale-95" |
||||||
|
> |
||||||
|
完成設計,Go! |
||||||
|
</button> |
||||||
|
) : ( |
||||||
|
<p className="py-2 text-center text-sm text-muted-foreground"> |
||||||
|
再選 {MIN_COMPONENT_TYPES - typeCount} 種不同元件即可出發 |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'; |
||||||
|
import { uploadResultImage } from '@/app/actions/upload-result'; |
||||||
|
import { captureNode } from '@/lib/capture'; |
||||||
|
import { getCharacter, randomTitle, type Title } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
import { useSettingsStore } from '@/stores/settings-store'; |
||||||
|
import { QrCode } from './qr-code'; |
||||||
|
|
||||||
|
type UploadStatus = 'pending' | 'ready' | 'error'; |
||||||
|
|
||||||
|
// Hard ceiling: never let the kiosk get stuck on the result screen if the
|
||||||
|
// upload hangs (architecture.md §5.3).
|
||||||
|
const MAX_WAIT_MS = 30_000; |
||||||
|
|
||||||
|
export function ResultStep() { |
||||||
|
const characterId = useFlowStore((s) => s.characterId); |
||||||
|
const result = useFlowStore((s) => s.result); |
||||||
|
const setResult = useFlowStore((s) => s.setResult); |
||||||
|
const setTitle = useFlowStore((s) => s.setTitle); |
||||||
|
const reset = useFlowStore((s) => s.reset); |
||||||
|
|
||||||
|
const resultSeconds = useSettingsStore((s) => s.resultSeconds); |
||||||
|
const devMode = useSettingsStore((s) => s.devMode); |
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null); |
||||||
|
const startedRef = useRef(false); |
||||||
|
|
||||||
|
// TODO: swap randomTitle() for decideTitle(placed) once the rule is final.
|
||||||
|
const [title] = useState<Title>(() => randomTitle()); |
||||||
|
const [status, setStatus] = useState<UploadStatus>('pending'); |
||||||
|
|
||||||
|
const character = getCharacter(characterId); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setTitle(title.key); |
||||||
|
}, [setTitle, title.key]); |
||||||
|
|
||||||
|
// Capture + upload once.
|
||||||
|
useEffect(() => { |
||||||
|
if (startedRef.current) return; |
||||||
|
startedRef.current = true; |
||||||
|
|
||||||
|
(async () => { |
||||||
|
try { |
||||||
|
if (!cardRef.current) throw new Error('nothing to capture'); |
||||||
|
const blob = await captureNode(cardRef.current); |
||||||
|
const form = new FormData(); |
||||||
|
form.append('image', blob, 'result.png'); |
||||||
|
const res = await uploadResultImage(form); |
||||||
|
if (!res.ok || !res.url) throw new Error(res.error ?? 'upload failed'); |
||||||
|
setResult(res.url); |
||||||
|
setStatus('ready'); |
||||||
|
} catch { |
||||||
|
// Dev fallback so the QR + countdown can be exercised without a working
|
||||||
|
// capture/upload pipeline yet.
|
||||||
|
if (devMode) setResult('https://dl.giant.com.tw/MQR/demo.png'); |
||||||
|
setStatus('error'); |
||||||
|
} |
||||||
|
})(); |
||||||
|
}, [setResult, devMode]); |
||||||
|
|
||||||
|
// Return to standby: after the upload settles, or at the latest MAX_WAIT_MS.
|
||||||
|
useEffect(() => { |
||||||
|
const settled = status !== 'pending'; |
||||||
|
const delay = settled ? resultSeconds * 1000 : MAX_WAIT_MS; |
||||||
|
const timer = setTimeout(reset, delay); |
||||||
|
return () => clearTimeout(timer); |
||||||
|
}, [status, resultSeconds, reset]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-8"> |
||||||
|
<div |
||||||
|
ref={cardRef} |
||||||
|
className="flex w-full max-w-sm flex-col items-center gap-3 rounded-3xl bg-card p-8 text-center" |
||||||
|
> |
||||||
|
{character && ( |
||||||
|
<p className="text-sm text-muted-foreground"> |
||||||
|
{character.name} · 要去{character.destination} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<p className="text-sm tracking-wide text-muted-foreground"> |
||||||
|
{title.en} |
||||||
|
</p> |
||||||
|
<p className="text-4xl font-medium">{title.zh}</p> |
||||||
|
<p className="max-w-xs text-sm leading-relaxed text-muted-foreground"> |
||||||
|
{title.subtitle} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3"> |
||||||
|
{result ? ( |
||||||
|
<QrCode data={result} size={200} /> |
||||||
|
) : ( |
||||||
|
<div className="flex size-[200px] items-center justify-center rounded-xl border border-border text-sm text-muted-foreground"> |
||||||
|
{status === 'error' ? '上傳失敗' : '產生中…'} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
掃描 QR Code 帶走你的設計 |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useEffect } from 'react'; |
||||||
|
import { STEP } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
import { useSettingsStore } from '@/stores/settings-store'; |
||||||
|
|
||||||
|
export function RidingStep() { |
||||||
|
const setStep = useFlowStore((s) => s.setStep); |
||||||
|
const ridingSeconds = useSettingsStore((s) => s.ridingSeconds); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const timer = setTimeout(() => setStep(STEP.RESULT), ridingSeconds * 1000); |
||||||
|
return () => clearTimeout(timer); |
||||||
|
}, [setStep, ridingSeconds]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8"> |
||||||
|
<div className="text-5xl motion-safe:animate-bounce">🚲</div> |
||||||
|
<p className="text-2xl font-medium">騎乘中...</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onClose: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export function SettingsPanel({ onClose }: Props) { |
||||||
|
const s = useSettingsStore(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6"> |
||||||
|
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl bg-card p-6"> |
||||||
|
<h2 className="text-lg font-medium">設定</h2> |
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm"> |
||||||
|
站台 ID |
||||||
|
<input |
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2" |
||||||
|
value={s.id} |
||||||
|
onChange={(e) => s.setId(e.target.value)} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-sm"> |
||||||
|
MQTT Broker URL |
||||||
|
<input |
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2" |
||||||
|
placeholder="wss://192.168.1.50/mqtt" |
||||||
|
value={s.brokerUrl ?? ''} |
||||||
|
onChange={(e) => s.setBrokerUrl(e.target.value || null)} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3"> |
||||||
|
<label className="flex flex-col gap-1 text-sm"> |
||||||
|
騎乘秒數 |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2" |
||||||
|
value={s.ridingSeconds} |
||||||
|
onChange={(e) => s.setRidingSeconds(Number(e.target.value))} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
<label className="flex flex-col gap-1 text-sm"> |
||||||
|
成果秒數 |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2" |
||||||
|
value={s.resultSeconds} |
||||||
|
onChange={(e) => s.setResultSeconds(Number(e.target.value))} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
<label className="flex flex-col gap-1 text-sm"> |
||||||
|
閒置秒數 |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2" |
||||||
|
value={s.idleSeconds} |
||||||
|
onChange={(e) => s.setIdleSeconds(Number(e.target.value))} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
checked={s.devMode} |
||||||
|
onChange={(e) => s.setDevMode(e.target.checked)} |
||||||
|
/> |
||||||
|
開發模式 |
||||||
|
</label> |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={onClose} |
||||||
|
className="mt-2 rounded-full bg-primary py-3 text-primary-foreground" |
||||||
|
> |
||||||
|
關閉 |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useSecretKnock } from '@/hooks/use-secret-knock'; |
||||||
|
import { STEP } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onOpenSettings: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
export function StandbyStep({ onOpenSettings }: Props) { |
||||||
|
const setStep = useFlowStore((s) => s.setStep); |
||||||
|
const knock = useSecretKnock(onOpenSettings, 5, 1500); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-12 px-10 text-center"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={knock} |
||||||
|
className="max-w-xl text-2xl leading-relaxed font-medium" |
||||||
|
> |
||||||
|
在這個城市,大家都想騎自行車到各種地方,為他們設計道路吧! |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => setStep(STEP.CHARACTER)} |
||||||
|
className="rounded-full bg-primary px-16 py-5 text-xl font-medium text-primary-foreground transition-transform active:scale-95" |
||||||
|
> |
||||||
|
開始 |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
'use client'; |
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react'; |
||||||
|
import { serializeFlowState } from '@/lib/flow'; |
||||||
|
import { useFlowStore } from '@/stores/flow-store'; |
||||||
|
import { useSettingsStore } from '@/stores/settings-store'; |
||||||
|
import { useMQTT } from './use-mqtt'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Publishes the current flow state to `station/{id}/state` (retained, QoS 1) on |
||||||
|
* every change, so the state is the single source of truth that Unity / other |
||||||
|
* clients read and recover from on reconnect. |
||||||
|
* |
||||||
|
* TODO: throttle/debounce publishes during high-frequency edits (width drag) |
||||||
|
* before sending over WiFi (architecture.md §3.7). |
||||||
|
*/ |
||||||
|
export function useFlowSync() { |
||||||
|
const id = useSettingsStore((s) => s.id); |
||||||
|
const brokerUrl = useSettingsStore((s) => s.brokerUrl); |
||||||
|
|
||||||
|
const step = useFlowStore((s) => s.step); |
||||||
|
const characterId = useFlowStore((s) => s.characterId); |
||||||
|
const placed = useFlowStore((s) => s.placed); |
||||||
|
const title = useFlowStore((s) => s.title); |
||||||
|
const result = useFlowStore((s) => s.result); |
||||||
|
|
||||||
|
const stateTopic = `station/${id}/state`; |
||||||
|
|
||||||
|
// Controller mostly publishes; subscribe to nothing for now.
|
||||||
|
const onMessage = useCallback(() => {}, []); |
||||||
|
const { client, connectionStatus } = useMQTT({ |
||||||
|
brokerUrl, |
||||||
|
topics: [], |
||||||
|
onMessage, |
||||||
|
}); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!client?.connected) return; |
||||||
|
const payload = serializeFlowState({ |
||||||
|
id, |
||||||
|
step, |
||||||
|
characterId, |
||||||
|
placed, |
||||||
|
title, |
||||||
|
result, |
||||||
|
}); |
||||||
|
client.publish(stateTopic, JSON.stringify(payload), { |
||||||
|
qos: 1, |
||||||
|
retain: true, |
||||||
|
}); |
||||||
|
}, [client, stateTopic, id, step, characterId, placed, title, result]); |
||||||
|
|
||||||
|
return { connectionStatus }; |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
import { toBlob } from 'html-to-image'; |
||||||
|
|
||||||
|
// Capture a DOM node to a PNG image Blob for upload.
|
||||||
|
//
|
||||||
|
// Single seam for the result flow: the rest of the pipeline (upload, QR, MQTT
|
||||||
|
// publish) only depends on this returning a Blob.
|
||||||
|
export async function captureNode(node: HTMLElement): Promise<Blob> { |
||||||
|
const blob = await toBlob(node, { |
||||||
|
pixelRatio: 2, |
||||||
|
cacheBust: true, |
||||||
|
backgroundColor: '#ffffff', |
||||||
|
}); |
||||||
|
if (!blob) throw new Error('capture produced no blob'); |
||||||
|
return blob; |
||||||
|
} |
||||||
@ -0,0 +1,227 @@ |
|||||||
|
// Domain model for the street-design exhibit flow. Pure data + helpers so the
|
||||||
|
// UI, MQTT payload, and (future) tests share one source of truth.
|
||||||
|
|
||||||
|
export const STEP = { |
||||||
|
STANDBY: 0, |
||||||
|
CHARACTER: 1, |
||||||
|
LEARN: 2, |
||||||
|
REMODEL: 3, |
||||||
|
RIDING: 4, |
||||||
|
RESULT: 5, |
||||||
|
} as const; |
||||||
|
|
||||||
|
export type Step = (typeof STEP)[keyof typeof STEP]; |
||||||
|
|
||||||
|
export const STEP_NAMES: Record<Step, string> = { |
||||||
|
0: 'standby', |
||||||
|
1: 'character', |
||||||
|
2: 'learn', |
||||||
|
3: 'remodel', |
||||||
|
4: 'riding', |
||||||
|
5: 'result', |
||||||
|
}; |
||||||
|
|
||||||
|
/** Minimum number of distinct component types before the design can be sent. */ |
||||||
|
export const MIN_COMPONENT_TYPES = 3; |
||||||
|
|
||||||
|
/** Width steps allowed for road-type components. */ |
||||||
|
export const WIDTH_MIN = 1; |
||||||
|
export const WIDTH_MAX = 3; |
||||||
|
export const WIDTH_DEFAULT = 2; |
||||||
|
|
||||||
|
// --- Characters ------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CharacterId = 'sam' | 'peter' | 'jenny' | 'kiki' | 'lizhu'; |
||||||
|
|
||||||
|
export interface Character { |
||||||
|
id: CharacterId; |
||||||
|
name: string; |
||||||
|
destination: string; |
||||||
|
} |
||||||
|
|
||||||
|
export const CHARACTERS: Character[] = [ |
||||||
|
{ id: 'sam', name: '山姆', destination: '同學家' }, |
||||||
|
{ id: 'peter', name: '彼得', destination: '學校' }, |
||||||
|
{ id: 'jenny', name: '珍妮', destination: '市場' }, |
||||||
|
{ id: 'kiki', name: '琪琪', destination: '公司' }, |
||||||
|
{ id: 'lizhu', name: '麗珠', destination: '公園' }, |
||||||
|
]; |
||||||
|
|
||||||
|
export function getCharacter(id: CharacterId | null): Character | null { |
||||||
|
return CHARACTERS.find((c) => c.id === id) ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
// --- Street components ------------------------------------------------------
|
||||||
|
|
||||||
|
export type ComponentCategory = 'bike' | 'pedestrian' | 'green' | 'vehicle'; |
||||||
|
|
||||||
|
export type ComponentKey = |
||||||
|
| 'bike-lane' |
||||||
|
| 'bike-priority-marking' |
||||||
|
| 'bike-signal' |
||||||
|
| 'bike-rack' |
||||||
|
| 'street-tree' |
||||||
|
| 'sidewalk' |
||||||
|
| 'car-lane' |
||||||
|
| 'motor-lane'; |
||||||
|
|
||||||
|
export interface StreetComponent { |
||||||
|
key: ComponentKey; |
||||||
|
name: string; |
||||||
|
category: ComponentCategory; |
||||||
|
/** Road-type components support width + ordering adjustment. */ |
||||||
|
isRoad: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export const COMPONENTS: StreetComponent[] = [ |
||||||
|
{ key: 'bike-lane', name: '自行車專用道', category: 'bike', isRoad: true }, |
||||||
|
{ |
||||||
|
key: 'bike-priority-marking', |
||||||
|
name: '自行車優先標線', |
||||||
|
category: 'bike', |
||||||
|
isRoad: false, |
||||||
|
}, |
||||||
|
{ key: 'bike-signal', name: '自行車號誌', category: 'bike', isRoad: false }, |
||||||
|
{ key: 'bike-rack', name: '自行車車架', category: 'bike', isRoad: false }, |
||||||
|
{ key: 'street-tree', name: '行道樹', category: 'green', isRoad: false }, |
||||||
|
{ key: 'sidewalk', name: '人行道', category: 'pedestrian', isRoad: true }, |
||||||
|
{ key: 'car-lane', name: '汽車道', category: 'vehicle', isRoad: true }, |
||||||
|
{ key: 'motor-lane', name: '機車道', category: 'vehicle', isRoad: true }, |
||||||
|
]; |
||||||
|
|
||||||
|
export const TOTAL_COMPONENT_TYPES = COMPONENTS.length; |
||||||
|
|
||||||
|
export function getComponent(key: ComponentKey): StreetComponent { |
||||||
|
const found = COMPONENTS.find((c) => c.key === key); |
||||||
|
if (!found) throw new Error(`unknown component: ${key}`); |
||||||
|
return found; |
||||||
|
} |
||||||
|
|
||||||
|
export function isRoadComponent(key: ComponentKey): boolean { |
||||||
|
return getComponent(key).isRoad; |
||||||
|
} |
||||||
|
|
||||||
|
/** A component instance placed on the street (multiple of one type allowed). */ |
||||||
|
export interface PlacedComponent { |
||||||
|
uid: string; |
||||||
|
key: ComponentKey; |
||||||
|
/** Only meaningful when the component is a road type. */ |
||||||
|
width: number; |
||||||
|
} |
||||||
|
|
||||||
|
// --- Titles -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type TitleKey = 'cyclist' | 'walker' | 'balancer' | 'futurist'; |
||||||
|
|
||||||
|
export interface Title { |
||||||
|
key: TitleKey; |
||||||
|
en: string; |
||||||
|
zh: string; |
||||||
|
subtitle: string; |
||||||
|
} |
||||||
|
|
||||||
|
export const TITLES: Record<TitleKey, Title> = { |
||||||
|
cyclist: { |
||||||
|
key: 'cyclist', |
||||||
|
en: "THE CYCLIST'S FRIEND", |
||||||
|
zh: '逐風者', |
||||||
|
subtitle: '多一個騎士,就少一台車的碳排', |
||||||
|
}, |
||||||
|
walker: { |
||||||
|
key: 'walker', |
||||||
|
en: "THE WALKER'S FRIEND", |
||||||
|
zh: '散策者', |
||||||
|
subtitle: '走路是碳排最低的移動方式', |
||||||
|
}, |
||||||
|
balancer: { |
||||||
|
key: 'balancer', |
||||||
|
en: 'THE BALANCER', |
||||||
|
zh: '共行者', |
||||||
|
subtitle: '一起為減碳努力', |
||||||
|
}, |
||||||
|
futurist: { |
||||||
|
key: 'futurist', |
||||||
|
en: 'THE FUTURIST', |
||||||
|
zh: '未來使者', |
||||||
|
subtitle: '你的街道,是淨零城市的榜樣', |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
export interface TitleStats { |
||||||
|
total: number; |
||||||
|
typeCount: number; |
||||||
|
bikeCount: number; |
||||||
|
pedestrianScore: number; |
||||||
|
} |
||||||
|
|
||||||
|
export function titleStats(placed: PlacedComponent[]): TitleStats { |
||||||
|
const total = placed.length; |
||||||
|
const typeCount = new Set(placed.map((p) => p.key)).size; |
||||||
|
const bikeCount = placed.filter( |
||||||
|
(p) => getComponent(p.key).category === 'bike', |
||||||
|
).length; |
||||||
|
// Pedestrian-friendly score = sidewalks + street trees.
|
||||||
|
const pedestrianScore = placed.filter( |
||||||
|
(p) => p.key === 'sidewalk' || p.key === 'street-tree', |
||||||
|
).length; |
||||||
|
return { total, typeCount, bikeCount, pedestrianScore }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Deterministic title decision with explicit precedence so overlapping |
||||||
|
* conditions resolve consistently: futurist > balancer > cyclist/walker. |
||||||
|
* |
||||||
|
* NOTE: the brief asks for a random title for now (see `randomTitle`). This is |
||||||
|
* kept ready for when the real rule is switched on. |
||||||
|
*/ |
||||||
|
export function decideTitle(placed: PlacedComponent[]): Title { |
||||||
|
const { total, typeCount, bikeCount, pedestrianScore } = titleStats(placed); |
||||||
|
|
||||||
|
if (total >= 6 || typeCount === TOTAL_COMPONENT_TYPES) return TITLES.futurist; |
||||||
|
if (bikeCount >= 1 && total - bikeCount >= 1 && typeCount >= 4) |
||||||
|
return TITLES.balancer; |
||||||
|
return bikeCount >= pedestrianScore ? TITLES.cyclist : TITLES.walker; |
||||||
|
} |
||||||
|
|
||||||
|
/** Random title — current placeholder behaviour per the brief. */ |
||||||
|
export function randomTitle(): Title { |
||||||
|
const keys = Object.keys(TITLES) as TitleKey[]; |
||||||
|
return TITLES[keys[Math.floor(Math.random() * keys.length)]]; |
||||||
|
} |
||||||
|
|
||||||
|
// --- MQTT payload -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FlowStatePayload { |
||||||
|
id: string; |
||||||
|
step: Step; |
||||||
|
stepName: string; |
||||||
|
character: CharacterId | null; |
||||||
|
components: { key: ComponentKey; order: number; width: number | null }[]; |
||||||
|
title: TitleKey | null; |
||||||
|
result: string | null; |
||||||
|
updatedAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
export function serializeFlowState(input: { |
||||||
|
id: string; |
||||||
|
step: Step; |
||||||
|
characterId: CharacterId | null; |
||||||
|
placed: PlacedComponent[]; |
||||||
|
title: TitleKey | null; |
||||||
|
result: string | null; |
||||||
|
}): FlowStatePayload { |
||||||
|
return { |
||||||
|
id: input.id, |
||||||
|
step: input.step, |
||||||
|
stepName: STEP_NAMES[input.step], |
||||||
|
character: input.characterId, |
||||||
|
components: input.placed.map((p, i) => ({ |
||||||
|
key: p.key, |
||||||
|
order: i + 1, |
||||||
|
width: isRoadComponent(p.key) ? p.width : null, |
||||||
|
})), |
||||||
|
title: input.title, |
||||||
|
result: input.result, |
||||||
|
updatedAt: Date.now(), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
import { create } from 'zustand'; |
||||||
|
import { |
||||||
|
type CharacterId, |
||||||
|
type ComponentKey, |
||||||
|
isRoadComponent, |
||||||
|
type PlacedComponent, |
||||||
|
STEP, |
||||||
|
type Step, |
||||||
|
type TitleKey, |
||||||
|
WIDTH_DEFAULT, |
||||||
|
WIDTH_MAX, |
||||||
|
WIDTH_MIN, |
||||||
|
} from '@/lib/flow'; |
||||||
|
|
||||||
|
// Transient per-session state. Deliberately NOT persisted: a reload or an idle
|
||||||
|
// timeout starts a fresh session at standby.
|
||||||
|
type FlowState = { |
||||||
|
step: Step; |
||||||
|
characterId: CharacterId | null; |
||||||
|
placed: PlacedComponent[]; |
||||||
|
title: TitleKey | null; |
||||||
|
result: string | null; |
||||||
|
|
||||||
|
setStep: (step: Step) => void; |
||||||
|
selectCharacter: (id: CharacterId) => void; |
||||||
|
addComponent: (key: ComponentKey) => void; |
||||||
|
removeComponent: (uid: string) => void; |
||||||
|
moveComponent: (uid: string, dir: -1 | 1) => void; |
||||||
|
setWidth: (uid: string, width: number) => void; |
||||||
|
setTitle: (title: TitleKey | null) => void; |
||||||
|
setResult: (result: string | null) => void; |
||||||
|
reset: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
function clampWidth(w: number): number { |
||||||
|
return Math.max(WIDTH_MIN, Math.min(WIDTH_MAX, Math.round(w))); |
||||||
|
} |
||||||
|
|
||||||
|
export const useFlowStore = create<FlowState>()((set) => ({ |
||||||
|
step: STEP.STANDBY, |
||||||
|
characterId: null, |
||||||
|
placed: [], |
||||||
|
title: null, |
||||||
|
result: null, |
||||||
|
|
||||||
|
setStep: (step) => set({ step }), |
||||||
|
|
||||||
|
selectCharacter: (characterId) => set({ characterId }), |
||||||
|
|
||||||
|
addComponent: (key) => |
||||||
|
set((state) => ({ |
||||||
|
placed: [ |
||||||
|
...state.placed, |
||||||
|
{ |
||||||
|
uid: crypto.randomUUID(), |
||||||
|
key, |
||||||
|
width: isRoadComponent(key) ? WIDTH_DEFAULT : WIDTH_DEFAULT, |
||||||
|
}, |
||||||
|
], |
||||||
|
})), |
||||||
|
|
||||||
|
removeComponent: (uid) => |
||||||
|
set((state) => ({ placed: state.placed.filter((p) => p.uid !== uid) })), |
||||||
|
|
||||||
|
moveComponent: (uid, dir) => |
||||||
|
set((state) => { |
||||||
|
const i = state.placed.findIndex((p) => p.uid === uid); |
||||||
|
const j = i + dir; |
||||||
|
if (i === -1 || j < 0 || j >= state.placed.length) return state; |
||||||
|
const placed = state.placed.slice(); |
||||||
|
[placed[i], placed[j]] = [placed[j], placed[i]]; |
||||||
|
return { placed }; |
||||||
|
}), |
||||||
|
|
||||||
|
setWidth: (uid, width) => |
||||||
|
set((state) => ({ |
||||||
|
placed: state.placed.map((p) => |
||||||
|
p.uid === uid ? { ...p, width: clampWidth(width) } : p, |
||||||
|
), |
||||||
|
})), |
||||||
|
|
||||||
|
setTitle: (title) => set({ title }), |
||||||
|
|
||||||
|
setResult: (result) => set({ result }), |
||||||
|
|
||||||
|
reset: () => |
||||||
|
set({ |
||||||
|
step: STEP.STANDBY, |
||||||
|
characterId: null, |
||||||
|
placed: [], |
||||||
|
title: null, |
||||||
|
result: null, |
||||||
|
}), |
||||||
|
})); |
||||||
Loading…
Reference in new issue