Spaces:
Sleeping
Sleeping
| 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>© 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 />); | |