Initial commit: MOPC platform with Docker deployment setup
Some checks failed
Build and Push Docker Image / build (push) Failing after 10s
Some checks failed
Build and Push Docker Image / build (push) Failing after 10s
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user