365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Plus, Folder, MoreVertical, Trash2, Pencil } from 'lucide-react'
|
|
import { useProjects } from '../contexts/ProjectContext'
|
|
import { useView } from '../contexts/ViewContext'
|
|
import { LtxLogo } from '../components/LtxLogo'
|
|
import { Button } from '../components/ui/button'
|
|
import { pathToFileUrl } from '../lib/file-url'
|
|
import type { Project } from '../types/project-model'
|
|
import { useProjectReferencesMigration } from '../hooks/useProjectReferencesMigration'
|
|
|
|
function formatDate(timestamp: number): string {
|
|
const date = new Date(timestamp)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})
|
|
}
|
|
|
|
function ProjectCard({ project, onOpen, onDelete, onRename }: {
|
|
project: Project
|
|
onOpen: () => void
|
|
onDelete: () => void
|
|
onRename: () => void
|
|
}) {
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
const [imgError, setImgError] = useState(false)
|
|
|
|
// Keep existing representative selection logic: prefer first image, else first asset.
|
|
const representativeAsset = project.assets.find(a => a.type === 'image') || project.assets[0] || null
|
|
const representativeUrl = representativeAsset?.path ? pathToFileUrl(representativeAsset.path) : null
|
|
const representativeBigThumbnailUrl = representativeAsset?.bigThumbnailPath
|
|
? pathToFileUrl(representativeAsset.bigThumbnailPath)
|
|
: null
|
|
|
|
return (
|
|
<div
|
|
className="group relative bg-zinc-900 rounded-lg overflow-hidden border border-zinc-800 hover:border-zinc-700 transition-colors cursor-pointer"
|
|
onClick={onOpen}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div className="aspect-video bg-zinc-800 flex items-center justify-center relative overflow-hidden">
|
|
{representativeAsset && !imgError ? (
|
|
representativeAsset.type === 'video' ? (
|
|
representativeBigThumbnailUrl ? (
|
|
<img
|
|
src={representativeBigThumbnailUrl}
|
|
alt={project.name}
|
|
className="w-full h-full object-cover"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
) : representativeUrl ? (
|
|
<video
|
|
src={representativeUrl}
|
|
className="w-full h-full object-cover"
|
|
muted
|
|
preload="metadata"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
) : (
|
|
<Folder className="h-12 w-12 text-zinc-600" />
|
|
)
|
|
) : representativeUrl ? (
|
|
<img
|
|
src={representativeUrl}
|
|
alt={project.name}
|
|
className="w-full h-full object-cover"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
) : (
|
|
<Folder className="h-12 w-12 text-zinc-600" />
|
|
)
|
|
) : (
|
|
<Folder className="h-12 w-12 text-zinc-600" />
|
|
)}
|
|
{/* Hover overlay */}
|
|
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-3">
|
|
<h3 className="font-medium text-white truncate">{project.name}</h3>
|
|
<p className="text-xs text-zinc-500 mt-1">{formatDate(project.updatedAt)}</p>
|
|
</div>
|
|
|
|
{/* Menu button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setShowMenu(!showMenu)
|
|
}}
|
|
className="absolute top-2 right-2 p-1.5 rounded bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70"
|
|
>
|
|
<MoreVertical className="h-4 w-4 text-white" />
|
|
</button>
|
|
|
|
{/* Dropdown menu */}
|
|
{showMenu && (
|
|
<div
|
|
className="absolute top-10 right-2 bg-zinc-800 rounded-lg shadow-lg border border-zinc-700 py-1 z-10 min-w-[120px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => { onRename(); setShowMenu(false) }}
|
|
className="w-full px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
Rename
|
|
</button>
|
|
<button
|
|
onClick={() => { onDelete(); setShowMenu(false) }}
|
|
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Home() {
|
|
const { projectIds, getProject, createProject, deleteProject, renameProject } = useProjects()
|
|
const { openProject } = useView()
|
|
const { migrationStatus, migrateProjects } = useProjectReferencesMigration()
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const [newProjectName, setNewProjectName] = useState('')
|
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const migrationStartedRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (migrationStatus.status !== 'needed' || migrationStartedRef.current) return
|
|
migrationStartedRef.current = true
|
|
void migrateProjects()
|
|
}, [migrateProjects, migrationStatus.status])
|
|
|
|
const projects = useMemo(() => (
|
|
projectIds
|
|
.map(projectId => getProject(projectId))
|
|
.filter((project): project is Project => project !== null)
|
|
), [getProject, projectIds])
|
|
|
|
const handleCreateProject = () => {
|
|
if (newProjectName.trim()) {
|
|
const project = createProject(newProjectName.trim())
|
|
setNewProjectName('')
|
|
setIsCreating(false)
|
|
openProject(project.id)
|
|
}
|
|
}
|
|
|
|
const handleRenameProject = (id: string, currentName: string) => {
|
|
setRenamingId(id)
|
|
setRenameValue(currentName)
|
|
}
|
|
|
|
const submitRename = () => {
|
|
if (renamingId && renameValue.trim()) {
|
|
renameProject(renamingId, renameValue.trim())
|
|
}
|
|
setRenamingId(null)
|
|
setRenameValue('')
|
|
}
|
|
|
|
if (migrationStatus.status === 'needed' || migrationStatus.status === 'inProgress') {
|
|
const progressPct = migrationStatus.status === 'inProgress'
|
|
? migrationStatus.ratio * 100
|
|
: 0
|
|
|
|
return (
|
|
<div className="h-full min-h-0 bg-background flex items-center justify-center">
|
|
<div className="w-[360px]">
|
|
<p className="text-center text-sm text-zinc-300 mb-4">
|
|
Migrating project references...
|
|
</p>
|
|
<div className="h-2 w-full rounded-full bg-zinc-800 overflow-hidden">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-150"
|
|
style={{ width: `${Math.max(0, Math.min(100, progressPct))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full min-h-0 bg-background flex">
|
|
{/* Sidebar */}
|
|
<aside className="w-64 border-r border-zinc-800 flex flex-col">
|
|
<div className="p-6">
|
|
<LtxLogo className="h-6 w-auto text-white" />
|
|
</div>
|
|
|
|
<nav className="flex-1 px-3">
|
|
<button className="w-full px-3 py-2 rounded-lg bg-zinc-800 text-white text-left text-sm font-medium flex items-center gap-2">
|
|
<Folder className="h-4 w-4" />
|
|
Home
|
|
</button>
|
|
|
|
{projects.length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="px-3 text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">
|
|
Recent Projects
|
|
</h4>
|
|
{projects.slice(0, 5).map(project => (
|
|
<button
|
|
key={project.id}
|
|
onClick={() => openProject(project.id)}
|
|
className="w-full px-3 py-2 rounded-lg text-zinc-400 hover:bg-zinc-800 hover:text-white text-left text-sm flex items-center gap-2 transition-colors truncate"
|
|
>
|
|
<Folder className="h-4 w-4 flex-shrink-0" />
|
|
<span className="truncate">{project.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</nav>
|
|
|
|
<div className="p-4 border-t border-zinc-800">
|
|
<button
|
|
onClick={() => setIsCreating(true)}
|
|
className="w-full px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
New Project
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 overflow-auto">
|
|
{/* Header Banner with video background */}
|
|
<div className="relative h-72 overflow-hidden">
|
|
<video
|
|
src="./hero-video.mp4"
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
{/* Dark overlay for text readability */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10" />
|
|
<div className="absolute bottom-6 left-8 z-10">
|
|
<h1 className="text-3xl font-bold text-white mb-2 drop-shadow-lg">LTX DeskWEB</h1>
|
|
<p className="text-zinc-200 drop-shadow-md">Create and manage your video projects</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projects Grid */}
|
|
<div className="p-8">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-semibold text-white">Projects</h2>
|
|
</div>
|
|
|
|
{projects.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<Folder className="h-16 w-16 text-zinc-700 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-zinc-400 mb-2">No projects yet</h3>
|
|
<p className="text-zinc-500 mb-6">Create your first project to get started</p>
|
|
<Button
|
|
onClick={() => setIsCreating(true)}
|
|
className="bg-blue-600 hover:bg-blue-500"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Create Project
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{projects.map(project => (
|
|
<ProjectCard
|
|
key={project.id}
|
|
project={project}
|
|
onOpen={() => openProject(project.id)}
|
|
onDelete={() => {
|
|
if (confirm(`Delete "${project.name}"?`)) {
|
|
deleteProject(project.id)
|
|
}
|
|
}}
|
|
onRename={() => handleRenameProject(project.id, project.name)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Create Project Modal */}
|
|
{isCreating && (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
|
<div className="bg-zinc-900 rounded-xl p-6 w-full max-w-md border border-zinc-800">
|
|
<h2 className="text-xl font-semibold text-white mb-4">Create New Project</h2>
|
|
<input
|
|
type="text"
|
|
value={newProjectName}
|
|
onChange={(e) => setNewProjectName(e.target.value)}
|
|
placeholder="Project name"
|
|
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500"
|
|
autoFocus
|
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateProject()}
|
|
/>
|
|
<div className="flex gap-3 mt-6">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { setIsCreating(false); setNewProjectName('') }}
|
|
className="flex-1 border-zinc-700"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreateProject}
|
|
disabled={!newProjectName.trim()}
|
|
className="flex-1 bg-blue-600 hover:bg-blue-500"
|
|
>
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rename Modal */}
|
|
{renamingId && (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
|
<div className="bg-zinc-900 rounded-xl p-6 w-full max-w-md border border-zinc-800">
|
|
<h2 className="text-xl font-semibold text-white mb-4">Rename Project</h2>
|
|
<input
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
placeholder="Project name"
|
|
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500"
|
|
autoFocus
|
|
onKeyDown={(e) => e.key === 'Enter' && submitRename()}
|
|
/>
|
|
<div className="flex gap-3 mt-6">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { setRenamingId(null); setRenameValue('') }}
|
|
className="flex-1 border-zinc-700"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={submitRename}
|
|
disabled={!renameValue.trim()}
|
|
className="flex-1 bg-blue-600 hover:bg-blue-500"
|
|
>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)
|
|
}
|