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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &quot;{project.title}&quot;. 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}