feat: structure 2
This commit is contained in:
173
frontend/src/components/layout/LeftSidebar.tsx
Normal file
173
frontend/src/components/layout/LeftSidebar.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
|
||||
Image,
|
||||
Calendar,
|
||||
Star,
|
||||
Flag,
|
||||
Trash2,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
HardDrive
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TreeItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
count?: number
|
||||
children?: TreeItem[]
|
||||
type?: 'folder' | 'heap' | 'special'
|
||||
}
|
||||
|
||||
export function LeftSidebar() {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id)
|
||||
} else {
|
||||
newExpanded.add(id)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const libraryTree: TreeItem[] = [
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Library',
|
||||
icon: <HardDrive className="h-4 w-4" />,
|
||||
children: [
|
||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'folders',
|
||||
label: 'Folders',
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
children: [], // Will be populated from API
|
||||
},
|
||||
{
|
||||
id: 'heaps',
|
||||
label: 'Heaps',
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
children: [], // Will be populated from API
|
||||
},
|
||||
]
|
||||
|
||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedItems.has(item.id)
|
||||
const isSelected = selectedItem === item.id
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
|
||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
depth > 0 && 'text-[13px]'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||
onClick={() => {
|
||||
setSelectedItem(item.id)
|
||||
if (hasChildren) {
|
||||
toggleExpanded(item.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpanded(item.id)
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-surface-offset"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
|
||||
{/* Item Icon */}
|
||||
{item.icon && (
|
||||
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
|
||||
{/* Count Badge */}
|
||||
{item.count !== undefined && item.count > 0 && (
|
||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions (shown on hover) */}
|
||||
{(item.id === 'folders' || item.id === 'heaps') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Handle add folder/heap
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<h2 className="text-sm font-semibold text-text">Library</h2>
|
||||
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="border-t border-border p-3">
|
||||
<button className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Source Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
frontend/src/components/layout/RightSidebar.tsx
Normal file
302
frontend/src/components/layout/RightSidebar.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
MapPin,
|
||||
Camera,
|
||||
Aperture,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Check,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export function RightSidebar() {
|
||||
const { selectedPhotos, clearSelection } = usePhotoStore()
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['basic', 'camera', 'location', 'tags'])
|
||||
)
|
||||
const [rating, setRating] = useState(0)
|
||||
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
const newExpanded = new Set(expandedSections)
|
||||
if (newExpanded.has(section)) {
|
||||
newExpanded.delete(section)
|
||||
} else {
|
||||
newExpanded.add(section)
|
||||
}
|
||||
setExpandedSections(newExpanded)
|
||||
}
|
||||
|
||||
// Mock photo data - in real app, fetch based on selectedPhotos
|
||||
const mockPhoto = selectedPhotos.length > 0 ? {
|
||||
filename: 'IMG_1234.jpg',
|
||||
size: '3.2 MB',
|
||||
dimensions: '4032 × 3024',
|
||||
dateTaken: new Date('2024-01-15T14:30:00'),
|
||||
camera: 'Canon EOS R5',
|
||||
lens: 'RF 24-70mm F2.8L IS USM',
|
||||
iso: 400,
|
||||
aperture: 'f/2.8',
|
||||
shutterSpeed: '1/250',
|
||||
focalLength: '50mm',
|
||||
location: 'San Francisco, CA',
|
||||
tags: ['landscape', 'sunset', 'golden hour'],
|
||||
} : null
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<div className="text-text-muted">
|
||||
<Info className="mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-sm">Select photos to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const multipleSelected = selectedPhotos.length > 1
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-text">
|
||||
{multipleSelected
|
||||
? `${selectedPhotos.length} Photos Selected`
|
||||
: 'Photo Details'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="border-b border-border p-4">
|
||||
{/* Rating Stars */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setRating(rating === value ? 0 : value)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
'h-5 w-5 transition-colors',
|
||||
value <= rating
|
||||
? 'fill-star text-star'
|
||||
: 'text-text-muted hover:text-star'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flag Status */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
flagStatus === 'pick'
|
||||
? 'bg-pick/20 text-pick'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Pick
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
flagStatus === 'reject'
|
||||
? 'bg-reject/20 text-reject'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Sections */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{mockPhoto && (
|
||||
<>
|
||||
{/* Basic Info */}
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => toggleSection('basic')}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
<span className="font-medium text-text">Basic Info</span>
|
||||
{expandedSections.has('basic') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.has('basic') && (
|
||||
<div className="px-4 pb-3 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-text-muted">Filename:</span>
|
||||
<p className="text-text">{mockPhoto.filename}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Size:</span>
|
||||
<p className="text-text">{mockPhoto.size}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Dimensions:</span>
|
||||
<p className="text-text">{mockPhoto.dimensions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Date Taken:</span>
|
||||
<p className="text-text">
|
||||
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Camera Info */}
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => toggleSection('camera')}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
<span className="font-medium text-text">Camera</span>
|
||||
{expandedSections.has('camera') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.has('camera') && (
|
||||
<div className="px-4 pb-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-3 w-3 text-text-muted" />
|
||||
<span className="text-text">{mockPhoto.camera}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Aperture className="h-3 w-3 text-text-muted" />
|
||||
<span className="text-text">{mockPhoto.lens}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<div>
|
||||
<span className="text-text-muted">ISO:</span>
|
||||
<span className="ml-1 text-text">{mockPhoto.iso}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Aperture:</span>
|
||||
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Shutter:</span>
|
||||
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Focal:</span>
|
||||
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => toggleSection('location')}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
<span className="font-medium text-text">Location</span>
|
||||
{expandedSections.has('location') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.has('location') && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="h-3 w-3 text-text-muted" />
|
||||
<span className="text-text">{mockPhoto.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => toggleSection('tags')}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
<span className="font-medium text-text">Tags</span>
|
||||
{expandedSections.has('tags') ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.has('tags') && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mockPhoto.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{multipleSelected && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="space-y-2">
|
||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
||||
Add to Heap
|
||||
</button>
|
||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
||||
Export Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/layout/TopBar.tsx
Normal file
116
frontend/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Grid,
|
||||
List,
|
||||
SlidersHorizontal,
|
||||
FolderOpen,
|
||||
Upload,
|
||||
Settings,
|
||||
Menu
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
|
||||
export function TopBar() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||
{/* Left Section - Menu and App Name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
||||
{selectedCount > 0 && (
|
||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center Section - Search */}
|
||||
<div className="flex max-w-xl flex-1 items-center px-8">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search photos..."
|
||||
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section - View Controls and Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex rounded-md border border-border">
|
||||
<button
|
||||
className={clsx(
|
||||
'rounded-l-md px-2 py-1',
|
||||
viewMode === 'grid'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface text-text-muted hover:bg-surface-2'
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid view"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
'rounded-r-md px-2 py-1',
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface text-text-muted hover:bg-surface-2'
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List view"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Filter photos"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-border" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Add folder"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Import photos"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user