eunzzang's picture
Fix learning route build and restore word learning layout
11ab88a verified
Raw
History Blame Contribute Delete
25.8 kB
import React from "react";
import { createRoot } from "react-dom/client";
import { Camera, ChevronLeft, ImagePlus, Save, Trash2 } from "lucide-react";
import { requestJson, getConfidenceState } from "./api/client";
import "./style.css";
const fallbackLabels = Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index));
function navigate(path) {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}
function Link({ href, children, className, current, ...props }) {
return (
<a
className={className}
href={href}
aria-current={current ? "page" : undefined}
{...props}
onClick={(event) => {
event.preventDefault();
navigate(href);
}}
>
{children}
</a>
);
}
function Navbar({ path }) {
return (
<header className="navbar">
<Link className="brand" href="/">
<span className="brand-mark">ASL</span>
<span>ASL Letter Board</span>
</Link>
<nav>
<Link href="/" current={path === "/"}>ํ™ˆ</Link>
<Link href="/classify" current={path === "/classify"}>๋ถ„๋ฅ˜</Link>
<Link href="/learning" current={path === "/learning"}>๋‹จ์–ด ํ•™์Šต</Link>
<Link href="/board" current={path === "/board"}>๊ฒŒ์‹œํŒ</Link>
</nav>
</header>
);
}
function Home() {
const [intro, setIntro] = React.useState(() => sessionStorage.getItem("intro-done") === "1");
function startIntro() {
sessionStorage.setItem("intro-done", "1");
setIntro(true);
}
return (
<>
{!intro && (
<div className="intro-overlay">
<div className="intro-content">
<button className="intro-start-btn" type="button" onClick={startIntro}>์‹œ์ž‘</button>
</div>
</div>
)}
<section className={`home-hero ${intro ? "" : "is-hidden"}`}>
<div>
<p className="eyebrow">ASL A-Z Learning</p>
<h1>ASL A-Z ์ˆ˜ํ™” ์•ŒํŒŒ๋ฒณ ๋ถ„๋ฅ˜ ๊ฒŒ์‹œํŒ</h1>
<p>์›น์บ ์ด๋‚˜ ์ด๋ฏธ์ง€๋ฅผ ํ†ตํ•ด ASL ์•ŒํŒŒ๋ฒณ A๋ถ€ํ„ฐ Z๊นŒ์ง€ ๋Œ€๋žต์ ์œผ๋กœ ๊ตฌ๋ถ„ํ•˜๊ณ , ๊ฒฐ๊ณผ๋ฅผ ๊ฒŒ์‹œํŒ์— ์ €์žฅํ•ด ๊ฐ„๋‹จํ•œ ํ•™์Šต ๊ธฐ๋ก์ฒ˜๋Ÿผ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
<div className="home-actions">
<Link className="button-link" href="/classify">๋ถ„๋ฅ˜ ์‹œ์ž‘</Link>
<Link className="button-link secondary-link" href="/learning">๋‹จ์–ด ํ•™์Šตํ•˜๊ธฐ</Link>
<Link className="button-link secondary-link" href="/board">ํ•™์Šต ๊ธฐ๋ก ๋ณด๊ธฐ</Link>
</div>
</div>
</section>
</>
);
}
function Classify() {
const [file, setFile] = React.useState(null);
const [preview, setPreview] = React.useState("");
const [result, setResult] = React.useState(null);
const [dragging, setDragging] = React.useState(false);
const [stream, setStream] = React.useState(null);
const videoRef = React.useRef(null);
React.useEffect(() => () => stopCamera(), [stream]);
function stopCamera() {
if (!stream) return;
stream.getTracks().forEach((track) => track.stop());
setStream(null);
}
function selectFile(nextFile) {
if (!nextFile) return;
stopCamera();
setFile(nextFile);
const reader = new FileReader();
reader.onload = () => setPreview(reader.result);
reader.readAsDataURL(nextFile);
}
async function runPrediction(blob, filename = "capture.jpg") {
const formData = new FormData();
formData.append("file", blob, filename);
try {
const data = await requestJson("/api/v1/predict", { method: "POST", body: formData });
setResult(data);
} catch (error) {
alert(error.message);
}
}
async function submit(event) {
event.preventDefault();
if (!file) {
alert("์ด๋ฏธ์ง€๋ฅผ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.");
return;
}
await runPrediction(file, file.name);
}
async function toggleCamera() {
if (stream) {
stopCamera();
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
alert("์ด ๋ธŒ๋ผ์šฐ์ €์—์„œ๋Š” ์›น์บ ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
return;
}
try {
const nextStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
setPreview("");
setStream(nextStream);
if (videoRef.current) {
videoRef.current.srcObject = nextStream;
}
} catch {
alert("์›น์บ  ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด ์ฃผ์„ธ์š”.");
}
}
React.useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream;
}
}, [stream]);
async function captureCamera() {
const video = videoRef.current;
if (!stream || !video?.videoWidth) {
alert("์›น์บ  ํ™”๋ฉด์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.");
return;
}
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
setPreview(dataUrl);
stopCamera();
canvas.toBlob(async (blob) => {
if (!blob) {
alert("์›น์บ  ์ด๋ฏธ์ง€๋ฅผ ์บก์ฒ˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.");
return;
}
const capturedFile = new File([blob], "webcam-capture.jpg", { type: "image/jpeg" });
setFile(capturedFile);
await runPrediction(blob, "webcam-capture.jpg");
}, "image/jpeg", 0.92);
}
async function savePost() {
if (!result) return;
try {
const post = await requestJson("/api/v1/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: `ASL ${result.predicted_class} ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ`,
image_url: preview,
prediction: result.predicted_class,
confidence: result.confidence,
}),
});
navigate(`/post/${post.id}`);
} catch {
alert("ํ•™์Šต ๊ธฐ๋ก์„ ์ €์žฅํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.");
}
}
return (
<>
<section className="page-heading">
<p className="eyebrow">ASL A-Z Classification</p>
<h1>ASL ์•ŒํŒŒ๋ฒณ ์ด๋ฏธ์ง€๋ฅผ ๋ถ„๋ฅ˜ํ•˜์„ธ์š”</h1>
<p>A๋ถ€ํ„ฐ Z๊นŒ์ง€์˜ ์˜์–ด ์ˆ˜ํ™” ์ด๋ฏธ์ง€๋ฅผ ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ์›น์บ ์œผ๋กœ ์ดฌ์˜ํ•ด ์–ด๋–ค ์•ŒํŒŒ๋ฒณ์— ๊ฐ€๊นŒ์šด์ง€ ๊ฐ„๋‹จํžˆ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.</p>
</section>
<section className={`predict-layout ${result ? "has-result" : ""}`}>
<section className="upload-box card">
<div className="section-title">
<h2>์ˆ˜ํ™” ์ด๋ฏธ์ง€ ์ž…๋ ฅ</h2>
<span>JPG, PNG ยท ์ตœ๋Œ€ 10MB</span>
</div>
<form onSubmit={submit}>
<label
className={`upload-area ${preview ? "has-image" : ""} ${stream ? "is-camera" : ""} ${dragging ? "is-dragging" : ""}`}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
event.preventDefault();
setDragging(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const dropped = event.dataTransfer.files[0];
if (dropped?.type.startsWith("image/")) {
selectFile(dropped);
}
}}
>
<input type="file" accept="image/*" onChange={(event) => selectFile(event.target.files[0])} />
{stream && <video ref={videoRef} autoPlay playsInline muted />}
{preview && <img id="preview" src={preview} alt="" />}
{!preview && !stream && <span><ImagePlus size={22} /> ASL A-Z ์ด๋ฏธ์ง€ ์„ ํƒ ๋˜๋Š” ๋“œ๋ž˜๊ทธ</span>}
</label>
<div className="webcam-controls">
<button className="secondary-btn" type="button" onClick={toggleCamera}>
<Camera size={18} /> {stream ? "์›น์บ  ๋„๊ธฐ" : "์›น์บ  ์‹œ์ž‘"}
</button>
{stream && <button type="button" onClick={captureCamera}>์ดฌ์˜ ํ›„ ๋ถ„์„</button>}
</div>
<button type="submit">์•ŒํŒŒ๋ฒณ ๋ถ„๋ฅ˜</button>
</form>
</section>
{result && (
<section className="card result-card">
<div className="section-title">
<h2>๊ตฌ๋ถ„ ๊ฒฐ๊ณผ</h2>
<span>Top 5</span>
</div>
<div className="result-summary">
<span>{result.predicted_class}</span>
<strong>์‹ ๋ขฐ๋„ {(result.confidence * 100).toFixed(2)}%</strong>
</div>
<ul className="score-list">
{result.top_k.map((item) => (
<li key={item.label}>
<span>{item.label}</span>
<span className="score-track"><span className="score-bar" style={{ width: `${(item.score * 100).toFixed(2)}%` }} /></span>
<strong>{(item.score * 100).toFixed(1)}%</strong>
</li>
))}
</ul>
<button type="button" onClick={savePost}><Save size={18} /> ํ•™์Šต ๊ธฐ๋ก์œผ๋กœ ์ €์žฅ</button>
</section>
)}
</section>
</>
);
}
function Board() {
const [labels, setLabels] = React.useState(fallbackLabels);
const [posts, setPosts] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [category, setCategory] = React.useState(() => new URLSearchParams(window.location.search).get("category") || "");
const limit = 6;
React.useEffect(() => {
requestJson("/api/v1/predict/labels")
.then((data) => setLabels(Array.isArray(data.classes) && data.classes.length ? data.classes : fallbackLabels))
.catch(() => setLabels(fallbackLabels));
}, []);
React.useEffect(() => {
loadPosts(0, true);
}, [category]);
async function loadPosts(skip = posts.length, reset = false) {
const params = new URLSearchParams({ skip, limit });
if (category) params.set("category", category);
const data = await requestJson(`/api/v1/posts?${params.toString()}`);
setPosts((current) => (reset ? data.items : [...current, ...data.items]));
setTotal(data.total);
}
return (
<>
<section className="page-heading compact">
<p className="eyebrow">Learning Archive</p>
<h1>ASL A-Z ํ•™์Šต ๊ธฐ๋ก</h1>
<p>์ €์žฅ๋œ ๊ตฌ๋ถ„ ๊ฒฐ๊ณผ๋ฅผ A-Z ์•ŒํŒŒ๋ฒณ๋ณ„๋กœ ํ™•์ธํ•˜๊ณ , ์ œ๋ชฉ ์ˆ˜์ •๊ณผ ์‚ญ์ œ๋ฅผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
</section>
<section className="toolbar">
<Link className="button-link" href="/classify">์ƒˆ ์•ŒํŒŒ๋ฒณ ๋ถ„๋ฅ˜</Link>
<div className="alphabet-tabs" aria-label="์•ŒํŒŒ๋ฒณ ํ•„ํ„ฐ">
{["", ...labels].map((label) => (
<button
key={label || "all"}
type="button"
className={`tab-chip ${category === label ? "is-active" : ""}`}
onClick={() => {
setCategory(label);
window.history.replaceState({}, "", label ? `/board?category=${encodeURIComponent(label)}` : "/board");
}}
>
{label || "์ „์ฒด"}
</button>
))}
</div>
</section>
<section className="post-grid">
{posts.length === 0 && <p className="empty-state">์•„์ง ์ €์žฅ๋œ ASL ํ•™์Šต ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ๋ถ„๋ฅ˜ ํ™”๋ฉด์—์„œ A-Z ๊ฒฐ๊ณผ๋ฅผ ์ €์žฅํ•ด ์ฃผ์„ธ์š”.</p>}
{posts.map((post, index) => {
const state = getConfidenceState(post.confidence);
return (
<Link className="post-card" href={`/post/${post.id}`} key={post.id} style={{ "--card-delay": `${index * 0.05}s` }}>
<img src={post.image_url} alt={post.title} />
<div className="post-body">
<div className="post-letter-row">
<strong className="post-letter">{post.prediction}</strong>
<span className={`confidence-badge ${state.className}`}>{state.label}</span>
</div>
<h3>{post.title}</h3>
<div className="post-meta">
<span className="post-confidence">{(post.confidence * 100).toFixed(1)}%</span>
<span className="post-label">ASL {post.prediction}</span>
</div>
<span className="detail-link">์ƒ์„ธ ๋ณด๊ธฐ</span>
</div>
</Link>
);
})}
{posts.length < total && <button id="loadMoreBtn" type="button" onClick={() => loadPosts()}>๋” ๋ณด๊ธฐ</button>}
</section>
</>
);
}
function PostDetail({ id }) {
const [post, setPost] = React.useState(null);
const [notFound, setNotFound] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [title, setTitle] = React.useState("");
React.useEffect(() => {
requestJson(`/api/v1/posts/${id}`)
.then((data) => {
setPost(data);
setTitle(data.title);
})
.catch(() => setNotFound(true));
}, [id]);
async function saveTitle() {
const nextTitle = title.trim();
if (!nextTitle) {
alert("์ œ๋ชฉ์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.");
return;
}
const data = await requestJson(`/api/v1/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: nextTitle }),
});
setPost(data);
setTitle(data.title);
setEditing(false);
}
async function deletePost() {
if (!confirm("๊ฒŒ์‹œ๊ธ€์„ ์‚ญ์ œํ• ๊นŒ์š”?")) return;
await requestJson(`/api/v1/posts/${id}`, { method: "DELETE" });
navigate("/board");
}
if (notFound) return <div className="empty-state">ํ•™์Šต ๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.</div>;
if (!post) return <div className="empty-state">๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.</div>;
const state = getConfidenceState(post.confidence);
return (
<main className="detail-shell">
<article className="detail-card card">
<img className="detail-image" src={post.image_url} alt={post.title} />
<div className="detail-body">
<p className="eyebrow">Saved ASL Prediction</p>
<div className="detail-title-row">
<h1>{post.title}</h1>
<div className="detail-actions">
<button className="secondary-btn" type="button" onClick={() => setEditing(true)}>์ˆ˜์ •</button>
<button className="danger-btn" type="button" onClick={deletePost}><Trash2 size={18} /> ์‚ญ์ œ</button>
</div>
</div>
{editing && (
<div className="detail-title-editor">
<input className="title-input" value={title} aria-label="๊ฒŒ์‹œ๊ธ€ ์ œ๋ชฉ" onChange={(event) => setTitle(event.target.value)} />
<button type="button" onClick={saveTitle}>์ €์žฅ</button>
<button className="secondary-btn" type="button" onClick={() => setEditing(false)}>์ทจ์†Œ</button>
</div>
)}
<div className="result-row"><span>์˜ˆ์ธก ์•ŒํŒŒ๋ฒณ</span><strong>{post.prediction}</strong></div>
<div className="result-row"><span>์‹ ๋ขฐ๋„</span><strong>{(post.confidence * 100).toFixed(1)}%</strong></div>
<div className="result-row"><span>ํŒ๋‹จ ์ƒํƒœ</span><strong className={`confidence-badge ${state.className}`}>{state.label}</strong></div>
<div className="detail-link-row">
<Link className="button-link secondary-link" href={`/board?category=${encodeURIComponent(post.prediction)}`}>๊ฐ™์€ ์•ŒํŒŒ๋ฒณ ๋ณด๊ธฐ</Link>
<Link className="button-link" href="/classify">๋‹ค์‹œ ๋ถ„๋ฅ˜ํ•˜๊ธฐ</Link>
</div>
</div>
</article>
</main>
);
}
function LoveLearning() {
const [stream, setStream] = React.useState(null);
const [word, setWord] = React.useState("LOVE");
const [activeIndex, setActiveIndex] = React.useState(0);
const videoRef = React.useRef(null);
const letters = React.useMemo(() => word.toUpperCase().replace(/[^A-Z]/g, "").split(""), [word]);
const currentIndex = letters.length > 0 ? Math.min(activeIndex, letters.length - 1) : 0;
const activeLetter = letters[currentIndex] || "";
async function startPractice() {
try {
const nextStream = await navigator.mediaDevices.getUserMedia({ video: true });
setStream(nextStream);
if (videoRef.current) videoRef.current.srcObject = nextStream;
} catch {
alert("์›น์บ  ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด ์ฃผ์„ธ์š”.");
}
}
React.useEffect(() => {
setActiveIndex(0);
}, [word]);
React.useEffect(() => {
if (videoRef.current && stream) videoRef.current.srcObject = stream;
return () => stream?.getTracks().forEach((track) => track.stop());
}, [stream]);
return (
<>
<section className="page-heading">
<p className="eyebrow">Word Learning</p>
<h1>๋‹จ์–ด ASL ์•ŒํŒŒ๋ฒณ ํ•™์Šต</h1>
<p>์›ํ•˜๋Š” ์˜์–ด ๋‹จ์–ด๋ฅผ ์ž…๋ ฅํ•˜๊ณ  ์•ŒํŒŒ๋ฒณ๋ณ„ ASL ์† ๋ชจ์–‘์„ ์ˆœ์„œ๋Œ€๋กœ ํ™•์ธํ•˜์„ธ์š”.</p>
</section>
<section className="word-learning">
<div className="word-input-panel">
<label htmlFor="wordInput">ํ•™์Šตํ•  ๋‹จ์–ด</label>
<input
id="wordInput"
value={word}
maxLength={18}
placeholder="์˜ˆ: LOVE, APPLE, HELLO"
onChange={(event) => setWord(event.target.value)}
/>
</div>
<div className="love-letters">
{letters.map((letter, index) => (
<button
type="button"
className={`letter letter-choice ${index === currentIndex ? "is-active" : ""}`}
key={`${letter}-${index}`}
onClick={() => setActiveIndex(index)}
>
<h3>{letter}</h3>
<img
src={`/test-images/${letter}_test.jpg`}
alt={`${letter} ์† ๋ชจ์–‘`}
onError={(event) => { event.currentTarget.src = "/static/images/placeholder.jpg"; }}
/>
<p>{index + 1}๋ฒˆ์งธ ๊ธ€์ž</p>
</button>
))}
</div>
{letters.length > 0 ? (
<div className="practice-compare">
<div className="letter focus-letter">
<span className="letter-step">{currentIndex + 1} / {letters.length}</span>
<h3>{activeLetter}</h3>
<img
src={`/test-images/${activeLetter}_test.jpg`}
alt={`${activeLetter} ์† ๋ชจ์–‘`}
onError={(event) => { event.currentTarget.src = "/static/images/placeholder.jpg"; }}
/>
<p>{activeLetter} ์† ๋ชจ์–‘์„ ์›น์บ  ๊ฑฐ์šธ๋กœ ๋น„๊ตํ•ด๋ณด์„ธ์š”.</p>
<div className="letter-controls">
<button type="button" className="secondary-btn" onClick={() => setActiveIndex(Math.max(0, currentIndex - 1))} disabled={currentIndex === 0}>
<ChevronLeft size={18} /> ์ด์ „
</button>
<button type="button" onClick={() => setActiveIndex(Math.min(letters.length - 1, currentIndex + 1))} disabled={currentIndex === letters.length - 1}>
๋‹ค์Œ
</button>
</div>
</div>
<div className="practice">
<h3>๊ฑฐ์šธ ์—ฐ์Šต</h3>
<p>ํŒ์ • ์—†์ด ์›น์บ  ํ™”๋ฉด์œผ๋กœ ๋‚ด ์† ๋ชจ์–‘์„ ์ƒ˜ํ”Œ๊ณผ ์ง์ ‘ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.</p>
<button type="button" onClick={startPractice}><Camera size={18} /> ์›น์บ  ์ผœ๊ธฐ</button>
<video ref={videoRef} width="640" height="480" autoPlay playsInline muted />
<canvas width="640" height="480" />
</div>
</div>
) : (
<p className="empty-state">A-Z ์˜์–ด ์•ŒํŒŒ๋ฒณ์œผ๋กœ ๋‹จ์–ด๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.</p>
)}
</section>
<footer><p>&copy; 2026 ASL Learning Board</p></footer>
</>
);
}
function WebcamTest() {
const [cameras, setCameras] = React.useState([]);
const [selectedDeviceId, setSelectedDeviceId] = React.useState("");
const [status, setStatus] = React.useState("๋Œ€๊ธฐ ์ค‘");
const [stream, setStream] = React.useState(null);
const videoRef = React.useRef(null);
async function loadCameras() {
if (!navigator.mediaDevices?.enumerateDevices) {
setCameras([]);
setStatus("์žฅ์น˜ ๋ชฉ๋ก์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
const nextCameras = devices.filter((device) => device.kind === "videoinput");
setCameras(nextCameras);
if (!selectedDeviceId && nextCameras[0]) {
setSelectedDeviceId(nextCameras[0].deviceId);
}
return nextCameras;
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setStream(null);
setStatus("์ค‘์ง€๋จ");
}
async function startCamera(mode) {
stopCamera();
if (!navigator.mediaDevices?.getUserMedia) {
setStatus("์‹คํŒจ: ์ด ๋ธŒ๋ผ์šฐ์ €์—์„œ getUserMedia๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.");
return;
}
try {
const availableCameras = await loadCameras();
const useSafeMode = mode === "safe";
const video = {
width: useSafeMode ? { ideal: 640 } : { ideal: 1280 },
height: useSafeMode ? { ideal: 480 } : { ideal: 720 },
};
if (selectedDeviceId) {
video.deviceId = { exact: selectedDeviceId };
}
const nextStream = await navigator.mediaDevices.getUserMedia({ video, audio: false });
setStream(nextStream);
if (videoRef.current) {
videoRef.current.srcObject = nextStream;
}
const track = nextStream.getVideoTracks()[0];
const settings = track.getSettings();
setStatus([
"์„ฑ๊ณต: ์›น์บ  ์ŠคํŠธ๋ฆผ์ด ์—ด๋ ธ์Šต๋‹ˆ๋‹ค.",
`์นด๋ฉ”๋ผ ๊ฐœ์ˆ˜: ${availableCameras.length}`,
`์‚ฌ์šฉ ์ค‘: ${track.label || "์ด๋ฆ„ ํ™•์ธ ๋ถˆ๊ฐ€"}`,
`ํ•ด์ƒ๋„: ${settings.width || "?"} x ${settings.height || "?"}`,
].join("\n"));
await loadCameras();
} catch (error) {
setStatus([
"์‹คํŒจ: ์›น์บ ์„ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.",
`์ด๋ฆ„: ${error.name}`,
`๋ฉ”์‹œ์ง€: ${error.message}`,
"",
"ํ™•์ธ:",
"1. Windows ์นด๋ฉ”๋ผ ์•ฑ, Zoom, Teams, OBS, ๋‹ค๋ฅธ ๋ธŒ๋ผ์šฐ์ € ํƒญ์„ ๋ชจ๋‘ ์ข…๋ฃŒ",
"2. USB ์›น์บ ์ด๋ฉด ๋บ๋‹ค๊ฐ€ ๋‹ค์‹œ ์—ฐ๊ฒฐ",
"3. ์ €ํ•ด์ƒ๋„ ํ…Œ์ŠคํŠธ ๋ฒ„ํŠผ์œผ๋กœ ๋‹ค์‹œ ์‹œ๋„",
].join("\n"));
}
}
React.useEffect(() => {
loadCameras().catch((error) => setStatus(`์žฅ์น˜ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`));
return () => stream?.getTracks().forEach((track) => track.stop());
}, []);
return (
<main className="webcam-test-shell">
<section className="page-heading compact">
<p className="eyebrow">Webcam Test</p>
<h1>์›น์บ  ํ…Œ์ŠคํŠธ</h1>
</section>
<div className="toolbar webcam-test-toolbar">
<select value={selectedDeviceId} onChange={(event) => setSelectedDeviceId(event.target.value)}>
{cameras.length === 0 && <option>์นด๋ฉ”๋ผ ์—†์Œ</option>}
{cameras.map((camera, index) => (
<option value={camera.deviceId} key={camera.deviceId}>{camera.label || `์นด๋ฉ”๋ผ ${index + 1}`}</option>
))}
</select>
<button type="button" onClick={() => startCamera("normal")}>์›น์บ  ํ…Œ์ŠคํŠธ</button>
<button className="secondary-btn" type="button" onClick={() => startCamera("safe")}>์ €ํ•ด์ƒ๋„ ํ…Œ์ŠคํŠธ</button>
<button className="secondary-btn" type="button" onClick={stopCamera}>์ค‘์ง€</button>
</div>
<video className="webcam-test-video" ref={videoRef} autoPlay playsInline muted />
<pre className="webcam-test-status">{status}</pre>
</main>
);
}
function App() {
const [path, setPath] = React.useState(window.location.pathname);
React.useEffect(() => {
const onPopState = () => setPath(window.location.pathname);
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const postMatch = path.match(/^\/post\/(\d+)$/);
return (
<>
<Navbar path={path} />
{path === "/" && <Home />}
{path === "/classify" && <Classify />}
{path === "/board" && <Board />}
{path === "/learning" && <LoveLearning />}
{path === "/webcam-test" && <WebcamTest />}
{postMatch && <PostDetail id={postMatch[1]} />}
{!["/", "/classify", "/board", "/learning", "/webcam-test"].includes(path) && !postMatch && (
<div className="empty-state">
<p>ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.</p>
<button type="button" onClick={() => navigate("/")}>
<ChevronLeft size={18} /> ํ™ˆ์œผ๋กœ
</button>
</div>
)}
</>
);
}
createRoot(document.getElementById("root")).render(<App />);