(null)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -71,6 +74,43 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
🏷
)}
+ {onAiTag && (
+ {
+ e.stopPropagation()
+ setAiTagging(true)
+ setAiTagError(null)
+ try {
+ await onAiTag()
+ onTagsChanged?.()
+ } catch (err) {
+ setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
+ setTimeout(() => setAiTagError(null), 4000)
+ } finally {
+ setAiTagging(false)
+ }
+ }}
+ disabled={aiTagging}
+ className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
+ style={{
+ backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
+ color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
+ fontSize: '1.5rem',
+ }}
+ onMouseEnter={(e) => {
+ if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
+ }}
+ onMouseLeave={(e) => {
+ if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
+ }}
+ aria-label="AI Tag this image"
+ title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
+ >
+ {aiTagging ? (
+ âźł
+ ) : '✨'}
+
+ )}
{
+ 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 }) {
+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; onAiTag?: (e: FileEntry) => Promise }) {
type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState(
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(null)
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
+ const [aiTagging, setAiTagging] = useState(false)
+ const [aiTagError, setAiTagError] = useState(null)
useEffect(() => {
if (!menuOpen) return
@@ -548,10 +577,10 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
{/* Kebab menu — top-right, shown on hover */}
- {(onDelete || onRename) && (
+ {(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
{ 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' && (
+ {
+ 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
+
+ )}
{onRename && (
{
@@ -596,6 +645,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
)}
+ {/* AI tagging status overlay */}
+ {(aiTagging || aiTagError) && (
+ e.stopPropagation()}
+ >
+
+ {aiTagError ?? 'AI Tagging…'}
+
+ {aiTagError && (
+ setAiTagError(null)}
+ className="ml-2 underline text-xs"
+ style={{ color: '#fca5a5' }}
+ >
+ dismiss
+
+ )}
+
+ )}
+
{/* Delete confirmation overlay */}
{confirming && (
{
+ const config = getAiConfig()
+ if (!config.endpoint || !config.model) {
+ throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
+ }
+
+ const tags = getTags()
+ const categories = getCategories()
+ if (tags.length === 0) {
+ return []
+ }
+
+ const validTagIds = new Set(tags.map((t) => t.id))
+ const systemPrompt = buildTagPrompt(tags, categories)
+
+ const db = getDb()
+ const item = db
+ .prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
+ .get(itemKey) as MediaItemRow | undefined
+
+ if (!item) {
+ throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
+ }
+
+ const libraryId = itemKey.split(':')[0]
+ const library = getLibrary(libraryId)
+ if (!library) {
+ throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
+ }
+ const libraryRoot = resolveLibraryRoot(library)
+
+ const imagePath = resolveItemImage(libraryRoot, item)
+ if (!imagePath) {
+ throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
+ }
+
+ const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
+ const base64 = fs.readFileSync(thumbnailPath, 'base64')
+
+ const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
+ const validIds = suggestedIds.filter((id) => validTagIds.has(id))
+
+ for (const tagId of validIds) {
+ addTagToItem(itemKey, tagId)
+ }
+
+ db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?').run(Date.now(), itemKey)
+
+ return validIds
+}