新增資料庫架構
This commit is contained in:
161
components/content-moderation-feedback.tsx
Normal file
161
components/content-moderation-feedback.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { AlertTriangle, Lightbulb, CheckCircle, XCircle, RefreshCw } from "lucide-react"
|
||||
import type { ModerationResult } from "@/lib/content-moderation"
|
||||
|
||||
interface ContentModerationFeedbackProps {
|
||||
result: ModerationResult
|
||||
onRetry: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ContentModerationFeedback({ result, onRetry, className = "" }: ContentModerationFeedbackProps) {
|
||||
if (result.issues.length === 0 && result.suggestions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "border-red-500/50 bg-red-900/20"
|
||||
case "medium":
|
||||
return "border-yellow-500/50 bg-yellow-900/20"
|
||||
case "low":
|
||||
return "border-blue-500/50 bg-blue-900/20"
|
||||
default:
|
||||
return "border-slate-500/50 bg-slate-900/20"
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return <XCircle className="w-5 h-5 text-red-400" />
|
||||
case "medium":
|
||||
return <AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||
case "low":
|
||||
return <Lightbulb className="w-5 h-5 text-blue-400" />
|
||||
default:
|
||||
return <CheckCircle className="w-5 h-5 text-green-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityTitle = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "內容審核未通過"
|
||||
case "medium":
|
||||
return "內容建議優化"
|
||||
case "low":
|
||||
return "內容建議"
|
||||
default:
|
||||
return "內容檢查"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`${getSeverityColor(result.severity)} backdrop-blur-sm border ${className}`}>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* 標題區域 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{getSeverityIcon(result.severity)}
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-white text-sm md:text-base">{getSeverityTitle(result.severity)}</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge
|
||||
className={`text-xs px-2 py-0.5 ${
|
||||
result.severity === "high"
|
||||
? "bg-red-500/20 text-red-200 border-red-400/30"
|
||||
: result.severity === "medium"
|
||||
? "bg-yellow-500/20 text-yellow-200 border-yellow-400/30"
|
||||
: "bg-blue-500/20 text-blue-200 border-blue-400/30"
|
||||
}`}
|
||||
>
|
||||
{result.severity === "high" ? "需要修改" : result.severity === "medium" ? "建議優化" : "輕微建議"}
|
||||
</Badge>
|
||||
{!result.isAppropriate && (
|
||||
<Badge className="bg-red-500/20 text-red-200 border-red-400/30 text-xs px-2 py-0.5">無法提交</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!result.isAppropriate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="text-blue-200 hover:text-white hover:bg-blue-800/50 px-3"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
重新檢查
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 問題列表 */}
|
||||
{result.issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-red-200 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
發現的問題:
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{result.issues.map((issue, index) => (
|
||||
<li key={index} className="text-sm text-red-100 flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 建議列表 */}
|
||||
{result.suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-blue-200 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
改善建議:
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{result.suggestions.map((suggestion, index) => (
|
||||
<li key={index} className="text-sm text-blue-100 flex items-start gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0"></div>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 被阻擋的詞彙 */}
|
||||
{result.blockedWords.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-red-200">需要修改的詞彙:</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.blockedWords.map((word, index) => (
|
||||
<Badge key={index} className="bg-red-500/20 text-red-200 border-red-400/30 text-xs px-2 py-1">
|
||||
{word}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 鼓勵訊息 */}
|
||||
{!result.isAppropriate && (
|
||||
<Alert className="border-cyan-500/30 bg-cyan-900/20">
|
||||
<Lightbulb className="h-4 w-4 text-cyan-400" />
|
||||
<AlertDescription className="text-cyan-100 text-sm">
|
||||
我們理解工作中的挫折和困難。請使用更建設性的語言來描述遇到的問題,
|
||||
這樣我們能更好地幫助你找到解決方案。你的每一個真實困擾都很重要!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
214
components/image-gallery.tsx
Normal file
214
components/image-gallery.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, RotateCw, Maximize2 } from "lucide-react"
|
||||
import { formatFileSize, type ImageFile } from "@/lib/image-utils"
|
||||
|
||||
interface ImageGalleryProps {
|
||||
images: ImageFile[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ImageModalProps {
|
||||
images: ImageFile[]
|
||||
currentIndex: number
|
||||
onClose: () => void
|
||||
onNavigate: (index: number) => void
|
||||
}
|
||||
|
||||
function ImageModal({ images, currentIndex, onClose, onNavigate }: ImageModalProps) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
const currentImage = images[currentIndex]
|
||||
|
||||
const handlePrevious = () => {
|
||||
const newIndex = currentIndex > 0 ? currentIndex - 1 : images.length - 1
|
||||
onNavigate(newIndex)
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const newIndex = currentIndex < images.length - 1 ? currentIndex + 1 : 0
|
||||
onNavigate(newIndex)
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
}
|
||||
|
||||
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3))
|
||||
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.25))
|
||||
const handleRotate = () => setRotation((prev) => (prev + 90) % 360)
|
||||
|
||||
const handleDownload = () => {
|
||||
// 創建下載連結
|
||||
const link = document.createElement("a")
|
||||
link.href = currentImage.url
|
||||
link.download = currentImage.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center">
|
||||
{/* 關閉按鈕 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
{/* 工具列 */}
|
||||
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
||||
<Badge className="bg-black/50 text-white border-white/20">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 bg-black/50 rounded-lg p-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} className="text-white hover:bg-white/20 p-2">
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-white text-sm px-2">{Math.round(zoom * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} className="text-white hover:bg-white/20 p-2">
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleRotate} className="text-white hover:bg-white/20 p-2">
|
||||
<RotateCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDownload} className="text-white hover:bg-white/20 p-2">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 導航按鈕 */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/20 p-3"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/20 p-3"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 圖片容器 */}
|
||||
<div className="flex items-center justify-center w-full h-full p-16">
|
||||
<img
|
||||
src={currentImage.url || "/placeholder.svg"}
|
||||
alt={currentImage.name}
|
||||
className="max-w-full max-h-full object-contain transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 如果圖片載入失敗,顯示錯誤訊息
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/placeholder.svg?height=400&width=400&text=圖片載入失敗"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 圖片資訊 */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-10">
|
||||
<Card className="bg-black/50 backdrop-blur-sm border-white/20">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-white text-sm">
|
||||
<div className="font-medium truncate">{currentImage.name}</div>
|
||||
<div className="text-white/70 text-xs mt-1">
|
||||
{formatFileSize(currentImage.size)} • {currentImage.type}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ImageGallery({ images, className = "" }: ImageGalleryProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
|
||||
if (images.length === 0) return null
|
||||
|
||||
const openModal = (index: number) => {
|
||||
setCurrentImageIndex(index)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-blue-500/20 text-blue-200 border border-blue-400/30">
|
||||
📷 相關圖片 ({images.length})
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.id} className="relative group cursor-pointer" onClick={() => openModal(index)}>
|
||||
<div className="aspect-square rounded-lg overflow-hidden bg-slate-700/50 border border-slate-600/50 hover:border-cyan-400/50 transition-all duration-200">
|
||||
<img
|
||||
src={image.url || "/placeholder.svg"}
|
||||
alt={image.name}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
// 如果圖片載入失敗,顯示預設圖片
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/placeholder.svg?height=200&width=200&text=圖片載入失敗"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 懸停覆蓋層 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<Maximize2 className="w-6 h-6 mx-auto mb-1" />
|
||||
<div className="text-xs">點擊放大</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 檔案名稱 */}
|
||||
<div className="mt-1 text-xs text-slate-400 truncate" title={image.name}>
|
||||
{image.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 圖片模態框 */}
|
||||
{modalOpen && (
|
||||
<ImageModal
|
||||
images={images}
|
||||
currentIndex={currentImageIndex}
|
||||
onClose={closeModal}
|
||||
onNavigate={setCurrentImageIndex}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
334
components/image-upload.tsx
Normal file
334
components/image-upload.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Upload, X, AlertCircle, Eye, RotateCcw, FileImage } from "lucide-react"
|
||||
import {
|
||||
validateImageFile,
|
||||
createImageFile,
|
||||
revokeImageUrl,
|
||||
formatFileSize,
|
||||
compressImage,
|
||||
MAX_FILES_PER_UPLOAD,
|
||||
MAX_TOTAL_FILES,
|
||||
MAX_FILE_SIZE,
|
||||
type ImageFile,
|
||||
} from "@/lib/image-utils"
|
||||
import { soundManager } from "@/lib/sound-effects"
|
||||
|
||||
interface ImageUploadProps {
|
||||
images: ImageFile[]
|
||||
onImagesChange: (images: ImageFile[]) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ImageUpload({ images, onImagesChange, disabled = false, className = "" }: ImageUploadProps) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled) return
|
||||
|
||||
setUploading(true)
|
||||
setErrors([])
|
||||
|
||||
const newErrors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
|
||||
// 檢查總數量限制
|
||||
if (images.length + files.length > MAX_TOTAL_FILES) {
|
||||
newErrors.push(`最多只能上傳 ${MAX_TOTAL_FILES} 張圖片`)
|
||||
setErrors(newErrors)
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 檢查單次上傳數量
|
||||
if (files.length > MAX_FILES_PER_UPLOAD) {
|
||||
newErrors.push(`單次最多只能上傳 ${MAX_FILES_PER_UPLOAD} 張圖片`)
|
||||
}
|
||||
|
||||
// 驗證每個檔案
|
||||
const filesToProcess = Array.from(files).slice(0, MAX_FILES_PER_UPLOAD)
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const validation = validateImageFile(file)
|
||||
if (validation.isValid) {
|
||||
validFiles.push(file)
|
||||
} else {
|
||||
newErrors.push(`${file.name}: ${validation.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (newErrors.length > 0) {
|
||||
setErrors(newErrors)
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
try {
|
||||
// 壓縮並創建圖片物件
|
||||
const newImageFiles: ImageFile[] = []
|
||||
|
||||
for (const file of validFiles) {
|
||||
let processedFile = file
|
||||
|
||||
// 如果檔案過大,嘗試壓縮
|
||||
if (file.size > MAX_FILE_SIZE * 0.8) {
|
||||
try {
|
||||
processedFile = await compressImage(file, 1920, 0.8)
|
||||
} catch (error) {
|
||||
console.warn("圖片壓縮失敗,使用原檔案:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 轉換為 base64 格式
|
||||
const imageFile = await createImageFile(processedFile)
|
||||
newImageFiles.push(imageFile)
|
||||
}
|
||||
|
||||
onImagesChange([...images, ...newImageFiles])
|
||||
await soundManager.play("success")
|
||||
} catch (error) {
|
||||
newErrors.push("圖片處理失敗,請重試")
|
||||
setErrors(newErrors)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
},
|
||||
[images, onImagesChange, disabled],
|
||||
)
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragIn = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOut = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
},
|
||||
[handleFiles],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFiles(e.target.files)
|
||||
}
|
||||
// 清空 input 值,允許重複選擇相同檔案
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleFiles],
|
||||
)
|
||||
|
||||
const removeImage = useCallback(
|
||||
async (imageId: string) => {
|
||||
const imageToRemove = images.find((img) => img.id === imageId)
|
||||
if (imageToRemove) {
|
||||
revokeImageUrl(imageToRemove)
|
||||
onImagesChange(images.filter((img) => img.id !== imageId))
|
||||
await soundManager.play("click")
|
||||
}
|
||||
},
|
||||
[images, onImagesChange],
|
||||
)
|
||||
|
||||
const clearAllImages = useCallback(async () => {
|
||||
images.forEach(revokeImageUrl)
|
||||
onImagesChange([])
|
||||
setErrors([])
|
||||
await soundManager.play("click")
|
||||
}, [images, onImagesChange])
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 上傳區域 */}
|
||||
<Card
|
||||
className={`
|
||||
relative overflow-hidden transition-all duration-200 cursor-pointer
|
||||
${
|
||||
dragActive
|
||||
? "border-cyan-400 bg-cyan-500/10 scale-[1.02]"
|
||||
: "border-slate-600/50 bg-slate-700/30 hover:bg-slate-600/40"
|
||||
}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""}
|
||||
`}
|
||||
onDragEnter={handleDragIn}
|
||||
onDragLeave={handleDragOut}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<CardContent className="p-6 md:p-8 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className={`
|
||||
w-16 h-16 rounded-full flex items-center justify-center transition-all duration-200
|
||||
${dragActive ? "bg-cyan-500/20 text-cyan-300 scale-110" : "bg-slate-600/50 text-slate-300"}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="w-6 h-6 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-white">{dragActive ? "放開以上傳圖片" : "上傳相關圖片"}</h3>
|
||||
<p className="text-sm text-slate-300">{uploading ? "正在處理圖片..." : "拖拽圖片到此處或點擊選擇檔案"}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 text-xs text-slate-400">
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
支援 JPG、PNG、WebP、GIF
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
單檔最大 5MB
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
最多 {MAX_TOTAL_FILES} 張
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 錯誤訊息 */}
|
||||
{errors.length > 0 && (
|
||||
<Alert className="border-red-500/50 bg-red-900/20">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-100">
|
||||
<div className="space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
• {error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 已上傳的圖片 */}
|
||||
{images.length > 0 && (
|
||||
<Card className="bg-slate-800/50 backdrop-blur-sm border border-slate-600/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileImage className="w-5 h-5 text-blue-400" />
|
||||
<h4 className="font-semibold text-white">
|
||||
已上傳圖片 ({images.length}/{MAX_TOTAL_FILES})
|
||||
</h4>
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllImages}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
清空全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="aspect-square rounded-lg overflow-hidden bg-slate-700/50 border border-slate-600/50">
|
||||
<img
|
||||
src={image.url || "/placeholder.svg"}
|
||||
alt={image.name}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
// 如果圖片載入失敗,顯示預設圖片
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/placeholder.svg?height=200&width=200&text=圖片載入失敗"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 懸停覆蓋層 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white hover:bg-white/20 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// 在新視窗中開啟圖片
|
||||
window.open(image.url, "_blank")
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeImage(image.id)
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 檔案資訊 */}
|
||||
<div className="mt-2 text-xs text-slate-400 truncate">
|
||||
<div className="font-medium text-slate-300 truncate" title={image.name}>
|
||||
{image.name}
|
||||
</div>
|
||||
<div>{formatFileSize(image.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
314
components/migration-dialog.tsx
Normal file
314
components/migration-dialog.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Database, Upload, CheckCircle, XCircle, AlertTriangle, Loader2, Trash2, RefreshCw } from "lucide-react"
|
||||
import { MigrationService, testSupabaseConnection } from "@/lib/supabase-service"
|
||||
|
||||
interface MigrationDialogProps {
|
||||
onComplete?: () => void
|
||||
onSkip?: () => void
|
||||
}
|
||||
|
||||
export default function MigrationDialog({ onComplete, onSkip }: MigrationDialogProps) {
|
||||
const [step, setStep] = useState<"check" | "migrate" | "complete" | "error">("check")
|
||||
const [localDataCount, setLocalDataCount] = useState(0)
|
||||
const [migrationResult, setMigrationResult] = useState<{
|
||||
success: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
} | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
checkLocalData()
|
||||
checkConnection()
|
||||
}, [])
|
||||
|
||||
const checkLocalData = () => {
|
||||
try {
|
||||
const wishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
setLocalDataCount(wishes.length)
|
||||
} catch (error) {
|
||||
console.error("Error checking local data:", error)
|
||||
setLocalDataCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
const checkConnection = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const connected = await testSupabaseConnection()
|
||||
setIsConnected(connected)
|
||||
} catch (error) {
|
||||
console.error("Connection check failed:", error)
|
||||
setIsConnected(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startMigration = async () => {
|
||||
if (!isConnected) {
|
||||
alert("請先確保 Supabase 連接正常")
|
||||
return
|
||||
}
|
||||
|
||||
setStep("migrate")
|
||||
setIsLoading(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
// 模擬進度更新
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => Math.min(prev + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const result = await MigrationService.migrateWishesFromLocalStorage()
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setProgress(100)
|
||||
|
||||
setMigrationResult(result)
|
||||
|
||||
if (result.success > 0) {
|
||||
setStep("complete")
|
||||
} else {
|
||||
setStep("error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error)
|
||||
setMigrationResult({
|
||||
success: 0,
|
||||
failed: localDataCount,
|
||||
errors: [`遷移過程失敗: ${error}`],
|
||||
})
|
||||
setStep("error")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearLocalData = () => {
|
||||
if (confirm("確定要清除本地數據嗎?此操作無法復原。")) {
|
||||
MigrationService.clearLocalStorageData()
|
||||
setLocalDataCount(0)
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
const skipMigration = () => {
|
||||
if (confirm("跳過遷移將繼續使用本地存儲。確定要跳過嗎?")) {
|
||||
onSkip?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (localDataCount === 0) {
|
||||
return (
|
||||
<Card className="bg-slate-800/50 backdrop-blur-sm border border-green-600/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-3">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
準備就緒
|
||||
</CardTitle>
|
||||
<CardDescription className="text-green-200">沒有發現本地數據,可以直接開始使用 Supabase</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white"
|
||||
>
|
||||
開始使用
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-800/50 backdrop-blur-sm border border-blue-600/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-3">
|
||||
<Database className="w-6 h-6 text-blue-400" />
|
||||
數據遷移到 Supabase
|
||||
</CardTitle>
|
||||
<CardDescription className="text-blue-200">
|
||||
發現 {localDataCount} 個本地困擾案例,建議遷移到雲端數據庫
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 連接狀態 */}
|
||||
<div className="flex items-center justify-between p-3 bg-slate-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${isConnected ? "bg-green-400" : "bg-red-400"}`}></div>
|
||||
<span className="text-white text-sm">Supabase 連接狀態</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={isConnected ? "bg-green-500/20 text-green-200" : "bg-red-500/20 text-red-200"}>
|
||||
{isConnected ? "已連接" : "未連接"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkConnection}
|
||||
disabled={isLoading}
|
||||
className="text-blue-200 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === "check" && (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-blue-500/50 bg-blue-900/20">
|
||||
<AlertTriangle className="h-4 w-4 text-blue-400" />
|
||||
<AlertDescription className="text-blue-100">
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>遷移優勢:</strong>
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>• 數據永久保存,不會因清除瀏覽器而丟失</li>
|
||||
<li>• 支援多設備同步訪問</li>
|
||||
<li>• 更好的性能和穩定性</li>
|
||||
<li>• 支援更多用戶同時使用</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={startMigration}
|
||||
disabled={!isConnected || isLoading}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-cyan-600 hover:from-blue-600 hover:to-cyan-700 text-white"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
開始遷移
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={skipMigration}
|
||||
className="border-slate-600 text-slate-300 hover:bg-slate-700 bg-transparent"
|
||||
>
|
||||
暫時跳過
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "migrate" && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400 mx-auto mb-2" />
|
||||
<p className="text-white">正在遷移數據...</p>
|
||||
</div>
|
||||
<Progress value={progress} className="w-full" />
|
||||
<p className="text-sm text-slate-300 text-center">請稍候,正在將 {localDataCount} 個案例遷移到雲端</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "complete" && migrationResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">遷移完成!</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 bg-green-900/20 rounded-lg border border-green-600/30">
|
||||
<div className="text-2xl font-bold text-green-400">{migrationResult.success}</div>
|
||||
<div className="text-sm text-green-200">成功遷移</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-900/20 rounded-lg border border-red-600/30">
|
||||
<div className="text-2xl font-bold text-red-400">{migrationResult.failed}</div>
|
||||
<div className="text-sm text-red-200">遷移失敗</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{migrationResult.errors.length > 0 && (
|
||||
<Alert className="border-yellow-500/50 bg-yellow-900/20">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-400" />
|
||||
<AlertDescription className="text-yellow-100">
|
||||
<details>
|
||||
<summary className="cursor-pointer">查看錯誤詳情</summary>
|
||||
<div className="mt-2 text-xs space-y-1">
|
||||
{migrationResult.errors.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={clearLocalData}
|
||||
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
清除本地數據並完成
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onComplete}
|
||||
className="border-slate-600 text-slate-300 hover:bg-slate-700 bg-transparent"
|
||||
>
|
||||
保留本地數據
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "error" && migrationResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">遷移失敗</h3>
|
||||
</div>
|
||||
|
||||
<Alert className="border-red-500/50 bg-red-900/20">
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-100">
|
||||
<div className="space-y-2">
|
||||
<p>遷移過程中遇到問題:</p>
|
||||
<div className="text-xs space-y-1 ml-4">
|
||||
{migrationResult.errors.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={startMigration}
|
||||
disabled={!isConnected}
|
||||
className="flex-1 bg-gradient-to-r from-blue-500 to-cyan-600 hover:from-blue-600 hover:to-cyan-700 text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重試遷移
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={skipMigration}
|
||||
className="border-slate-600 text-slate-300 hover:bg-slate-700 bg-transparent"
|
||||
>
|
||||
跳過遷移
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
394
components/supabase-image-upload.tsx
Normal file
394
components/supabase-image-upload.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Upload, X, AlertCircle, Eye, RotateCcw, FileImage, Cloud, Loader2 } from "lucide-react"
|
||||
import {
|
||||
SupabaseImageService,
|
||||
ImageCompressionService,
|
||||
type SupabaseImageFile,
|
||||
type BatchUploadResult,
|
||||
} from "@/lib/supabase-image-utils"
|
||||
import { soundManager } from "@/lib/sound-effects"
|
||||
|
||||
interface SupabaseImageUploadProps {
|
||||
images: SupabaseImageFile[]
|
||||
onImagesChange: (images: SupabaseImageFile[]) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
maxFiles?: number
|
||||
}
|
||||
|
||||
export default function SupabaseImageUpload({
|
||||
images,
|
||||
onImagesChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
maxFiles = 10,
|
||||
}: SupabaseImageUploadProps) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [uploadStats, setUploadStats] = useState<{ total: 0; completed: 0; failed: 0 }>({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled) return
|
||||
|
||||
setUploading(true)
|
||||
setErrors([])
|
||||
setUploadProgress(0)
|
||||
|
||||
const fileArray = Array.from(files)
|
||||
const remainingSlots = maxFiles - images.length
|
||||
|
||||
if (fileArray.length > remainingSlots) {
|
||||
setErrors([`最多只能再上傳 ${remainingSlots} 張圖片`])
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 初始化統計
|
||||
setUploadStats({ total: fileArray.length, completed: 0, failed: 0 })
|
||||
|
||||
// 先壓縮圖片
|
||||
setUploadProgress(10)
|
||||
const compressedFiles = await ImageCompressionService.compressImages(fileArray)
|
||||
setUploadProgress(20)
|
||||
|
||||
// 批量上傳到 Supabase Storage
|
||||
const uploadResult: BatchUploadResult = await SupabaseImageService.uploadImages(compressedFiles)
|
||||
|
||||
// 更新統計
|
||||
setUploadStats({
|
||||
total: uploadResult.total,
|
||||
completed: uploadResult.successful.length,
|
||||
failed: uploadResult.failed.length,
|
||||
})
|
||||
|
||||
// 處理上傳結果
|
||||
if (uploadResult.successful.length > 0) {
|
||||
onImagesChange([...images, ...uploadResult.successful])
|
||||
await soundManager.play("success")
|
||||
}
|
||||
|
||||
// 處理錯誤
|
||||
if (uploadResult.failed.length > 0) {
|
||||
const errorMessages = uploadResult.failed.map((failure) => `${failure.file.name}: ${failure.error}`)
|
||||
setErrors(errorMessages)
|
||||
}
|
||||
|
||||
setUploadProgress(100)
|
||||
} catch (error) {
|
||||
console.error("Upload process error:", error)
|
||||
setErrors([`上傳過程中發生錯誤: ${error}`])
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
setUploadStats({ total: 0, completed: 0, failed: 0 })
|
||||
}, 2000)
|
||||
}
|
||||
},
|
||||
[images, onImagesChange, disabled, maxFiles],
|
||||
)
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragIn = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setDragActive(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOut = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
},
|
||||
[handleFiles],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFiles(e.target.files)
|
||||
}
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleFiles],
|
||||
)
|
||||
|
||||
const removeImage = useCallback(
|
||||
async (imageId: string) => {
|
||||
const imageToRemove = images.find((img) => img.id === imageId)
|
||||
if (imageToRemove) {
|
||||
// 從 Supabase Storage 刪除圖片
|
||||
const deleteResult = await SupabaseImageService.deleteImage(imageToRemove.storage_path)
|
||||
if (!deleteResult.success) {
|
||||
console.warn("Failed to delete image from storage:", deleteResult.error)
|
||||
// 即使刪除失敗,也從列表中移除(避免阻塞用戶操作)
|
||||
}
|
||||
|
||||
onImagesChange(images.filter((img) => img.id !== imageId))
|
||||
await soundManager.play("click")
|
||||
}
|
||||
},
|
||||
[images, onImagesChange],
|
||||
)
|
||||
|
||||
const clearAllImages = useCallback(async () => {
|
||||
if (images.length === 0) return
|
||||
|
||||
const storagePaths = images.map((img) => img.storage_path)
|
||||
const deleteResult = await SupabaseImageService.deleteImages(storagePaths)
|
||||
|
||||
if (!deleteResult.success) {
|
||||
console.warn("Failed to delete some images from storage:", deleteResult.error)
|
||||
}
|
||||
|
||||
onImagesChange([])
|
||||
setErrors([])
|
||||
await soundManager.play("click")
|
||||
}, [images, onImagesChange])
|
||||
|
||||
const 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 Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* 上傳區域 */}
|
||||
<Card
|
||||
className={`
|
||||
relative overflow-hidden transition-all duration-200 cursor-pointer
|
||||
${
|
||||
dragActive
|
||||
? "border-cyan-400 bg-cyan-500/10 scale-[1.02]"
|
||||
: "border-slate-600/50 bg-slate-700/30 hover:bg-slate-600/40"
|
||||
}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""}
|
||||
`}
|
||||
onDragEnter={handleDragIn}
|
||||
onDragLeave={handleDragOut}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && !uploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<CardContent className="p-6 md:p-8 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className={`
|
||||
w-16 h-16 rounded-full flex items-center justify-center transition-all duration-200
|
||||
${dragActive ? "bg-cyan-500/20 text-cyan-300 scale-110" : "bg-slate-600/50 text-slate-300"}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Upload className="w-8 h-8" />
|
||||
<Cloud className="w-4 h-4 absolute -bottom-1 -right-1 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{uploading ? "正在上傳到雲端..." : dragActive ? "放開以上傳圖片" : "上傳相關圖片到雲端"}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-300">
|
||||
{uploading ? "圖片將安全存儲在 Supabase 雲端" : "拖拽圖片到此處或點擊選擇檔案"}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 text-xs text-slate-400">
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
<Cloud className="w-3 h-3 mr-1" />
|
||||
雲端存儲
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
支援 JPG、PNG、WebP、GIF
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
單檔最大 5MB
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-600/50 text-slate-300">
|
||||
最多 {maxFiles} 張
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上傳進度 */}
|
||||
{uploading && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<Progress value={uploadProgress} className="w-full" />
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>
|
||||
{uploadStats.completed}/{uploadStats.total} 完成
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
{uploadStats.failed > 0 && (
|
||||
<div className="text-xs text-red-400">{uploadStats.failed} 個檔案上傳失敗</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
disabled={disabled || uploading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 錯誤訊息 */}
|
||||
{errors.length > 0 && (
|
||||
<Alert className="border-red-500/50 bg-red-900/20">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<AlertDescription className="text-red-100">
|
||||
<div className="space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index} className="text-sm">
|
||||
• {error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 已上傳的圖片 */}
|
||||
{images.length > 0 && (
|
||||
<Card className="bg-slate-800/50 backdrop-blur-sm border border-slate-600/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileImage className="w-5 h-5 text-blue-400" />
|
||||
<h4 className="font-semibold text-white">
|
||||
雲端圖片 ({images.length}/{maxFiles})
|
||||
</h4>
|
||||
<Badge className="bg-blue-500/20 text-blue-200 border border-blue-400/30 text-xs px-2 py-1">
|
||||
<Cloud className="w-3 h-3 mr-1" />
|
||||
Supabase
|
||||
</Badge>
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllImages}
|
||||
disabled={uploading}
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
清空全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{images.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<div className="aspect-square rounded-lg overflow-hidden bg-slate-700/50 border border-slate-600/50">
|
||||
<img
|
||||
src={image.public_url || "/placeholder.svg"}
|
||||
alt={image.name}
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = "/placeholder.svg?height=200&width=200&text=圖片載入失敗"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 懸停覆蓋層 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white hover:bg-white/20 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(image.public_url, "_blank")
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeImage(image.id)
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 雲端標識 */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Badge className="bg-blue-500/80 text-white text-xs px-1.5 py-0.5">
|
||||
<Cloud className="w-3 h-3" />
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 檔案資訊 */}
|
||||
<div className="mt-2 text-xs text-slate-400 truncate">
|
||||
<div className="font-medium text-slate-300 truncate" title={image.name}>
|
||||
{image.name}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{formatFileSize(image.size)}</span>
|
||||
<span className="text-blue-400">雲端</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -17,10 +17,10 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-700">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-white" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-slate-700 bg-white ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
@@ -20,9 +20,12 @@ import { categorizeWishMultiple, type Wish } from "@/lib/categorization"
|
||||
import { generateSolutionRecommendations, type SolutionCategory } from "@/lib/solution-recommendations"
|
||||
import { useState, useEffect } from "react"
|
||||
import { soundManager } from "@/lib/sound-effects"
|
||||
import ImageGallery from "@/components/image-gallery"
|
||||
import { restoreImageFile, type ImageFile } from "@/lib/image-utils"
|
||||
import { LikeService } from "@/lib/supabase-service"
|
||||
|
||||
interface WishCardProps {
|
||||
wish: Wish
|
||||
wish: Wish & { images?: any[]; like_count?: number } // 添加圖片支援和點讚數
|
||||
}
|
||||
|
||||
export default function WishCard({ wish }: WishCardProps) {
|
||||
@@ -34,12 +37,29 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
|
||||
// 載入點讚數據
|
||||
useEffect(() => {
|
||||
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
|
||||
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
|
||||
const loadLikeData = async () => {
|
||||
try {
|
||||
// 從 Supabase 獲取用戶已點讚的困擾列表
|
||||
const userLikedWishes = await LikeService.getUserLikedWishes()
|
||||
|
||||
// 設置點讚狀態
|
||||
setHasLiked(userLikedWishes.includes(wish.id))
|
||||
|
||||
// 點讚數從 wish 的 like_count 字段獲取,如果沒有則默認為 0
|
||||
setLikeCount(wish.like_count || 0)
|
||||
} catch (error) {
|
||||
console.error("載入點讚數據失敗:", error)
|
||||
// 如果 Supabase 連接失敗,回退到 localStorage
|
||||
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
|
||||
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
|
||||
|
||||
setLikeCount(likes[wish.id] || 0)
|
||||
setHasLiked(likedWishes.includes(wish.id))
|
||||
}, [wish.id])
|
||||
setLikeCount(likes[wish.id] || 0)
|
||||
setHasLiked(likedWishes.includes(wish.id))
|
||||
}
|
||||
}
|
||||
|
||||
loadLikeData()
|
||||
}, [wish.id, wish.like_count])
|
||||
|
||||
const handleLike = async () => {
|
||||
if (hasLiked || isLiking) return
|
||||
@@ -49,24 +69,46 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
// 播放點讚音效
|
||||
await soundManager.play("click")
|
||||
|
||||
// 更新點讚數據
|
||||
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
|
||||
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
|
||||
try {
|
||||
// 使用 Supabase 點讚服務
|
||||
const success = await LikeService.likeWish(wish.id)
|
||||
|
||||
if (success) {
|
||||
// 更新本地狀態
|
||||
setLikeCount(prev => prev + 1)
|
||||
setHasLiked(true)
|
||||
|
||||
// 播放成功音效
|
||||
setTimeout(async () => {
|
||||
await soundManager.play("success")
|
||||
}, 300)
|
||||
} else {
|
||||
// 已經點讚過
|
||||
console.log("已經點讚過此困擾")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("點讚失敗:", error)
|
||||
|
||||
// 如果 Supabase 失敗,回退到 localStorage
|
||||
const likes = JSON.parse(localStorage.getItem("wishLikes") || "{}")
|
||||
const likedWishes = JSON.parse(localStorage.getItem("userLikedWishes") || "[]")
|
||||
|
||||
likes[wish.id] = (likes[wish.id] || 0) + 1
|
||||
likedWishes.push(wish.id)
|
||||
likes[wish.id] = (likes[wish.id] || 0) + 1
|
||||
likedWishes.push(wish.id)
|
||||
|
||||
localStorage.setItem("wishLikes", JSON.stringify(likes))
|
||||
localStorage.setItem("userLikedWishes", JSON.stringify(likedWishes))
|
||||
localStorage.setItem("wishLikes", JSON.stringify(likes))
|
||||
localStorage.setItem("userLikedWishes", JSON.stringify(likedWishes))
|
||||
|
||||
setLikeCount(likes[wish.id])
|
||||
setHasLiked(true)
|
||||
|
||||
// 播放成功音效
|
||||
setTimeout(async () => {
|
||||
await soundManager.play("success")
|
||||
setLikeCount(likes[wish.id])
|
||||
setHasLiked(true)
|
||||
|
||||
// 播放成功音效
|
||||
setTimeout(async () => {
|
||||
await soundManager.play("success")
|
||||
}, 300)
|
||||
} finally {
|
||||
setIsLiking(false)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -84,6 +126,9 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
// 生成解決方案建議
|
||||
const solutionRecommendation = generateSolutionRecommendations(wish)
|
||||
|
||||
// 轉換圖片數據格式 - 使用 restoreImageFile 恢復圖片
|
||||
const images: ImageFile[] = (wish.images || []).map((img) => restoreImageFile(img))
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case "easy":
|
||||
@@ -220,6 +265,16 @@ export default function WishCard({ wish }: WishCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 圖片展示區域 */}
|
||||
{images.length > 0 && (
|
||||
<div className="relative overflow-hidden rounded-lg md:rounded-xl bg-gradient-to-r from-slate-700/60 to-slate-800/60 border border-slate-600/40 hover:border-green-400/30 p-4 md:p-5 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-green-500/8 to-emerald-500/8"></div>
|
||||
<div className="relative">
|
||||
<ImageGallery images={images} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 共鳴支持區塊 - 新增 */}
|
||||
<div className="relative overflow-hidden rounded-lg md:rounded-xl bg-gradient-to-r from-pink-800/30 to-rose-800/30 border border-pink-600/40 p-3 md:p-4 backdrop-blur-sm transition-all duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/10 to-rose-500/10"></div>
|
||||
|
Reference in New Issue
Block a user