From 38cbdc22e126ebc4376a52d43faa116ac6de3b42 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Sat, 14 Feb 2026 11:12:39 +0100 Subject: [PATCH] feat: add span label types admin page with seed functionality --- src/app/span-label-types/page.tsx | 401 ++++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 src/app/span-label-types/page.tsx diff --git a/src/app/span-label-types/page.tsx b/src/app/span-label-types/page.tsx new file mode 100644 index 0000000..9e82879 --- /dev/null +++ b/src/app/span-label-types/page.tsx @@ -0,0 +1,401 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type SpanLabelType = { + id: number; + name: string; + display_name: string; + color: string; + hotkey: string | null; + is_active: number; + sort_order: number; + created_at: number; +}; + +export default function SpanLabelTypesPage() { + const [types, setTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + name: '', + display_name: '', + color: '#2196F3', + hotkey: '', + sort_order: 0, + }); + + const fetchTypes = async () => { + try { + const res = await fetch('/api/span-label-types'); + if (res.ok) { + const data = await res.json(); + setTypes(data); + } + } catch (error) { + console.error('Error fetching span label types:', error); + } finally { + setLoading(false); + } + }; + + const seedDefaultTypes = async () => { + const defaultTypes = [ + { name: 'bull_flag', display_name: 'Bull Flag', color: '#4CAF50', hotkey: '1', sort_order: 1 }, + { name: 'bear_flag', display_name: 'Bear Flag', color: '#F44336', hotkey: '2', sort_order: 2 }, + { name: 'triangle', display_name: 'Triangle', color: '#FF9800', hotkey: '3', sort_order: 3 }, + { name: 'wedge', display_name: 'Wedge', color: '#9C27B0', hotkey: '4', sort_order: 4 }, + { name: 'channel', display_name: 'Channel', color: '#2196F3', hotkey: '5', sort_order: 5 }, + { name: 'consolidation', display_name: 'Consolidation', color: '#607D8B', hotkey: '6', sort_order: 6 }, + ]; + + try { + for (const type of defaultTypes) { + await fetch('/api/span-label-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(type), + }); + } + fetchTypes(); + } catch (error) { + console.error('Error seeding types:', error); + } + }; + + useEffect(() => { + fetchTypes(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (editingId !== null) { + // Update existing type + const res = await fetch(`/api/span-label-types/${editingId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (res.ok) { + setEditingId(null); + resetForm(); + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to update type'); + } + } else { + // Create new type + const res = await fetch('/api/span-label-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (res.ok) { + setShowAddForm(false); + resetForm(); + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to create type'); + } + } + } catch (error) { + console.error('Error saving type:', error); + alert('Failed to save type'); + } + }; + + const handleEdit = (type: SpanLabelType) => { + setFormData({ + name: type.name, + display_name: type.display_name, + color: type.color, + hotkey: type.hotkey || '', + sort_order: type.sort_order, + }); + setEditingId(type.id); + setShowAddForm(true); + }; + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this span label type?')) { + return; + } + + try { + const res = await fetch(`/api/span-label-types/${id}`, { + method: 'DELETE', + }); + + if (res.ok) { + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to delete type'); + } + } catch (error) { + console.error('Error deleting type:', error); + alert('Failed to delete type'); + } + }; + + const resetForm = () => { + setFormData({ + name: '', + display_name: '', + color: '#2196F3', + hotkey: '', + sort_order: 0, + }); + setEditingId(null); + }; + + const handleCancel = () => { + setShowAddForm(false); + resetForm(); + }; + + if (loading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+
+

Span Label Types

+

Manage pattern labels for span annotations

+
+
+ + {types.length === 0 && ( + + )} + {!showAddForm && ( + + )} +
+
+ + {showAddForm && ( +
+

+ {editingId !== null ? 'Edit Span Label Type' : 'Add New Span Label Type'} +

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., bull_flag" + required + disabled={editingId !== null} + /> +

+ Used internally (cannot be changed after creation) +

+
+ +
+ + setFormData({ ...formData, display_name: e.target.value })} + placeholder="e.g., Bull Flag" + required + /> +
+ +
+ +
+ setFormData({ ...formData, color: e.target.value })} + className="w-20" + required + /> + setFormData({ ...formData, color: e.target.value })} + placeholder="#2196F3" + /> +
+
+ +
+ + setFormData({ ...formData, hotkey: e.target.value })} + placeholder="e.g., 1, 2, 3" + maxLength={1} + /> +

+ Single key for quick assignment (optional) +

+
+ +
+ + setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })} + placeholder="0" + /> +

+ Lower numbers appear first +

+
+
+ +
+ + +
+
+
+ )} + +
+ + + + + + + + + + + + + + {types.length === 0 ? ( + + + + ) : ( + types.map((type) => ( + + + + + + + + + + )) + )} + +
+ Name + + Display Name + + Color + + Hotkey + + Sort Order + + Status + + Actions +
+ No span label types found. Click "Seed Default Types" to get started. +
+ {type.name} + + {type.display_name} + +
+
+ {type.color} +
+
+ {type.hotkey || '-'} + + {type.sort_order} + + + {type.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
+
+
+
+ ); +}