1"use client"
2
3import type { CSSProperties, KeyboardEvent, ReactNode } from "react"
4import { Camera, Keyboard, Warning } from "@phosphor-icons/react"
5
6import { Button } from "@/components/ui/button"
7import { CountdownOverlay } from "@/features/capture/CountdownOverlay"
8import { ShutterButton } from "@/features/capture/ShutterButton"
9import {
10 canShoot,
11 type CapturePhase,
12 type CountdownValue,
13 type DeniedReason,
14} from "@/features/capture/machine"
15import { cn } from "@/lib/utils"
16
17/* DESIGN-SYSTEM §9: area kamera `ink`, garis 3 px, radius 16 px, braket bidik
18 di empat sudut, HUD di atas tengah, tombol jepret dan chip pintasan di bawah.
19 Berkas acuan `docs/ui-reference/capture.html` memakai rasio 1 : 1 di bawah 768 px. */
20
21export type FlashKind = "none" | "overlay" | "border"
22
23const BRACKETS = [
24 "top-4 left-4 border-t-4 border-l-4",
25 "top-4 right-4 border-t-4 border-r-4",
26 "bottom-4 left-4 border-b-4 border-l-4",
27 "bottom-4 right-4 border-b-4 border-r-4",
28]
29
30const DENIED_MESSAGES: Record<DeniedReason, string> = {
31 denied:
32 "Kamera belum diizinkan. Buka pengaturan situs di browser, izinkan kamera, lalu muat ulang.",
33 unavailable:
34 "Kamera tidak ditemukan atau sedang dipakai aplikasi lain. Tutup aplikasi lain, lalu coba lagi.",
35}
36
37interface CameraStageProps {
38 ratio: number
39 phase: CapturePhase
40 deniedReason: DeniedReason | null
41 countdownValue: CountdownValue | null
42 flash: FlashKind
43 onRequestPermission: () => void
44 onShoot: () => void
45 children?: ReactNode
46 hud?: ReactNode
47 /** M8 (SE-05): lapisan di atas pratinjau, dipakai untuk bayangan pose foto
48 * pembuat di layar teman. Di bawah HUD, hitung mundur, dan braket bidik. */
49 overlay?: ReactNode
50}
51
52function PermissionScreen({
53 onRequestPermission,
54}: {
55 onRequestPermission: () => void
56}) {
57 return (
58 <div className="flex flex-col items-center gap-4 px-6 text-center">
59 <Camera weight="bold" className="size-10 text-paper" aria-hidden />
60 <div className="flex flex-col gap-1.5">
61 <p className="text-[17px] font-bold text-paper">
62 Kamera dipakai motret di perangkat ini.
63 </p>
64 <p className="max-w-[36ch] text-[15px] leading-[1.4] text-paper">
65 Foto diproses di perangkat. Tidak ada yang diunggah.
66 </p>
67 </div>
68 <Button size="lg" onClick={onRequestPermission}>
69 Izinkan Kamera
70 </Button>
71 </div>
72 )
73}
74
75function DeniedScreen({
76 deniedReason,
77 onRequestPermission,
78}: {
79 deniedReason: DeniedReason | null
80 onRequestPermission: () => void
81}) {
82 return (
83 <div
84 role="alert"
85 className="flex flex-col items-center gap-4 px-6 text-center"
86 >
87 <Warning weight="bold" className="size-10 text-paper" aria-hidden />
88 <p className="max-w-[42ch] text-[15px] leading-[1.4] text-paper">
89 {deniedReason === null
90 ? DENIED_MESSAGES.unavailable
91 : DENIED_MESSAGES[deniedReason]}
92 </p>
93 <Button variant="neutral" onClick={onRequestPermission}>
94 Coba Lagi
95 </Button>
96 </div>
97 )
98}
99
100export function CameraStage({
101 ratio,
102 phase,
103 deniedReason,
104 countdownValue,
105 flash,
106 onRequestPermission,
107 onShoot,
108 children,
109 hud,
110 overlay,
111}: CameraStageProps) {
112 /* Tanpa kamera (belum izin atau izin ditolak) kontrol jepret tidak ditampilkan
113 supaya tidak menutupi tombol izin. */
114 const showControls = phase !== "idle" && phase !== "denied"
115
116 function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
117 if (event.key !== " " || event.target !== event.currentTarget) {
118 return
119 }
120 event.preventDefault()
121 onShoot()
122 }
123
124 return (
125 <div
126 role="group"
127 aria-label="Area kamera"
128 tabIndex={0}
129 onKeyDown={handleKeyDown}
130 style={{ "--cam-ratio": String(ratio) } as CSSProperties}
131 className={cn(
132 "relative flex aspect-(--cam-ratio) items-center justify-center overflow-hidden rounded-stage border-3 bg-ink",
133 "max-md:aspect-square",
134 "focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-lime",
135 flash === "border" ? "border-lime" : "border-ink"
136 )}
137 >
138 {BRACKETS.map((corner) => (
139 <span
140 key={corner}
141 aria-hidden
142 className={cn(
143 /* z-10: video dicerminkan lewat transform, jadi ia punya stacking context sendiri. */
144 "pointer-events-none absolute z-10 size-9 border-lime",
145 corner
146 )}
147 />
148 ))}
149
150 {phase === "idle" ? (
151 <PermissionScreen onRequestPermission={onRequestPermission} />
152 ) : null}
153 {phase === "denied" ? (
154 <DeniedScreen
155 deniedReason={deniedReason}
156 onRequestPermission={onRequestPermission}
157 />
158 ) : null}
159 {phase === "idle" || phase === "denied" ? null : children}
160
161 {/* M8 (SE-05): bayangan pose teman, opasitasnya diatur pemanggil. z-[5]
162 menjaga braket bidik (z-10) tetap di atasnya. */}
163 {overlay === undefined || phase === "idle" || phase === "denied" ? null : (
164 <div
165 aria-hidden
166 data-testid="stage-overlay"
167 className="pointer-events-none absolute inset-0 z-[5] opacity-35"
168 >
169 {overlay}
170 </div>
171 )}
172
173 {hud === undefined ? null : (
174 <div className="absolute inset-x-0 top-3.5 flex justify-center px-4 md:top-7 md:px-16">
175 {hud}
176 </div>
177 )}
178
179 {countdownValue === null ? null : (
180 <CountdownOverlay value={countdownValue} />
181 )}
182
183 {flash === "overlay" ? (
184 <div data-testid="flash" className="absolute inset-0 bg-paper/90" />
185 ) : null}
186
187 {showControls ? (
188 <div className="pointer-events-none absolute inset-x-0 bottom-5 flex items-center justify-center gap-3.5">
189 <span className="pointer-events-auto flex">
190 <ShutterButton onShoot={onShoot} disabled={!canShoot(phase)} />
191 </span>
192 <span className="hidden items-center gap-1.5 rounded-sm border-2 border-ink bg-paper px-2.5 py-1 font-mono text-xs font-bold tracking-[0.06em] text-ink uppercase md:inline-flex">
193 <Keyboard weight="bold" className="size-4" aria-hidden />
194 Spasi
195 </span>
196 </div>
197 ) : null}
198 </div>
199 )
200}
201