Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
224
src/components/shared/avatar-upload.tsx
Normal file
224
src/components/shared/avatar-upload.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { UserAvatar } from './user-avatar'
|
||||
import { Upload, Loader2, Trash2 } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type AvatarUploadProps = {
|
||||
user: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
profileImageKey?: string | null
|
||||
}
|
||||
currentAvatarUrl?: string | null
|
||||
onUploadComplete?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const MAX_SIZE_MB = 5
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
|
||||
export function AvatarUpload({
|
||||
user,
|
||||
currentAvatarUrl,
|
||||
onUploadComplete,
|
||||
children,
|
||||
}: AvatarUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const getUploadUrl = trpc.avatar.getUploadUrl.useMutation()
|
||||
const confirmUpload = trpc.avatar.confirmUpload.useMutation()
|
||||
const deleteAvatar = trpc.avatar.delete.useMutation()
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedFile(file)
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setPreview(e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Get pre-signed upload URL (includes provider type for tracking)
|
||||
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
|
||||
fileName: selectedFile.name,
|
||||
contentType: selectedFile.type,
|
||||
})
|
||||
|
||||
// Upload file directly to storage
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: selectedFile,
|
||||
headers: {
|
||||
'Content-Type': selectedFile.type,
|
||||
},
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
|
||||
// Confirm upload with the provider type that was used
|
||||
await confirmUpload.mutateAsync({ key, providerType })
|
||||
|
||||
// Invalidate avatar query
|
||||
utils.avatar.getUrl.invalidate()
|
||||
|
||||
toast.success('Avatar updated successfully')
|
||||
setOpen(false)
|
||||
setPreview(null)
|
||||
setSelectedFile(null)
|
||||
onUploadComplete?.()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error('Failed to upload avatar. Please try again.')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteAvatar.mutateAsync()
|
||||
utils.avatar.getUrl.invalidate()
|
||||
toast.success('Avatar removed')
|
||||
setOpen(false)
|
||||
onUploadComplete?.()
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
toast.error('Failed to remove avatar')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setPreview(null)
|
||||
setSelectedFile(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<div className="cursor-pointer">
|
||||
<UserAvatar user={user} avatarUrl={currentAvatarUrl} showEditOverlay />
|
||||
</div>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Profile Picture</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a new profile picture. Allowed formats: JPEG, PNG, GIF, WebP.
|
||||
Max size: {MAX_SIZE_MB}MB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Preview */}
|
||||
<div className="flex justify-center">
|
||||
<UserAvatar
|
||||
user={user}
|
||||
avatarUrl={preview || currentAvatarUrl}
|
||||
size="xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="avatar">Select image</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
{currentAvatarUrl && !preview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={handleCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
136
src/components/shared/block-editor.tsx
Normal file
136
src/components/shared/block-editor.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/core/fonts/inter.css'
|
||||
import '@blocknote/mantine/style.css'
|
||||
|
||||
import type { PartialBlock } from '@blocknote/core'
|
||||
|
||||
interface BlockEditorProps {
|
||||
initialContent?: string | null
|
||||
onChange?: (content: string) => void
|
||||
onUploadFile?: (file: File) => Promise<string>
|
||||
editable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlockEditor({
|
||||
initialContent,
|
||||
onChange,
|
||||
onUploadFile,
|
||||
editable = true,
|
||||
className,
|
||||
}: BlockEditorProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Parse initial content
|
||||
const parsedContent = useMemo(() => {
|
||||
if (!initialContent) return undefined
|
||||
try {
|
||||
return JSON.parse(initialContent) as PartialBlock[]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}, [initialContent])
|
||||
|
||||
// Default upload handler that uses the provided callback or creates blob URLs
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
if (onUploadFile) {
|
||||
return onUploadFile(file)
|
||||
}
|
||||
// Fallback: create blob URL (not persistent)
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent: parsedContent,
|
||||
uploadFile,
|
||||
})
|
||||
|
||||
// Handle content changes
|
||||
useEffect(() => {
|
||||
if (!onChange || !editor) return
|
||||
|
||||
const handleChange = () => {
|
||||
const content = JSON.stringify(editor.document)
|
||||
onChange(content)
|
||||
}
|
||||
|
||||
// Subscribe to changes
|
||||
editor.onEditorContentChange(handleChange)
|
||||
}, [editor, onChange])
|
||||
|
||||
// Client-side only rendering
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`min-h-[200px] rounded-lg border bg-muted/20 animate-pulse ${className}`} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bn-container ${className}`}>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
editable={editable}
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Read-only viewer component
|
||||
interface BlockViewerProps {
|
||||
content?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlockViewer({ content, className }: BlockViewerProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const parsedContent = useMemo(() => {
|
||||
if (!content) return undefined
|
||||
try {
|
||||
return JSON.parse(content) as PartialBlock[]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent: parsedContent,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`min-h-[100px] rounded-lg border bg-muted/20 animate-pulse ${className}`} />
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className={`text-muted-foreground text-sm ${className}`}>
|
||||
No content available
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bn-container ${className}`}>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
editable={false}
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
src/components/shared/edition-selector.tsx
Normal file
178
src/components/shared/edition-selector.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { useState } from 'react'
|
||||
|
||||
const statusConfig: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
DRAFT: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/50',
|
||||
text: 'text-amber-700 dark:text-amber-400',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
ACTIVE: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950/50',
|
||||
text: 'text-emerald-700 dark:text-emerald-400',
|
||||
dot: 'bg-emerald-500',
|
||||
},
|
||||
ARCHIVED: {
|
||||
bg: 'bg-slate-100 dark:bg-slate-800/50',
|
||||
text: 'text-slate-600 dark:text-slate-400',
|
||||
dot: 'bg-slate-400',
|
||||
},
|
||||
}
|
||||
|
||||
export function EditionSelector() {
|
||||
const { currentEdition, editions, setCurrentEdition, isLoading } = useEdition()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-1 py-2">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand-blue/10 to-brand-teal/10 animate-pulse">
|
||||
<span className="text-lg font-bold text-muted-foreground/50">--</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-16 rounded bg-muted animate-pulse" />
|
||||
<div className="h-2.5 w-10 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (editions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-1 py-2">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-muted/50 border border-dashed border-muted-foreground/20">
|
||||
<span className="text-lg font-bold text-muted-foreground/40">?</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">No editions</p>
|
||||
<p className="text-xs text-muted-foreground/60">Create one to start</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = currentEdition ? statusConfig[currentEdition.status] : statusConfig.DRAFT
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 rounded-xl px-1 py-2 text-left transition-all duration-200',
|
||||
'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
open && 'bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Year Badge */}
|
||||
<div className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-brand-blue shadow-xs transition-transform duration-200 group-hover:scale-[1.02]">
|
||||
<span className="text-lg font-bold tracking-tight text-white">
|
||||
{currentEdition ? String(currentEdition.year).slice(-2) : '--'}
|
||||
</span>
|
||||
{/* Status indicator dot */}
|
||||
<div className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white',
|
||||
status.dot
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{currentEdition ? currentEdition.year : 'Select'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{currentEdition?.status === 'ACTIVE' ? 'Current Edition' : currentEdition?.status?.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronDown className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground/60 transition-transform duration-200',
|
||||
open && 'rotate-180'
|
||||
)} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0 shadow-lg border-border/50"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command className="rounded-lg">
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
|
||||
No editions found
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="p-1.5">
|
||||
{editions.map((edition) => {
|
||||
const editionStatus = statusConfig[edition.status] || statusConfig.DRAFT
|
||||
const isSelected = currentEdition?.id === edition.id
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={edition.id}
|
||||
value={`${edition.name} ${edition.year}`}
|
||||
onSelect={() => {
|
||||
setCurrentEdition(edition.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'group/item flex items-center gap-3 rounded-lg px-2.5 py-2.5 cursor-pointer transition-colors',
|
||||
isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'hover:bg-slate-50 dark:hover:bg-slate-800/50'
|
||||
)}
|
||||
>
|
||||
{/* Year badge in dropdown */}
|
||||
<div className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg font-bold text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-brand-blue text-white'
|
||||
: 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
)}>
|
||||
{String(edition.year).slice(-2)}
|
||||
</div>
|
||||
|
||||
{/* Edition info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{edition.year}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn('h-1.5 w-1.5 rounded-full', editionStatus.dot)} />
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 capitalize">
|
||||
{edition.status.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check mark */}
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-brand-blue" />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
49
src/components/shared/empty-state.tsx
Normal file
49
src/components/shared/empty-state.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-12 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={action.onClick}
|
||||
asChild={!!action.href}
|
||||
>
|
||||
{action.href ? <a href={action.href}>{action.label}</a> : action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
462
src/components/shared/file-upload.tsx
Normal file
462
src/components/shared/file-upload.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
FileIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Film,
|
||||
FileText,
|
||||
Presentation,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB
|
||||
|
||||
type FileType = 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER'
|
||||
|
||||
interface UploadingFile {
|
||||
id: string
|
||||
file: File
|
||||
progress: number
|
||||
status: 'pending' | 'uploading' | 'complete' | 'error'
|
||||
error?: string
|
||||
fileType: FileType
|
||||
dbFileId?: string
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
projectId: string
|
||||
onUploadComplete?: (file: { id: string; fileName: string; fileType: string }) => void
|
||||
onUploadError?: (error: Error) => void
|
||||
allowedTypes?: string[]
|
||||
multiple?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Map MIME types to suggested file types
|
||||
function suggestFileType(mimeType: string): FileType {
|
||||
if (mimeType.startsWith('video/')) return 'VIDEO'
|
||||
if (mimeType === 'application/pdf') return 'EXEC_SUMMARY'
|
||||
if (
|
||||
mimeType.includes('presentation') ||
|
||||
mimeType.includes('powerpoint') ||
|
||||
mimeType.includes('slides')
|
||||
) {
|
||||
return 'PRESENTATION'
|
||||
}
|
||||
return 'OTHER'
|
||||
}
|
||||
|
||||
// Get icon for file type
|
||||
function getFileTypeIcon(fileType: FileType) {
|
||||
switch (fileType) {
|
||||
case 'VIDEO':
|
||||
return <Film className="h-4 w-4" />
|
||||
case 'EXEC_SUMMARY':
|
||||
return <FileText className="h-4 w-4" />
|
||||
case 'PRESENTATION':
|
||||
return <Presentation className="h-4 w-4" />
|
||||
default:
|
||||
return <FileIcon className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
export function FileUpload({
|
||||
projectId,
|
||||
onUploadComplete,
|
||||
onUploadError,
|
||||
allowedTypes,
|
||||
multiple = true,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
|
||||
const confirmUpload = trpc.file.confirmUpload.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Validate file
|
||||
const validateFile = useCallback(
|
||||
(file: File): string | null => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `File size exceeds ${formatFileSize(MAX_FILE_SIZE)} limit`
|
||||
}
|
||||
if (allowedTypes && !allowedTypes.includes(file.type)) {
|
||||
return `File type ${file.type} is not allowed`
|
||||
}
|
||||
return null
|
||||
},
|
||||
[allowedTypes]
|
||||
)
|
||||
|
||||
// Upload a single file
|
||||
const uploadFile = useCallback(
|
||||
async (uploadingFile: UploadingFile) => {
|
||||
const { file, id, fileType } = uploadingFile
|
||||
|
||||
try {
|
||||
// Update status to uploading
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, status: 'uploading' as const } : f))
|
||||
)
|
||||
|
||||
// Get pre-signed upload URL
|
||||
const { uploadUrl, file: dbFile } = await getUploadUrl.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
fileType,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
})
|
||||
|
||||
// Store the DB file ID
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, dbFileId: dbFile.id } : f))
|
||||
)
|
||||
|
||||
// Upload to MinIO using XHR for progress tracking
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100)
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, progress } : f))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Network error during upload'))
|
||||
})
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
reject(new Error('Upload aborted'))
|
||||
})
|
||||
|
||||
xhr.open('PUT', uploadUrl)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
// Confirm upload
|
||||
await confirmUpload.mutateAsync({ fileId: dbFile.id })
|
||||
|
||||
// Update status to complete
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === id ? { ...f, status: 'complete' as const, progress: 100 } : f
|
||||
)
|
||||
)
|
||||
|
||||
// Invalidate file list queries
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
|
||||
// Notify parent
|
||||
onUploadComplete?.({
|
||||
id: dbFile.id,
|
||||
fileName: file.name,
|
||||
fileType,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Upload failed'
|
||||
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === id ? { ...f, status: 'error' as const, error: errorMessage } : f
|
||||
)
|
||||
)
|
||||
|
||||
onUploadError?.(error instanceof Error ? error : new Error(errorMessage))
|
||||
}
|
||||
},
|
||||
[projectId, getUploadUrl, confirmUpload, utils, onUploadComplete, onUploadError]
|
||||
)
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const fileArray = Array.from(files)
|
||||
const filesToUpload = multiple ? fileArray : [fileArray[0]].filter(Boolean)
|
||||
|
||||
const newUploadingFiles: UploadingFile[] = filesToUpload.map((file) => {
|
||||
const validationError = validateFile(file)
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
progress: 0,
|
||||
status: validationError ? ('error' as const) : ('pending' as const),
|
||||
error: validationError || undefined,
|
||||
fileType: suggestFileType(file.type),
|
||||
}
|
||||
})
|
||||
|
||||
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
|
||||
|
||||
// Start uploading valid files
|
||||
newUploadingFiles
|
||||
.filter((f) => f.status === 'pending')
|
||||
.forEach((f) => uploadFile(f))
|
||||
},
|
||||
[multiple, validateFile, uploadFile]
|
||||
)
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (e.dataTransfer.files?.length) {
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
},
|
||||
[handleFiles]
|
||||
)
|
||||
|
||||
// File input change handler
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) {
|
||||
handleFiles(e.target.files)
|
||||
}
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = ''
|
||||
},
|
||||
[handleFiles]
|
||||
)
|
||||
|
||||
// Update file type for a pending file
|
||||
const updateFileType = useCallback((fileId: string, fileType: FileType) => {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => (f.id === fileId ? { ...f, fileType } : f))
|
||||
)
|
||||
}, [])
|
||||
|
||||
// Remove a file from the queue
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.id !== fileId))
|
||||
}, [])
|
||||
|
||||
// Retry a failed upload
|
||||
const retryUpload = useCallback(
|
||||
(fileId: string) => {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === fileId
|
||||
? { ...f, status: 'pending' as const, progress: 0, error: undefined }
|
||||
: f
|
||||
)
|
||||
)
|
||||
|
||||
const file = uploadingFiles.find((f) => f.id === fileId)
|
||||
if (file) {
|
||||
uploadFile({ ...file, status: 'pending', progress: 0, error: undefined })
|
||||
}
|
||||
},
|
||||
[uploadingFiles, uploadFile]
|
||||
)
|
||||
|
||||
const hasActiveUploads = uploadingFiles.some(
|
||||
(f) => f.status === 'pending' || f.status === 'uploading'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative border-2 border-dashed rounded-lg p-6 text-center transition-colors cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50',
|
||||
hasActiveUploads && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept={allowedTypes?.join(',')}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-2 font-medium">
|
||||
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
or click to browse (max {formatFileSize(MAX_FILE_SIZE)})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload queue */}
|
||||
{uploadingFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{uploadingFiles.map((uploadingFile) => (
|
||||
<div
|
||||
key={uploadingFile.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3',
|
||||
uploadingFile.status === 'error' && 'border-destructive/50 bg-destructive/5',
|
||||
uploadingFile.status === 'complete' && 'border-green-500/50 bg-green-500/5'
|
||||
)}
|
||||
>
|
||||
{/* File type icon */}
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{getFileTypeIcon(uploadingFile.fileType)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{uploadingFile.file.name}
|
||||
</p>
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{formatFileSize(uploadingFile.file.size)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress bar or error message */}
|
||||
{uploadingFile.status === 'uploading' && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Progress value={uploadingFile.progress} className="h-1.5 flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadingFile.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadingFile.status === 'error' && (
|
||||
<p className="mt-1 text-xs text-destructive">{uploadingFile.error}</p>
|
||||
)}
|
||||
|
||||
{uploadingFile.status === 'pending' && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Select
|
||||
value={uploadingFile.fileType}
|
||||
onValueChange={(v) => updateFileType(uploadingFile.id, v as FileType)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EXEC_SUMMARY">Executive Summary</SelectItem>
|
||||
<SelectItem value="PRESENTATION">Presentation</SelectItem>
|
||||
<SelectItem value="VIDEO">Video</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status / Actions */}
|
||||
<div className="shrink-0">
|
||||
{uploadingFile.status === 'pending' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{uploadingFile.status === 'uploading' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
)}
|
||||
|
||||
{uploadingFile.status === 'complete' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
|
||||
{uploadingFile.status === 'error' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
retryUpload(uploadingFile.id)
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Remove file"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(uploadingFile.id)
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clear completed */}
|
||||
{uploadingFiles.some((f) => f.status === 'complete') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.status !== 'complete'))
|
||||
}
|
||||
>
|
||||
Clear completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
344
src/components/shared/file-viewer.tsx
Normal file
344
src/components/shared/file-viewer.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
FileText,
|
||||
Video,
|
||||
File,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Play,
|
||||
FileImage,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ProjectFile {
|
||||
id: string
|
||||
fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC'
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
bucket: string
|
||||
objectKey: string
|
||||
}
|
||||
|
||||
interface FileViewerProps {
|
||||
files: ProjectFile[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function getFileIcon(fileType: string, mimeType: string) {
|
||||
if (mimeType.startsWith('video/')) return Video
|
||||
if (mimeType.startsWith('image/')) return FileImage
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (fileType === 'EXEC_SUMMARY' || fileType === 'PRESENTATION') return FileText
|
||||
if (fileType === 'VIDEO') return Video
|
||||
return File
|
||||
}
|
||||
|
||||
function getFileTypeLabel(fileType: string) {
|
||||
switch (fileType) {
|
||||
case 'EXEC_SUMMARY':
|
||||
return 'Executive Summary'
|
||||
case 'PRESENTATION':
|
||||
return 'Presentation'
|
||||
case 'VIDEO':
|
||||
return 'Video'
|
||||
case 'BUSINESS_PLAN':
|
||||
return 'Business Plan'
|
||||
case 'VIDEO_PITCH':
|
||||
return 'Video Pitch'
|
||||
case 'SUPPORTING_DOC':
|
||||
return 'Supporting Document'
|
||||
default:
|
||||
return 'Attachment'
|
||||
}
|
||||
}
|
||||
|
||||
export function FileViewer({ files, className }: FileViewerProps) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<File className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No files attached</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project has no files uploaded yet
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Sort files by type order
|
||||
const sortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
|
||||
const sortedFiles = [...files].sort(
|
||||
(a, b) => sortOrder.indexOf(a.fileType) - sortOrder.indexOf(b.fileType)
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sortedFiles.map((file) => (
|
||||
<FileItem key={file.id} file={file} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FileItem({ file }: { file: ProjectFile }) {
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||||
|
||||
const { data: urlData, isLoading: isLoadingUrl } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket: file.bucket, objectKey: file.objectKey },
|
||||
{ enabled: showPreview }
|
||||
)
|
||||
|
||||
const canPreview = file.mimeType.startsWith('video/') || file.mimeType === 'application/pdf'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{file.fileName}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getFileTypeLabel(file.fileType)}
|
||||
</Badge>
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canPreview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Close
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<FileDownloadButton file={file} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
{showPreview && (
|
||||
<div className="rounded-lg border bg-muted/50 overflow-hidden">
|
||||
{isLoadingUrl ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : urlData?.url ? (
|
||||
<FilePreview file={file} url={urlData.url} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
Failed to load preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileDownloadButton({ file }: { file: ProjectFile }) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket: file.bucket, objectKey: file.objectKey },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (result.data?.url) {
|
||||
// Open in new tab for download
|
||||
window.open(result.data.url, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get download URL:', error)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label="Download file"
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
|
||||
if (file.mimeType.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="w-full max-h-[500px]"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
if (file.mimeType === 'application/pdf') {
|
||||
return (
|
||||
<div className="relative">
|
||||
<iframe
|
||||
src={`${url}#toolbar=0`}
|
||||
className="w-full h-[600px]"
|
||||
title={file.fileName}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
asChild
|
||||
>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
Preview not available for this file type
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact file list for smaller views
|
||||
export function FileList({ files, className }: FileViewerProps) {
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{files.map((file) => {
|
||||
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||||
return (
|
||||
<CompactFileItem key={file.id} file={file} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactFileItem({ file }: { file: ProjectFile }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||||
|
||||
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
||||
{ bucket: file.bucket, objectKey: file.objectKey },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (result.data?.url) {
|
||||
window.open(result.data.url, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get download URL:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 rounded-md border p-2 text-left hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm">{file.fileName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileViewerSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
34
src/components/shared/loading-spinner.tsx
Normal file
34
src/components/shared/loading-spinner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
}
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={cn('animate-spin text-primary', sizeClasses[size], className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface FullPageLoaderProps {
|
||||
text?: string
|
||||
}
|
||||
|
||||
export function FullPageLoader({ text = 'Loading...' }: FullPageLoaderProps) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
src/components/shared/logo-upload.tsx
Normal file
226
src/components/shared/logo-upload.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ProjectLogo } from './project-logo'
|
||||
import { Upload, Loader2, Trash2, ImagePlus } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type LogoUploadProps = {
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
logoKey?: string | null
|
||||
}
|
||||
currentLogoUrl?: string | null
|
||||
onUploadComplete?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const MAX_SIZE_MB = 5
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
|
||||
export function LogoUpload({
|
||||
project,
|
||||
currentLogoUrl,
|
||||
onUploadComplete,
|
||||
children,
|
||||
}: LogoUploadProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const getUploadUrl = trpc.logo.getUploadUrl.useMutation()
|
||||
const confirmUpload = trpc.logo.confirmUpload.useMutation()
|
||||
const deleteLogo = trpc.logo.delete.useMutation()
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast.error('Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
|
||||
toast.error(`File too large. Maximum size is ${MAX_SIZE_MB}MB.`)
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedFile(file)
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setPreview(e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Get pre-signed upload URL (includes provider type for tracking)
|
||||
const { uploadUrl, key, providerType } = await getUploadUrl.mutateAsync({
|
||||
projectId: project.id,
|
||||
fileName: selectedFile.name,
|
||||
contentType: selectedFile.type,
|
||||
})
|
||||
|
||||
// Upload file directly to storage
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: selectedFile,
|
||||
headers: {
|
||||
'Content-Type': selectedFile.type,
|
||||
},
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
|
||||
// Confirm upload with the provider type that was used
|
||||
await confirmUpload.mutateAsync({ projectId: project.id, key, providerType })
|
||||
|
||||
// Invalidate logo query
|
||||
utils.logo.getUrl.invalidate({ projectId: project.id })
|
||||
|
||||
toast.success('Logo updated successfully')
|
||||
setOpen(false)
|
||||
setPreview(null)
|
||||
setSelectedFile(null)
|
||||
onUploadComplete?.()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error('Failed to upload logo. Please try again.')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteLogo.mutateAsync({ projectId: project.id })
|
||||
utils.logo.getUrl.invalidate({ projectId: project.id })
|
||||
toast.success('Logo removed')
|
||||
setOpen(false)
|
||||
onUploadComplete?.()
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
toast.error('Failed to remove logo')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setPreview(null)
|
||||
setSelectedFile(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
{currentLogoUrl ? 'Change Logo' : 'Add Logo'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Project Logo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a logo for "{project.title}". Allowed formats: JPEG, PNG, GIF, WebP.
|
||||
Max size: {MAX_SIZE_MB}MB.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Preview */}
|
||||
<div className="flex justify-center">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={preview || currentLogoUrl}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Select image</Label>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="logo"
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
{currentLogoUrl && !preview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={handleCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
55
src/components/shared/logo.tsx
Normal file
55
src/components/shared/logo.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Image from 'next/image'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LogoProps {
|
||||
variant?: 'small' | 'long'
|
||||
className?: string
|
||||
showText?: boolean
|
||||
textSuffix?: string
|
||||
}
|
||||
|
||||
export function Logo({
|
||||
variant = 'small',
|
||||
className,
|
||||
showText = false,
|
||||
textSuffix,
|
||||
}: LogoProps) {
|
||||
if (variant === 'long') {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Image
|
||||
src="/images/MOPC-blue-long.png"
|
||||
alt="MOPC Logo"
|
||||
width={120}
|
||||
height={40}
|
||||
className="h-8 w-auto"
|
||||
priority
|
||||
/>
|
||||
{textSuffix && (
|
||||
<span className="text-xs text-muted-foreground">{textSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3', className)}>
|
||||
<Image
|
||||
src="/images/MOPC-blue-small.png"
|
||||
alt="MOPC Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
priority
|
||||
/>
|
||||
{showText && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">MOPC</span>
|
||||
{textSuffix && (
|
||||
<span className="text-xs text-muted-foreground">{textSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/components/shared/page-header.tsx
Normal file
32
src/components/shared/page-header.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/components/shared/project-logo-with-url.tsx
Normal file
44
src/components/shared/project-logo-with-url.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { ProjectLogo } from './project-logo'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
type ProjectLogoWithUrlProps = {
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
logoKey?: string | null
|
||||
}
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
fallback?: 'icon' | 'initials'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project logo component that fetches the URL automatically via tRPC.
|
||||
* Use this in client components when you only have the project data without the URL.
|
||||
*/
|
||||
export function ProjectLogoWithUrl({
|
||||
project,
|
||||
size = 'md',
|
||||
fallback = 'icon',
|
||||
className,
|
||||
}: ProjectLogoWithUrlProps) {
|
||||
const { data: logoUrl } = trpc.logo.getUrl.useQuery(
|
||||
{ projectId: project.id },
|
||||
{
|
||||
enabled: !!project.logoKey,
|
||||
staleTime: 60 * 1000, // Cache for 1 minute
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={logoUrl}
|
||||
size={size}
|
||||
fallback={fallback}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
src/components/shared/project-logo.tsx
Normal file
90
src/components/shared/project-logo.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn, getInitials } from '@/lib/utils'
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
|
||||
type ProjectLogoProps = {
|
||||
project: {
|
||||
title: string
|
||||
logoKey?: string | null
|
||||
}
|
||||
logoUrl?: string | null
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
fallback?: 'icon' | 'initials'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-16 w-16',
|
||||
}
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-8 w-8',
|
||||
}
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-lg',
|
||||
}
|
||||
|
||||
export function ProjectLogo({
|
||||
project,
|
||||
logoUrl,
|
||||
size = 'md',
|
||||
fallback = 'icon',
|
||||
className,
|
||||
}: ProjectLogoProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const initials = getInitials(project.title)
|
||||
|
||||
// Reset error state when logoUrl changes
|
||||
useEffect(() => {
|
||||
setImageError(false)
|
||||
}, [logoUrl])
|
||||
|
||||
const showImage = logoUrl && !imageError
|
||||
|
||||
if (showImage) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-lg bg-muted',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={`${project.title} logo`}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg bg-muted',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{fallback === 'icon' ? (
|
||||
<ClipboardList className={cn('text-muted-foreground', iconSizeClasses[size])} />
|
||||
) : (
|
||||
<span className={cn('font-medium text-muted-foreground', textSizeClasses[size])}>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/components/shared/status-tracker.tsx
Normal file
159
src/components/shared/status-tracker.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle, Circle, Clock } from 'lucide-react'
|
||||
|
||||
interface TimelineItem {
|
||||
status: string
|
||||
label: string
|
||||
date: Date | string | null
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface StatusTrackerProps {
|
||||
timeline: TimelineItem[]
|
||||
currentStatus: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusTracker({
|
||||
timeline,
|
||||
currentStatus,
|
||||
className,
|
||||
}: StatusTrackerProps) {
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return null
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className="space-y-0">
|
||||
{timeline.map((item, index) => {
|
||||
const isCompleted = item.completed
|
||||
const isCurrent =
|
||||
isCompleted && !timeline[index + 1]?.completed
|
||||
const isPending = !isCompleted
|
||||
|
||||
return (
|
||||
<div key={item.status} className="relative flex gap-4">
|
||||
{/* Vertical line */}
|
||||
{index < timeline.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-[15px] top-[32px] h-full w-0.5',
|
||||
isCompleted ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{isCompleted ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full',
|
||||
isCurrent
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-primary/20 text-primary'
|
||||
)}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<Clock className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-muted bg-background">
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
'font-medium',
|
||||
isPending && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.date && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(item.date)}
|
||||
</p>
|
||||
)}
|
||||
{isPending && !isCurrent && (
|
||||
<p className="text-sm text-muted-foreground">Pending</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact horizontal version
|
||||
interface StatusBarProps {
|
||||
status: string
|
||||
statuses: { value: string; label: string }[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBar({ status, statuses, className }: StatusBarProps) {
|
||||
const currentIndex = statuses.findIndex((s) => s.value === status)
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{statuses.map((s, index) => {
|
||||
const isCompleted = index <= currentIndex
|
||||
const isCurrent = index === currentIndex
|
||||
|
||||
return (
|
||||
<div key={s.value} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8',
|
||||
isCompleted ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
|
||||
isCurrent
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: isCompleted
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isCompleted && !isCurrent && (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
)}
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
361
src/components/shared/tag-input.tsx
Normal file
361
src/components/shared/tag-input.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { X, ChevronsUpDown, Check, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
category: string | null
|
||||
color: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface TagInputProps {
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
placeholder?: string
|
||||
maxTags?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
showCategories?: boolean
|
||||
}
|
||||
|
||||
export function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select tags...',
|
||||
maxTags,
|
||||
disabled = false,
|
||||
className,
|
||||
showCategories = true,
|
||||
}: TagInputProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const tags = tagsData?.tags || []
|
||||
|
||||
// Group tags by category
|
||||
const tagsByCategory = tags.reduce(
|
||||
(acc, tag) => {
|
||||
const category = tag.category || 'Other'
|
||||
if (!acc[category]) {
|
||||
acc[category] = []
|
||||
}
|
||||
acc[category].push(tag)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Tag[]>
|
||||
)
|
||||
|
||||
// Filter tags based on search
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
// Group filtered tags by category
|
||||
const filteredByCategory = filteredTags.reduce(
|
||||
(acc, tag) => {
|
||||
const category = tag.category || 'Other'
|
||||
if (!acc[category]) {
|
||||
acc[category] = []
|
||||
}
|
||||
acc[category].push(tag)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Tag[]>
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(tagName: string) => {
|
||||
if (value.includes(tagName)) {
|
||||
onChange(value.filter((t) => t !== tagName))
|
||||
} else {
|
||||
if (maxTags && value.length >= maxTags) return
|
||||
onChange([...value, tagName])
|
||||
}
|
||||
setSearch('')
|
||||
},
|
||||
[value, onChange, maxTags]
|
||||
)
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(tagName: string) => {
|
||||
onChange(value.filter((t) => t !== tagName))
|
||||
},
|
||||
[value, onChange]
|
||||
)
|
||||
|
||||
// Get tag details for selected tags
|
||||
const selectedTagDetails = value
|
||||
.map((name) => tags.find((t) => t.name === name))
|
||||
.filter((t) => t != null)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full justify-between font-normal',
|
||||
!value.length && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value.length > 0 ? `${value.length} tag(s) selected` : placeholder}
|
||||
{isLoading ? (
|
||||
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
|
||||
) : (
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
placeholder="Search tags..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Loading tags...
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<CommandEmpty>No tags found.</CommandEmpty>
|
||||
) : showCategories ? (
|
||||
// Show grouped by category
|
||||
Object.entries(filteredByCategory)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([category, categoryTags]) => (
|
||||
<CommandGroup key={category} heading={category}>
|
||||
{categoryTags.map((tag) => {
|
||||
const isSelected = value.includes(tag.name)
|
||||
const isDisabled =
|
||||
!isSelected && !!maxTags && value.length >= maxTags
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={tag.name}
|
||||
onSelect={() => handleSelect(tag.name)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
isDisabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: tag.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
{tag.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
- {tag.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))
|
||||
) : (
|
||||
// Flat list
|
||||
<CommandGroup>
|
||||
{filteredTags.map((tag) => {
|
||||
const isSelected = value.includes(tag.name)
|
||||
const isDisabled =
|
||||
!isSelected && !!maxTags && value.length >= maxTags
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={tag.name}
|
||||
onSelect={() => handleSelect(tag.name)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
isDisabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: tag.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected tags display */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedTagDetails.length > 0
|
||||
? selectedTagDetails.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="gap-1 pr-1"
|
||||
style={{
|
||||
backgroundColor: tag.color
|
||||
? `${tag.color}20`
|
||||
: undefined,
|
||||
borderColor: tag.color || undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color || '#6b7280' }}
|
||||
/>
|
||||
{tag.name}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(tag.name)}
|
||||
className="ml-1 rounded-full p-0.5 hover:bg-muted"
|
||||
aria-label={`Remove ${tag.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))
|
||||
: // Fallback for tags not found in system (legacy data)
|
||||
value.map((tagName) => (
|
||||
<Badge key={tagName} variant="secondary" className="gap-1 pr-1">
|
||||
{tagName}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(tagName)}
|
||||
className="ml-1 rounded-full p-0.5 hover:bg-muted"
|
||||
aria-label={`Remove ${tagName}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maxTags && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{value.length} / {maxTags} tags selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple variant for inline display (read-only)
|
||||
interface TagDisplayProps {
|
||||
tags: string[]
|
||||
className?: string
|
||||
maxDisplay?: number
|
||||
}
|
||||
|
||||
export function TagDisplay({
|
||||
tags,
|
||||
className,
|
||||
maxDisplay = 5,
|
||||
}: TagDisplayProps) {
|
||||
const { data: tagsData } = trpc.tag.list.useQuery({ isActive: true })
|
||||
const allTags = tagsData?.tags || []
|
||||
|
||||
const displayTags = tags.slice(0, maxDisplay)
|
||||
const remaining = tags.length - maxDisplay
|
||||
|
||||
const getTagColor = (name: string) => {
|
||||
const tag = allTags.find((t) => t.name === name)
|
||||
return tag?.color || '#6b7280'
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<span className={cn('text-muted-foreground text-sm', className)}>
|
||||
No tags
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-1', className)}>
|
||||
{displayTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${getTagColor(tag)}20`,
|
||||
borderColor: getTagColor(tag),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full mr-1"
|
||||
style={{ backgroundColor: getTagColor(tag) }}
|
||||
/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{remaining} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
src/components/shared/user-avatar.tsx
Normal file
87
src/components/shared/user-avatar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { cn, getInitials } from '@/lib/utils'
|
||||
import { Camera } from 'lucide-react'
|
||||
|
||||
type UserAvatarProps = {
|
||||
user: {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
profileImageKey?: string | null
|
||||
}
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
showEditOverlay?: boolean
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'h-6 w-6',
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16',
|
||||
}
|
||||
|
||||
const textSizeClasses = {
|
||||
xs: 'text-[10px]',
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
xl: 'text-lg',
|
||||
}
|
||||
|
||||
const iconSizeClasses = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-6 w-6',
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
user,
|
||||
size = 'md',
|
||||
className,
|
||||
showEditOverlay = false,
|
||||
avatarUrl,
|
||||
}: UserAvatarProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const initials = getInitials(user.name || user.email || 'U')
|
||||
|
||||
// Reset error state when avatarUrl changes
|
||||
useEffect(() => {
|
||||
setImageError(false)
|
||||
}, [avatarUrl])
|
||||
|
||||
return (
|
||||
<div className={cn('relative group', className)}>
|
||||
<Avatar className={cn(sizeClasses[size])}>
|
||||
{avatarUrl && !imageError ? (
|
||||
<AvatarImage
|
||||
src={avatarUrl}
|
||||
alt={user.name || 'User avatar'}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className={cn(textSizeClasses[size])}>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{showEditOverlay && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center rounded-full',
|
||||
'bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<Camera className={cn('text-white', iconSizeClasses[size])} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user