Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
/**
* Private Media Edit Modal
*
* Edit metadata for a single media item
*/
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
X,
Save,
Calendar,
Tag,
FileText,
Loader2,
Image as ImageIcon,
Video,
} from 'lucide-react'
import { api } from '../../lib/api'
import PersonSelector from './PersonSelector'
interface MediaItem {
id: number
filename: string
description?: string | null
file_type: 'image' | 'video' | 'other'
person_id?: number | null
media_date: string
tags: Array<{ id: number; name: string; color: string }>
thumbnail_url: string
}
interface EditModalProps {
open: boolean
onClose: () => void
item: MediaItem | null
onSuccess?: () => void
}
export function PrivateMediaEditModal({ open, onClose, item, onSuccess }: EditModalProps) {
const queryClient = useQueryClient()
const [personId, setPersonId] = useState<number | undefined>()
const [selectedTags, setSelectedTags] = useState<number[]>([])
const [mediaDate, setMediaDate] = useState('')
const [description, setDescription] = useState('')
// Load item data when it changes
useEffect(() => {
if (item) {
setPersonId(item.person_id ?? undefined)
setSelectedTags(item.tags.map(t => t.id))
setMediaDate(item.media_date)
setDescription(item.description || '')
}
}, [item])
const { data: tags = [] } = useQuery({
queryKey: ['private-gallery-tags'],
queryFn: () => api.privateGallery.getTags(),
enabled: open,
})
const updateMutation = useMutation({
mutationFn: (data: {
description?: string
person_id?: number
media_date?: string
tag_ids?: number[]
}) => api.privateGallery.updateMedia(item!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['private-gallery-media'] })
queryClient.invalidateQueries({ queryKey: ['private-gallery-albums'] })
handleClose()
onSuccess?.()
},
})
const handleClose = () => {
setPersonId(undefined)
setSelectedTags([])
setMediaDate('')
setDescription('')
updateMutation.reset()
onClose()
}
const toggleTag = (tagId: number) => {
setSelectedTags(prev =>
prev.includes(tagId)
? prev.filter(id => id !== tagId)
: [...prev, tagId]
)
}
const handleSubmit = () => {
if (selectedTags.length === 0) {
alert('Please select at least one tag')
return
}
updateMutation.mutate({
description: description || undefined,
person_id: personId || 0, // 0 means clear
media_date: mediaDate,
tag_ids: selectedTags,
})
}
if (!open || !item) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-lg bg-card rounded-xl shadow-2xl border border-border max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">Edit Media</h2>
<button
onClick={handleClose}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6">
{/* Preview */}
<div className="flex items-center gap-4 p-4 rounded-lg bg-secondary/50">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-secondary flex-shrink-0">
{item.file_type === 'image' ? (
<img
src={item.thumbnail_url}
alt={item.filename}
className="w-full h-full object-cover"
/>
) : item.file_type === 'video' ? (
<div className="w-full h-full flex items-center justify-center">
<Video className="w-6 h-6 text-muted-foreground" />
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.filename}</p>
<p className="text-sm text-muted-foreground capitalize">{item.file_type}</p>
</div>
</div>
{/* Person Selector */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
Person
</label>
<PersonSelector
value={personId}
onChange={(id) => setPersonId(id)}
/>
</div>
{/* Tags */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Tag className="w-4 h-4" />
<span className="text-red-500">*</span>
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedTags.includes(tag.id)
? 'text-white'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
style={selectedTags.includes(tag.id) ? { backgroundColor: tag.color } : undefined}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Date */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<Calendar className="w-4 h-4" />
Date
</label>
<input
type="date"
value={mediaDate}
onChange={(e) => setMediaDate(e.target.value)}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium mb-2">
<FileText className="w-4 h-4" />
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-4 py-2.5 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Add a description..."
/>
</div>
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-3 px-4 sm:px-6 py-4 border-t border-border">
<button
onClick={handleClose}
className="w-full sm:w-auto px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={updateMutation.isPending || selectedTags.length === 0}
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition-colors"
>
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
Save Changes
</>
)}
</button>
</div>
</div>
</div>
)
}
export default PrivateMediaEditModal