customize model based on step
This commit is contained in:
@@ -362,6 +362,31 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDescribe={async (e) => {
|
||||
if (e.type === 'directory') {
|
||||
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||
const res = await fetch('/api/ai-tagging/describe-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryId, path: dirRel }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Description generation failed')
|
||||
}
|
||||
} else {
|
||||
const itemKey = itemKeyFor(e)
|
||||
const res = await fetch('/api/ai-tagging/describe', {
|
||||
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 ?? 'Description generation failed')
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDelete={(e) => {
|
||||
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
||||
@@ -491,7 +516,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText }: { 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>; onExtractText?: (e: FileEntry) => Promise<void> }) {
|
||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { 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>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void> }) {
|
||||
type ImgState = 'loading' | 'loaded' | 'error'
|
||||
const [imgState, setImgState] = useState<ImgState>(
|
||||
entry.thumbnailUrl ? 'loading' : 'error'
|
||||
@@ -508,6 +533,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [textExtracting, setTextExtracting] = useState(false)
|
||||
const [textExtractError, setTextExtractError] = useState<string | null>(null)
|
||||
const [describing, setDescribing] = useState(false)
|
||||
const [describeError, setDescribeError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
@@ -538,7 +565,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(entry)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(entry) } }}
|
||||
className="group relative flex flex-col rounded-xl border overflow-hidden text-xs transition-all cursor-pointer"
|
||||
className="group relative flex flex-col rounded-xl border text-xs transition-all cursor-pointer"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)', aspectRatio: '1 / 1' }}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||
@@ -549,6 +576,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
{/* Inner wrapper — clips visual content to rounded corners */}
|
||||
<div className="absolute inset-0 overflow-hidden rounded-xl pointer-events-none">
|
||||
{/* Thumbnail image — hidden until loaded */}
|
||||
{entry.thumbnailUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -606,6 +635,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
▶
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag button — top-left, shown on hover */}
|
||||
<button
|
||||
@@ -618,11 +648,11 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
🏷
|
||||
</button>
|
||||
|
||||
{/* Kebab menu — top-right, shown on hover */}
|
||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory')) && (
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
||||
{/* Kebab menu — bottom-right, shown on hover */}
|
||||
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
|
||||
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block z-10" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null) }}
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null); setDescribeError(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"
|
||||
@@ -631,7 +661,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||
className="absolute right-0 bottom-full mb-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' && (
|
||||
@@ -654,6 +684,46 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
✨ AI Tag
|
||||
</button>
|
||||
)}
|
||||
{onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
setDescribing(true)
|
||||
setDescribeError(null)
|
||||
onDescribe(entry)
|
||||
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
|
||||
.finally(() => setDescribing(false))
|
||||
}}
|
||||
disabled={describing}
|
||||
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')}
|
||||
>
|
||||
📝 Describe
|
||||
</button>
|
||||
)}
|
||||
{onDescribe && entry.type === 'directory' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
setDescribing(true)
|
||||
setDescribeError(null)
|
||||
onDescribe(entry)
|
||||
.catch((err) => setDescribeError(err instanceof Error ? err.message : 'Description generation failed'))
|
||||
.finally(() => setDescribing(false))
|
||||
}}
|
||||
disabled={describing}
|
||||
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')}
|
||||
>
|
||||
📝 Describe Folder
|
||||
</button>
|
||||
)}
|
||||
{onExtractText && entry.mediaType === 'image' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -771,6 +841,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description generation status overlay */}
|
||||
{(describing || describeError) && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
||||
style={{ backgroundColor: describeError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span style={{ color: describeError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
||||
{describeError ?? 'Generating description…'}
|
||||
</span>
|
||||
{describeError && (
|
||||
<button
|
||||
onClick={() => setDescribeError(null)}
|
||||
className="ml-2 underline text-xs"
|
||||
style={{ color: '#fca5a5' }}
|
||||
>
|
||||
dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{confirming && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user