Commit ·
71569a5
1
Parent(s): 24d2e26
Frontend Finalization
Browse files- frontend/app/downloads/page.tsx +3 -1
- frontend/app/globals.css +40 -12
- frontend/app/live/page.tsx +55 -36
- frontend/app/objects/page.tsx +124 -35
frontend/app/downloads/page.tsx
CHANGED
|
@@ -246,7 +246,8 @@ export default function DownloadsPage() {
|
|
| 246 |
Pretrained Model Weights
|
| 247 |
</h2>
|
| 248 |
<div className="neu-card" style={{ overflow: "hidden" }}>
|
| 249 |
-
<
|
|
|
|
| 250 |
<thead>
|
| 251 |
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
|
| 252 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>MODEL</th>
|
|
@@ -281,6 +282,7 @@ export default function DownloadsPage() {
|
|
| 281 |
))}
|
| 282 |
</tbody>
|
| 283 |
</table>
|
|
|
|
| 284 |
</div>
|
| 285 |
</div>
|
| 286 |
|
|
|
|
| 246 |
Pretrained Model Weights
|
| 247 |
</h2>
|
| 248 |
<div className="neu-card" style={{ overflow: "hidden" }}>
|
| 249 |
+
<div style={{ overflowX: "auto", WebkitOverflowScrolling: "touch" }}>
|
| 250 |
+
<table style={{ width: "100%", borderCollapse: "collapse", minWidth: "520px" }}>
|
| 251 |
<thead>
|
| 252 |
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
|
| 253 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>MODEL</th>
|
|
|
|
| 282 |
))}
|
| 283 |
</tbody>
|
| 284 |
</table>
|
| 285 |
+
</div>
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
|
frontend/app/globals.css
CHANGED
|
@@ -215,7 +215,7 @@ a { color: inherit; text-decoration: none; }
|
|
| 215 |
|
| 216 |
/* ===== ANIMATIONS ===== */
|
| 217 |
@keyframes fadeInUp {
|
| 218 |
-
from { opacity: 0; transform: translateY(
|
| 219 |
to { opacity: 1; transform: translateY(0); }
|
| 220 |
}
|
| 221 |
@keyframes fadeIn {
|
|
@@ -227,30 +227,58 @@ a { color: inherit; text-decoration: none; }
|
|
| 227 |
to { opacity: 1; transform: translateX(0); }
|
| 228 |
}
|
| 229 |
@keyframes scaleIn {
|
| 230 |
-
from { opacity: 0; transform: scale(0.
|
| 231 |
to { opacity: 1; transform: scale(1); }
|
| 232 |
}
|
| 233 |
|
| 234 |
.anim-fade-up {
|
| 235 |
-
animation: fadeInUp 0.
|
| 236 |
}
|
| 237 |
.anim-fade-in {
|
| 238 |
-
animation: fadeIn 0.
|
| 239 |
}
|
| 240 |
.anim-slide-right {
|
| 241 |
-
animation: slideInRight 0.
|
| 242 |
}
|
| 243 |
.anim-scale-in {
|
| 244 |
-
animation: scaleIn 0.
|
| 245 |
}
|
| 246 |
|
| 247 |
/* Stagger delays */
|
| 248 |
-
.delay-1 { animation-delay: 0.
|
| 249 |
-
.delay-2 { animation-delay: 0.
|
| 250 |
-
.delay-3 { animation-delay: 0.
|
| 251 |
-
.delay-4 { animation-delay: 0.
|
| 252 |
-
.delay-5 { animation-delay: 0.
|
| 253 |
-
.delay-6 { animation-delay: 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
/* ===== RESPONSIVE ===== */
|
| 256 |
@media (max-width: 768px) {
|
|
|
|
| 215 |
|
| 216 |
/* ===== ANIMATIONS ===== */
|
| 217 |
@keyframes fadeInUp {
|
| 218 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 219 |
to { opacity: 1; transform: translateY(0); }
|
| 220 |
}
|
| 221 |
@keyframes fadeIn {
|
|
|
|
| 227 |
to { opacity: 1; transform: translateX(0); }
|
| 228 |
}
|
| 229 |
@keyframes scaleIn {
|
| 230 |
+
from { opacity: 0; transform: scale(0.97); }
|
| 231 |
to { opacity: 1; transform: scale(1); }
|
| 232 |
}
|
| 233 |
|
| 234 |
.anim-fade-up {
|
| 235 |
+
animation: fadeInUp 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
| 236 |
}
|
| 237 |
.anim-fade-in {
|
| 238 |
+
animation: fadeIn 0.5s ease both;
|
| 239 |
}
|
| 240 |
.anim-slide-right {
|
| 241 |
+
animation: slideInRight 0.5s ease both;
|
| 242 |
}
|
| 243 |
.anim-scale-in {
|
| 244 |
+
animation: scaleIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
| 245 |
}
|
| 246 |
|
| 247 |
/* Stagger delays */
|
| 248 |
+
.delay-1 { animation-delay: 0.08s; }
|
| 249 |
+
.delay-2 { animation-delay: 0.16s; }
|
| 250 |
+
.delay-3 { animation-delay: 0.24s; }
|
| 251 |
+
.delay-4 { animation-delay: 0.32s; }
|
| 252 |
+
.delay-5 { animation-delay: 0.4s; }
|
| 253 |
+
.delay-6 { animation-delay: 0.48s; }
|
| 254 |
+
|
| 255 |
+
/* Expand / Collapse transitions */
|
| 256 |
+
.expand-enter {
|
| 257 |
+
max-height: 0;
|
| 258 |
+
opacity: 0;
|
| 259 |
+
overflow: hidden;
|
| 260 |
+
transition: max-height 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
| 261 |
+
opacity 0.4s ease,
|
| 262 |
+
padding 0.4s ease,
|
| 263 |
+
margin 0.4s ease;
|
| 264 |
+
}
|
| 265 |
+
.expand-enter-active {
|
| 266 |
+
max-height: 200px;
|
| 267 |
+
opacity: 1;
|
| 268 |
+
}
|
| 269 |
+
.expand-collapse {
|
| 270 |
+
max-height: 200px;
|
| 271 |
+
opacity: 1;
|
| 272 |
+
overflow: hidden;
|
| 273 |
+
transition: max-height 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
| 274 |
+
opacity 0.4s ease,
|
| 275 |
+
padding 0.4s ease,
|
| 276 |
+
margin 0.4s ease;
|
| 277 |
+
}
|
| 278 |
+
.expand-collapse.collapsing {
|
| 279 |
+
max-height: 0;
|
| 280 |
+
opacity: 0;
|
| 281 |
+
}
|
| 282 |
|
| 283 |
/* ===== RESPONSIVE ===== */
|
| 284 |
@media (max-width: 768px) {
|
frontend/app/live/page.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export default function LivePage() {
|
|
| 13 |
const [camActive, setCamActive] = useState(false);
|
| 14 |
const [camSupported, setCamSupported] = useState(true);
|
| 15 |
const [lock, setLock] = useState(false);
|
|
|
|
| 16 |
const [mounted, setMounted] = useState(false);
|
| 17 |
|
| 18 |
const imgRef = useRef<HTMLImageElement>(null);
|
|
@@ -32,7 +33,7 @@ export default function LivePage() {
|
|
| 32 |
fd.append("file", file);
|
| 33 |
fd.append("mode", lock ? "combined" : "open_vocabulary");
|
| 34 |
fd.append("vocabulary", "person, cup, laptop, phone, bottle");
|
| 35 |
-
if (lock) fd.append("lock_target",
|
| 36 |
const r = await fetch("/backend-api/detect", { method: "POST", body: fd });
|
| 37 |
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 38 |
const d = await r.json();
|
|
@@ -196,47 +197,66 @@ export default function LivePage() {
|
|
| 196 |
)}
|
| 197 |
|
| 198 |
{/* Lock mode toggle */}
|
| 199 |
-
<div className={`anim-fade-up delay-3 neu-card`} style={{ padding: "
|
| 200 |
-
<div
|
| 201 |
-
onClick={() => setLock(!lock)}
|
| 202 |
-
style={{
|
| 203 |
-
width: "44px",
|
| 204 |
-
height: "24px",
|
| 205 |
-
borderRadius: "12px",
|
| 206 |
-
background: lock ? "#1E90FF" : "#cbd5e1",
|
| 207 |
-
cursor: "pointer",
|
| 208 |
-
position: "relative",
|
| 209 |
-
transition: "background 0.3s ease",
|
| 210 |
-
flexShrink: 0,
|
| 211 |
-
}}
|
| 212 |
-
>
|
| 213 |
<div
|
|
|
|
| 214 |
style={{
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
transition: "left 0.3s ease",
|
| 224 |
}}
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
</div>
|
| 236 |
|
| 237 |
{/* Result */}
|
| 238 |
{(result || dets.length > 0) && (
|
| 239 |
-
<div
|
| 240 |
<div className="neu-card" style={{ padding: "24px" }}>
|
| 241 |
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
| 242 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c" }}>Detection Results</h3>
|
|
@@ -255,9 +275,8 @@ export default function LivePage() {
|
|
| 255 |
{dets.map((d, i) => (
|
| 256 |
<div
|
| 257 |
key={i}
|
| 258 |
-
className="anim-slide-right"
|
| 259 |
style={{
|
| 260 |
-
|
| 261 |
display: "flex",
|
| 262 |
justifyContent: "space-between",
|
| 263 |
alignItems: "center",
|
|
|
|
| 13 |
const [camActive, setCamActive] = useState(false);
|
| 14 |
const [camSupported, setCamSupported] = useState(true);
|
| 15 |
const [lock, setLock] = useState(false);
|
| 16 |
+
const [lockTarget, setLockTarget] = useState("");
|
| 17 |
const [mounted, setMounted] = useState(false);
|
| 18 |
|
| 19 |
const imgRef = useRef<HTMLImageElement>(null);
|
|
|
|
| 33 |
fd.append("file", file);
|
| 34 |
fd.append("mode", lock ? "combined" : "open_vocabulary");
|
| 35 |
fd.append("vocabulary", "person, cup, laptop, phone, bottle");
|
| 36 |
+
if (lock && lockTarget.trim()) fd.append("lock_target", lockTarget.trim());
|
| 37 |
const r = await fetch("/backend-api/detect", { method: "POST", body: fd });
|
| 38 |
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
| 39 |
const d = await r.json();
|
|
|
|
| 197 |
)}
|
| 198 |
|
| 199 |
{/* Lock mode toggle */}
|
| 200 |
+
<div className={`anim-fade-up delay-3 neu-card`} style={{ padding: "20px 24px", marginBottom: "24px" }}>
|
| 201 |
+
<div style={{ display: "flex", alignItems: "center", gap: "16px", marginBottom: lock ? "16px" : 0 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
<div
|
| 203 |
+
onClick={() => setLock(!lock)}
|
| 204 |
style={{
|
| 205 |
+
width: "44px",
|
| 206 |
+
height: "24px",
|
| 207 |
+
borderRadius: "12px",
|
| 208 |
+
background: lock ? "#1E90FF" : "#cbd5e1",
|
| 209 |
+
cursor: "pointer",
|
| 210 |
+
position: "relative",
|
| 211 |
+
transition: "background 0.3s ease",
|
| 212 |
+
flexShrink: 0,
|
|
|
|
| 213 |
}}
|
| 214 |
+
>
|
| 215 |
+
<div
|
| 216 |
+
style={{
|
| 217 |
+
position: "absolute",
|
| 218 |
+
top: "2px",
|
| 219 |
+
left: lock ? "22px" : "2px",
|
| 220 |
+
width: "20px",
|
| 221 |
+
height: "20px",
|
| 222 |
+
borderRadius: "50%",
|
| 223 |
+
background: "#fff",
|
| 224 |
+
boxShadow: "0 1px 4px rgba(0,0,0,0.2)",
|
| 225 |
+
transition: "left 0.3s ease",
|
| 226 |
+
}}
|
| 227 |
+
/>
|
| 228 |
+
</div>
|
| 229 |
+
<div>
|
| 230 |
+
<p style={{ fontWeight: 700, fontSize: "0.85rem", color: "#1a202c" }}>
|
| 231 |
+
Lock Mode {lock ? "ON" : "OFF"}
|
| 232 |
+
</p>
|
| 233 |
+
<p style={{ fontSize: "0.75rem", color: "#64748b" }}>
|
| 234 |
+
{lock ? "Using combined pipeline with target lock" : "Standard multi-pipeline detection"}
|
| 235 |
+
</p>
|
| 236 |
+
</div>
|
| 237 |
</div>
|
| 238 |
+
|
| 239 |
+
{lock && (
|
| 240 |
+
<div style={{ overflow: "hidden", transition: "all 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94)" }}>
|
| 241 |
+
<div style={{ paddingBottom: "4px" }}>
|
| 242 |
+
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>LOCK TARGET</label>
|
| 243 |
+
<input
|
| 244 |
+
className="neu-input"
|
| 245 |
+
placeholder="e.g. cup, laptop, my coffee cup, blue screwdriver"
|
| 246 |
+
value={lockTarget}
|
| 247 |
+
onChange={(e) => setLockTarget(e.target.value)}
|
| 248 |
+
/>
|
| 249 |
+
<p style={{ fontSize: "0.7rem", color: "#94a3b8", marginTop: "6px" }}>
|
| 250 |
+
Enter the object name to track. Works with both known classes (80 COCO) and your custom trained objects.
|
| 251 |
+
</p>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
)}
|
| 255 |
</div>
|
| 256 |
|
| 257 |
{/* Result */}
|
| 258 |
{(result || dets.length > 0) && (
|
| 259 |
+
<div style={{ animation: "scaleIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both", marginBottom: "32px" }}>
|
| 260 |
<div className="neu-card" style={{ padding: "24px" }}>
|
| 261 |
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
| 262 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c" }}>Detection Results</h3>
|
|
|
|
| 275 |
{dets.map((d, i) => (
|
| 276 |
<div
|
| 277 |
key={i}
|
|
|
|
| 278 |
style={{
|
| 279 |
+
animation: `slideInRight 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${i * 0.07}s both`,
|
| 280 |
display: "flex",
|
| 281 |
justifyContent: "space-between",
|
| 282 |
alignItems: "center",
|
frontend/app/objects/page.tsx
CHANGED
|
@@ -1,40 +1,59 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState, useEffect } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
|
| 6 |
-
type Obj = { id: string;
|
| 7 |
|
| 8 |
export default function ObjectsPage() {
|
| 9 |
const [objects, setObjects] = useState<Obj[]>([]);
|
| 10 |
-
const [
|
| 11 |
-
const [
|
|
|
|
| 12 |
const [loading, setLoading] = useState(false);
|
| 13 |
const [mounted, setMounted] = useState(false);
|
|
|
|
| 14 |
|
| 15 |
const fetchObjects = async () => {
|
| 16 |
try {
|
| 17 |
const r = await fetch("/backend-api/objects");
|
| 18 |
if (r.ok) {
|
| 19 |
const d = await r.json();
|
| 20 |
-
setObjects(d.objects ?? []);
|
| 21 |
}
|
| 22 |
} catch { /* offline */ }
|
| 23 |
};
|
| 24 |
|
| 25 |
useEffect(() => { setMounted(true); fetchObjects(); }, []);
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const teach = async () => {
|
| 28 |
-
if (!
|
| 29 |
setLoading(true);
|
| 30 |
try {
|
| 31 |
const fd = new FormData();
|
| 32 |
-
fd.append("
|
| 33 |
-
fd.append("
|
| 34 |
const r = await fetch("/backend-api/objects", { method: "POST", body: fd });
|
| 35 |
if (r.ok) {
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
await fetchObjects();
|
| 39 |
} else {
|
| 40 |
const d = await r.json().catch(() => null);
|
|
@@ -56,6 +75,8 @@ export default function ObjectsPage() {
|
|
| 56 |
} catch { /* ignore */ }
|
| 57 |
};
|
| 58 |
|
|
|
|
|
|
|
| 59 |
return (
|
| 60 |
<div style={{ maxWidth: "900px", margin: "0 auto", padding: "40px 24px" }}>
|
| 61 |
<div className="anim-fade-up" style={{ marginBottom: "32px" }}>
|
|
@@ -68,61 +89,126 @@ export default function ObjectsPage() {
|
|
| 68 |
</div>
|
| 69 |
|
| 70 |
{/* Teach form */}
|
| 71 |
-
<div className=
|
| 72 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c", marginBottom: "20px", display: "flex", alignItems: "center", gap: "8px" }}>
|
| 73 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 74 |
Teach New Object
|
| 75 |
</h3>
|
| 76 |
|
| 77 |
-
<div style={{ display: "grid", gap: "16px", maxWidth: "
|
| 78 |
<div>
|
| 79 |
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>OBJECT NAME</label>
|
| 80 |
<input
|
| 81 |
className="neu-input"
|
| 82 |
placeholder="e.g. My Cup, Blue Screwdriver, Red Mouse"
|
| 83 |
-
value={
|
| 84 |
-
onChange={(e) =>
|
| 85 |
/>
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div>
|
| 89 |
-
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>
|
|
|
|
|
|
|
| 90 |
{mounted && (
|
| 91 |
-
<
|
| 92 |
-
<
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
)}
|
| 104 |
</div>
|
| 105 |
|
| 106 |
<button
|
| 107 |
onClick={teach}
|
| 108 |
-
disabled={!
|
| 109 |
className="neu-btn-primary"
|
| 110 |
style={{
|
| 111 |
padding: "14px 28px",
|
| 112 |
alignSelf: "flex-start",
|
| 113 |
-
opacity:
|
| 114 |
-
cursor:
|
| 115 |
}}
|
| 116 |
>
|
| 117 |
{loading ? (
|
| 118 |
<>
|
| 119 |
<div style={{ width: "16px", height: "16px", border: "2px solid #fff", borderTopColor: "transparent", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
|
| 120 |
-
Processing...
|
| 121 |
</>
|
| 122 |
) : (
|
| 123 |
<>
|
| 124 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 125 |
-
Teach Object
|
| 126 |
</>
|
| 127 |
)}
|
| 128 |
</button>
|
|
@@ -131,7 +217,7 @@ export default function ObjectsPage() {
|
|
| 131 |
</div>
|
| 132 |
|
| 133 |
{/* Saved objects */}
|
| 134 |
-
<div className=
|
| 135 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c", marginBottom: "16px", display: "flex", alignItems: "center", gap: "8px" }}>
|
| 136 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
| 137 |
Saved Objects
|
|
@@ -150,7 +236,7 @@ export default function ObjectsPage() {
|
|
| 150 |
<thead>
|
| 151 |
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
|
| 152 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>LABEL</th>
|
| 153 |
-
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>
|
| 154 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "right", letterSpacing: "0.1em" }}>ACTIONS</th>
|
| 155 |
</tr>
|
| 156 |
</thead>
|
|
@@ -164,10 +250,13 @@ export default function ObjectsPage() {
|
|
| 164 |
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
| 165 |
>
|
| 166 |
<td style={{ padding: "14px 20px" }}>
|
| 167 |
-
<span style={{ fontWeight: 700, color: "#1a202c", fontSize: "0.9rem" }}>{obj.
|
|
|
|
|
|
|
|
|
|
| 168 |
</td>
|
| 169 |
<td style={{ padding: "14px 20px", color: "#94a3b8", fontSize: "0.8rem" }}>
|
| 170 |
-
{obj.
|
| 171 |
</td>
|
| 172 |
<td style={{ padding: "14px 20px", textAlign: "right" }}>
|
| 173 |
<button
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useEffect, useRef } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
|
| 6 |
+
type Obj = { id: string; name: string; category?: string; images?: number; image_count?: number };
|
| 7 |
|
| 8 |
export default function ObjectsPage() {
|
| 9 |
const [objects, setObjects] = useState<Obj[]>([]);
|
| 10 |
+
const [name, setName] = useState("");
|
| 11 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 12 |
+
const [previews, setPreviews] = useState<string[]>([]);
|
| 13 |
const [loading, setLoading] = useState(false);
|
| 14 |
const [mounted, setMounted] = useState(false);
|
| 15 |
+
const fileRef = useRef<HTMLInputElement>(null);
|
| 16 |
|
| 17 |
const fetchObjects = async () => {
|
| 18 |
try {
|
| 19 |
const r = await fetch("/backend-api/objects");
|
| 20 |
if (r.ok) {
|
| 21 |
const d = await r.json();
|
| 22 |
+
setObjects(d.objects ?? d ?? []);
|
| 23 |
}
|
| 24 |
} catch { /* offline */ }
|
| 25 |
};
|
| 26 |
|
| 27 |
useEffect(() => { setMounted(true); fetchObjects(); }, []);
|
| 28 |
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
const urls = files.map((f) => URL.createObjectURL(f));
|
| 31 |
+
setPreviews(urls);
|
| 32 |
+
return () => urls.forEach((u) => URL.revokeObjectURL(u));
|
| 33 |
+
}, [files]);
|
| 34 |
+
|
| 35 |
+
const handleFiles = (input: FileList | null) => {
|
| 36 |
+
if (!input) return;
|
| 37 |
+
const newFiles = Array.from(input).filter((f) => f.type.startsWith("image/"));
|
| 38 |
+
setFiles((prev) => [...prev, ...newFiles]);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const removeFile = (index: number) => {
|
| 42 |
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
const teach = async () => {
|
| 46 |
+
if (!name.trim() || files.length === 0) return;
|
| 47 |
setLoading(true);
|
| 48 |
try {
|
| 49 |
const fd = new FormData();
|
| 50 |
+
fd.append("name", name.trim());
|
| 51 |
+
files.forEach((f) => fd.append("files", f));
|
| 52 |
const r = await fetch("/backend-api/objects", { method: "POST", body: fd });
|
| 53 |
if (r.ok) {
|
| 54 |
+
setName("");
|
| 55 |
+
setFiles([]);
|
| 56 |
+
if (fileRef.current) fileRef.current.value = "";
|
| 57 |
await fetchObjects();
|
| 58 |
} else {
|
| 59 |
const d = await r.json().catch(() => null);
|
|
|
|
| 75 |
} catch { /* ignore */ }
|
| 76 |
};
|
| 77 |
|
| 78 |
+
const ready = name.trim() && files.length > 0 && !loading;
|
| 79 |
+
|
| 80 |
return (
|
| 81 |
<div style={{ maxWidth: "900px", margin: "0 auto", padding: "40px 24px" }}>
|
| 82 |
<div className="anim-fade-up" style={{ marginBottom: "32px" }}>
|
|
|
|
| 89 |
</div>
|
| 90 |
|
| 91 |
{/* Teach form */}
|
| 92 |
+
<div className="anim-fade-up delay-1 neu-card" style={{ padding: "32px", marginBottom: "32px" }}>
|
| 93 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c", marginBottom: "20px", display: "flex", alignItems: "center", gap: "8px" }}>
|
| 94 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 95 |
Teach New Object
|
| 96 |
</h3>
|
| 97 |
|
| 98 |
+
<div style={{ display: "grid", gap: "16px", maxWidth: "600px" }}>
|
| 99 |
<div>
|
| 100 |
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>OBJECT NAME</label>
|
| 101 |
<input
|
| 102 |
className="neu-input"
|
| 103 |
placeholder="e.g. My Cup, Blue Screwdriver, Red Mouse"
|
| 104 |
+
value={name}
|
| 105 |
+
onChange={(e) => setName(e.target.value)}
|
| 106 |
/>
|
| 107 |
</div>
|
| 108 |
|
| 109 |
<div>
|
| 110 |
+
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>
|
| 111 |
+
REFERENCE IMAGES {files.length > 0 && <span style={{ color: "#1E90FF" }}>({files.length} selected)</span>}
|
| 112 |
+
</label>
|
| 113 |
{mounted && (
|
| 114 |
+
<>
|
| 115 |
+
<div
|
| 116 |
+
className="neu-file-input"
|
| 117 |
+
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
| 118 |
+
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleFiles(e.dataTransfer.files); }}
|
| 119 |
+
>
|
| 120 |
+
<input
|
| 121 |
+
ref={fileRef}
|
| 122 |
+
type="file"
|
| 123 |
+
accept="image/*"
|
| 124 |
+
multiple
|
| 125 |
+
onChange={(e) => { handleFiles(e.target.files); if (fileRef.current) fileRef.current.value = ""; }}
|
| 126 |
+
disabled={loading}
|
| 127 |
+
/>
|
| 128 |
+
<div className="neu-file-input-label" style={{ justifyContent: "flex-start", gap: "12px" }}>
|
| 129 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
| 130 |
+
{files.length === 0 ? "Choose Images (multiple supported)" : `${files.length} image${files.length > 1 ? "s" : ""} selected — click to add more`}
|
| 131 |
+
</div>
|
| 132 |
</div>
|
| 133 |
+
|
| 134 |
+
{/* Image previews */}
|
| 135 |
+
{files.length > 0 && (
|
| 136 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px", marginTop: "12px" }}>
|
| 137 |
+
{previews.map((url, i) => (
|
| 138 |
+
<div
|
| 139 |
+
key={i}
|
| 140 |
+
style={{
|
| 141 |
+
position: "relative",
|
| 142 |
+
width: "80px",
|
| 143 |
+
height: "80px",
|
| 144 |
+
borderRadius: "10px",
|
| 145 |
+
overflow: "hidden",
|
| 146 |
+
boxShadow: "var(--neu-shadow-sm)",
|
| 147 |
+
border: "1px solid rgba(30,144,255,0.15)",
|
| 148 |
+
animation: "scaleIn 0.25s ease both",
|
| 149 |
+
animationDelay: `${i * 0.03}s`,
|
| 150 |
+
}}
|
| 151 |
+
>
|
| 152 |
+
<img
|
| 153 |
+
src={url}
|
| 154 |
+
alt={`Preview ${i + 1}`}
|
| 155 |
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
| 156 |
+
/>
|
| 157 |
+
<button
|
| 158 |
+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); removeFile(i); }}
|
| 159 |
+
style={{
|
| 160 |
+
position: "absolute",
|
| 161 |
+
top: "3px",
|
| 162 |
+
right: "3px",
|
| 163 |
+
width: "18px",
|
| 164 |
+
height: "18px",
|
| 165 |
+
borderRadius: "50%",
|
| 166 |
+
background: "rgba(239,68,68,0.85)",
|
| 167 |
+
color: "#fff",
|
| 168 |
+
border: "none",
|
| 169 |
+
cursor: "pointer",
|
| 170 |
+
display: "flex",
|
| 171 |
+
alignItems: "center",
|
| 172 |
+
justifyContent: "center",
|
| 173 |
+
fontSize: "11px",
|
| 174 |
+
lineHeight: 1,
|
| 175 |
+
padding: 0,
|
| 176 |
+
}}
|
| 177 |
+
>
|
| 178 |
+
x
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
))}
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
|
| 185 |
+
<p style={{ fontSize: "0.7rem", color: "#94a3b8", marginTop: "8px" }}>
|
| 186 |
+
More images = better recognition. Add different angles and lighting.
|
| 187 |
+
</p>
|
| 188 |
+
</>
|
| 189 |
)}
|
| 190 |
</div>
|
| 191 |
|
| 192 |
<button
|
| 193 |
onClick={teach}
|
| 194 |
+
disabled={!ready}
|
| 195 |
className="neu-btn-primary"
|
| 196 |
style={{
|
| 197 |
padding: "14px 28px",
|
| 198 |
alignSelf: "flex-start",
|
| 199 |
+
opacity: ready ? 1 : 0.5,
|
| 200 |
+
cursor: ready ? "pointer" : "not-allowed",
|
| 201 |
}}
|
| 202 |
>
|
| 203 |
{loading ? (
|
| 204 |
<>
|
| 205 |
<div style={{ width: "16px", height: "16px", border: "2px solid #fff", borderTopColor: "transparent", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
|
| 206 |
+
Processing {files.length} image{files.length > 1 ? "s" : ""}...
|
| 207 |
</>
|
| 208 |
) : (
|
| 209 |
<>
|
| 210 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 211 |
+
Teach Object ({files.length} image{files.length > 1 ? "s" : ""})
|
| 212 |
</>
|
| 213 |
)}
|
| 214 |
</button>
|
|
|
|
| 217 |
</div>
|
| 218 |
|
| 219 |
{/* Saved objects */}
|
| 220 |
+
<div className="anim-fade-up delay-2">
|
| 221 |
<h3 style={{ fontSize: "1rem", fontWeight: 700, color: "#1a202c", marginBottom: "16px", display: "flex", alignItems: "center", gap: "8px" }}>
|
| 222 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
| 223 |
Saved Objects
|
|
|
|
| 236 |
<thead>
|
| 237 |
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.06)" }}>
|
| 238 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>LABEL</th>
|
| 239 |
+
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "left", letterSpacing: "0.1em" }}>IMAGES</th>
|
| 240 |
<th style={{ padding: "12px 20px", fontSize: "0.7rem", fontWeight: 700, color: "#94a3b8", textAlign: "right", letterSpacing: "0.1em" }}>ACTIONS</th>
|
| 241 |
</tr>
|
| 242 |
</thead>
|
|
|
|
| 250 |
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
| 251 |
>
|
| 252 |
<td style={{ padding: "14px 20px" }}>
|
| 253 |
+
<span style={{ fontWeight: 700, color: "#1a202c", fontSize: "0.9rem" }}>{obj.name}</span>
|
| 254 |
+
{obj.category && obj.category !== "custom" && (
|
| 255 |
+
<span style={{ marginLeft: "8px", fontSize: "0.65rem", color: "#1E90FF", background: "rgba(30,144,255,0.08)", padding: "2px 8px", borderRadius: "9999px" }}>{obj.category}</span>
|
| 256 |
+
)}
|
| 257 |
</td>
|
| 258 |
<td style={{ padding: "14px 20px", color: "#94a3b8", fontSize: "0.8rem" }}>
|
| 259 |
+
{obj.image_count ?? obj.images ?? 0} image{((obj.image_count ?? obj.images ?? 0)) !== 1 ? "s" : ""}
|
| 260 |
</td>
|
| 261 |
<td style={{ padding: "14px 20px", textAlign: "right" }}>
|
| 262 |
<button
|