ai starter implementation

This commit is contained in:
Garret Patti
2026-04-12 15:39:48 -04:00
parent 0238dbda7a
commit 732e9134c3
5 changed files with 212 additions and 5 deletions

View File

@@ -321,6 +321,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
entry={entry}
onOpen={handleEntry}
onTag={handleTagEntry}
onAiTag={async (e) => {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
@@ -375,6 +389,19 @@ export default function MixedView({ libraryId, initialPath }: Props) {
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
/>
)}
@@ -424,7 +451,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)
}
function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean> }) {
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void> }) {
type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error'
@@ -437,6 +464,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
const [entryRenameName, setEntryRenameName] = useState('')
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
useEffect(() => {
if (!menuOpen) return
@@ -548,10 +577,10 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
</button>
{/* Kebab menu — top-right, shown on hover */}
{(onDelete || onRename) && (
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null) }}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label="More options"
@@ -563,6 +592,26 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
>
{onAiTag && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setAiTagging(true)
setAiTagError(null)
onAiTag(entry)
.catch((err) => setAiTagError(err instanceof Error ? err.message : 'AI tagging failed'))
.finally(() => setAiTagging(false))
}}
disabled={aiTagging}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
AI Tag
</button>
)}
{onRename && (
<button
onClick={(e) => {
@@ -596,6 +645,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
</div>
)}
{/* AI tagging status overlay */}
{(aiTagging || aiTagError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: aiTagError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: aiTagError ? '#fca5a5' : 'var(--text-secondary)' }}>
{aiTagError ?? 'AI Tagging…'}
</span>
{aiTagError && (
<button
onClick={() => setAiTagError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Delete confirmation overlay */}
{confirming && (
<div