muhammadpriv001 commited on
Commit
71569a5
·
1 Parent(s): 24d2e26

Frontend Finalization

Browse files
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
- <table style={{ width: "100%", borderCollapse: "collapse" }}>
 
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(24px); }
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.95); }
231
  to { opacity: 1; transform: scale(1); }
232
  }
233
 
234
  .anim-fade-up {
235
- animation: fadeInUp 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
236
  }
237
  .anim-fade-in {
238
- animation: fadeIn 0.4s ease both;
239
  }
240
  .anim-slide-right {
241
- animation: slideInRight 0.4s ease both;
242
  }
243
  .anim-scale-in {
244
- animation: scaleIn 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
245
  }
246
 
247
  /* Stagger delays */
248
- .delay-1 { animation-delay: 0.05s; }
249
- .delay-2 { animation-delay: 0.1s; }
250
- .delay-3 { animation-delay: 0.15s; }
251
- .delay-4 { animation-delay: 0.2s; }
252
- .delay-5 { animation-delay: 0.25s; }
253
- .delay-6 { animation-delay: 0.3s; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", "person");
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: "16px 24px", display: "flex", alignItems: "center", gap: "16px", marginBottom: "24px" }}>
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
- position: "absolute",
216
- top: "2px",
217
- left: lock ? "22px" : "2px",
218
- width: "20px",
219
- height: "20px",
220
- borderRadius: "50%",
221
- background: "#fff",
222
- boxShadow: "0 1px 4px rgba(0,0,0,0.2)",
223
- transition: "left 0.3s ease",
224
  }}
225
- />
226
- </div>
227
- <div>
228
- <p style={{ fontWeight: 700, fontSize: "0.85rem", color: "#1a202c" }}>
229
- Lock Mode {lock ? "ON" : "OFF"}
230
- </p>
231
- <p style={{ fontSize: "0.75rem", color: "#64748b" }}>
232
- {lock ? "Using combined pipeline with target lock" : "Standard multi-pipeline detection"}
233
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  </div>
236
 
237
  {/* Result */}
238
  {(result || dets.length > 0) && (
239
- <div className="anim-scale-in" style={{ marginBottom: "32px" }}>
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
- animationDelay: `${i * 0.05}s`,
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; label: string; created_at: string; embedding?: number[] };
7
 
8
  export default function ObjectsPage() {
9
  const [objects, setObjects] = useState<Obj[]>([]);
10
- const [label, setLabel] = useState("");
11
- const [file, setFile] = useState<File | null>(null);
 
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 (!label.trim() || !file) return;
29
  setLoading(true);
30
  try {
31
  const fd = new FormData();
32
- fd.append("label", label.trim());
33
- fd.append("file", file);
34
  const r = await fetch("/backend-api/objects", { method: "POST", body: fd });
35
  if (r.ok) {
36
- setLabel("");
37
- setFile(null);
 
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={`anim-fade-up delay-1 neu-card`} style={{ padding: "32px", marginBottom: "32px" }}>
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: "500px" }}>
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={label}
84
- onChange={(e) => setLabel(e.target.value)}
85
  />
86
  </div>
87
 
88
  <div>
89
- <label style={{ display: "block", fontSize: "0.75rem", fontWeight: 700, color: "#64748b", marginBottom: "6px", letterSpacing: "0.05em" }}>REFERENCE IMAGE</label>
 
 
90
  {mounted && (
91
- <div className="neu-file-input">
92
- <input
93
- type="file"
94
- accept="image/*"
95
- onChange={(e) => setFile(e.target.files?.[0] ?? null)}
96
- disabled={loading}
97
- />
98
- <div className="neu-file-input-label" style={{ justifyContent: "flex-start", gap: "12px" }}>
99
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
100
- {file ? file.name : "Choose Reference Image"}
 
 
 
 
 
 
 
 
101
  </div>
102
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  )}
104
  </div>
105
 
106
  <button
107
  onClick={teach}
108
- disabled={!label.trim() || !file || loading}
109
  className="neu-btn-primary"
110
  style={{
111
  padding: "14px 28px",
112
  alignSelf: "flex-start",
113
- opacity: !label.trim() || !file || loading ? 0.5 : 1,
114
- cursor: !label.trim() || !file || loading ? "not-allowed" : "pointer",
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={`anim-fade-up delay-2`}>
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" }}>CREATED</th>
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.label}</span>
 
 
 
168
  </td>
169
  <td style={{ padding: "14px 20px", color: "#94a3b8", fontSize: "0.8rem" }}>
170
- {obj.created_at ? new Date(obj.created_at).toLocaleDateString() : "—"}
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