新增資料庫架構
This commit is contained in:
249
lib/background-music-supabase.ts
Normal file
249
lib/background-music-supabase.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { UserSettingsService } from "./supabase-service"
|
||||
|
||||
// 背景音樂管理系統 - Supabase 版本
|
||||
class BackgroundMusicManagerSupabase {
|
||||
private audio: HTMLAudioElement | null = null
|
||||
private isPlaying = false
|
||||
private enabled = false
|
||||
private volume = 0.3
|
||||
private fadeInterval: NodeJS.Timeout | null = null
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initAudio()
|
||||
}
|
||||
|
||||
// 初始化並載入用戶設定
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
|
||||
try {
|
||||
const settings = await UserSettingsService.getUserSettings()
|
||||
if (settings) {
|
||||
this.volume = settings.background_music_volume
|
||||
this.enabled = settings.background_music_enabled
|
||||
this.isPlaying = false // 不自動播放
|
||||
}
|
||||
this.initialized = true
|
||||
} catch (error) {
|
||||
console.error("Failed to load user settings:", error)
|
||||
// 使用默認設定
|
||||
this.volume = 0.3
|
||||
this.enabled = false
|
||||
this.isPlaying = false
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
private initAudio() {
|
||||
try {
|
||||
this.audio = new Audio("https://hebbkx1anhila5yf.public.blob.vercel-storage.com/just-relax-11157-iAgp15dV2YGybAezUJFtKmKZPbteXd.mp3")
|
||||
this.audio.loop = true
|
||||
this.audio.volume = this.volume
|
||||
this.audio.preload = "metadata"
|
||||
this.audio.autoplay = false
|
||||
this.audio.muted = false
|
||||
|
||||
this.audio.addEventListener("canplaythrough", () => {
|
||||
// 音樂載入完成
|
||||
})
|
||||
|
||||
this.audio.addEventListener("error", (e) => {
|
||||
this.reinitAudio()
|
||||
})
|
||||
|
||||
this.audio.addEventListener("ended", () => {
|
||||
if (this.enabled && this.isPlaying) {
|
||||
this.audio?.play().catch(() => {
|
||||
this.reinitAudio()
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Audio initialization failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
private reinitAudio() {
|
||||
try {
|
||||
if (this.audio) {
|
||||
this.audio.pause()
|
||||
this.audio.src = ""
|
||||
this.audio = null
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.initAudio()
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error("Audio reinitialization failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fadeIn(duration = 2000) {
|
||||
if (!this.audio) return
|
||||
|
||||
this.audio.volume = 0
|
||||
const targetVolume = this.volume
|
||||
const steps = 50
|
||||
const stepTime = duration / steps
|
||||
const volumeStep = targetVolume / steps
|
||||
|
||||
let currentStep = 0
|
||||
this.fadeInterval = setInterval(() => {
|
||||
if (currentStep >= steps || !this.audio) {
|
||||
if (this.fadeInterval) {
|
||||
clearInterval(this.fadeInterval)
|
||||
this.fadeInterval = null
|
||||
}
|
||||
if (this.audio) {
|
||||
this.audio.volume = targetVolume
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.audio.volume = Math.min(volumeStep * currentStep, targetVolume)
|
||||
currentStep++
|
||||
}, stepTime)
|
||||
}
|
||||
|
||||
private fadeOut(duration = 1000) {
|
||||
if (!this.audio) return
|
||||
|
||||
const startVolume = this.audio.volume
|
||||
const steps = 50
|
||||
const stepTime = duration / steps
|
||||
const volumeStep = startVolume / steps
|
||||
|
||||
let currentStep = 0
|
||||
this.fadeInterval = setInterval(() => {
|
||||
if (currentStep >= steps || !this.audio) {
|
||||
if (this.fadeInterval) {
|
||||
clearInterval(this.fadeInterval)
|
||||
this.fadeInterval = null
|
||||
}
|
||||
if (this.audio) {
|
||||
this.audio.pause()
|
||||
this.audio.currentTime = 0
|
||||
this.audio.volume = this.volume
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.audio.volume = Math.max(startVolume - volumeStep * currentStep, 0)
|
||||
currentStep++
|
||||
}, stepTime)
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (!this.initialized) await this.init()
|
||||
|
||||
if (this.fadeInterval) {
|
||||
clearInterval(this.fadeInterval)
|
||||
this.fadeInterval = null
|
||||
}
|
||||
|
||||
if (!this.audio) {
|
||||
this.initAudio()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
if (!this.audio) return
|
||||
|
||||
try {
|
||||
this.enabled = true
|
||||
this.isPlaying = true
|
||||
|
||||
// 保存設定到 Supabase
|
||||
await this.saveSettings()
|
||||
|
||||
this.audio.currentTime = 0
|
||||
this.audio.volume = 0
|
||||
|
||||
await this.audio.play()
|
||||
this.fadeIn(2000)
|
||||
} catch (error) {
|
||||
console.error("Failed to start music:", error)
|
||||
this.reinitAudio()
|
||||
this.isPlaying = false
|
||||
this.enabled = false
|
||||
await this.saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.initialized) await this.init()
|
||||
|
||||
if (!this.audio) return
|
||||
|
||||
this.enabled = false
|
||||
this.isPlaying = false
|
||||
|
||||
// 保存設定到 Supabase
|
||||
await this.saveSettings()
|
||||
|
||||
if (this.fadeInterval) {
|
||||
clearInterval(this.fadeInterval)
|
||||
this.fadeInterval = null
|
||||
}
|
||||
|
||||
this.fadeOut(1000)
|
||||
}
|
||||
|
||||
async setVolume(volume: number) {
|
||||
if (!this.initialized) await this.init()
|
||||
|
||||
this.volume = Math.max(0, Math.min(1, volume))
|
||||
if (this.audio && this.isPlaying) {
|
||||
this.audio.volume = this.volume
|
||||
}
|
||||
|
||||
// 保存設定到 Supabase
|
||||
await this.saveSettings()
|
||||
}
|
||||
|
||||
// 保存設定到 Supabase
|
||||
private async saveSettings() {
|
||||
try {
|
||||
await UserSettingsService.updateUserSettings({
|
||||
backgroundMusicEnabled: this.enabled,
|
||||
backgroundMusicVolume: this.volume,
|
||||
backgroundMusicPlaying: this.isPlaying,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to save music settings:", error)
|
||||
}
|
||||
}
|
||||
|
||||
getVolume() {
|
||||
return this.volume
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.enabled
|
||||
}
|
||||
|
||||
getIsPlaying() {
|
||||
return this.isPlaying && this.audio && !this.audio.paused
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
isPlaying: this.getIsPlaying(),
|
||||
enabled: this.enabled,
|
||||
volume: this.volume,
|
||||
}
|
||||
}
|
||||
|
||||
getMusicInfo() {
|
||||
if (!this.audio) return null
|
||||
|
||||
return {
|
||||
duration: this.audio.duration || 0,
|
||||
currentTime: this.audio.currentTime || 0,
|
||||
loaded: this.audio.readyState >= 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局背景音樂管理器 - Supabase 版本
|
||||
export const backgroundMusicManagerSupabase = new BackgroundMusicManagerSupabase()
|
@@ -49,9 +49,9 @@ export const categories = [
|
||||
"報告",
|
||||
"圖表",
|
||||
],
|
||||
color: "#EC4899",
|
||||
bgColor: "from-pink-500/20 to-rose-600/20",
|
||||
borderColor: "border-pink-400/30",
|
||||
color: "#DB2777",
|
||||
bgColor: "from-pink-600/20 to-rose-700/20",
|
||||
borderColor: "border-pink-500/30",
|
||||
textColor: "text-pink-200",
|
||||
icon: "📊",
|
||||
},
|
||||
@@ -322,10 +322,10 @@ export function categorizeWishMultiple(wish: Wish): Category[] {
|
||||
name: "其他問題",
|
||||
description: "未能歸類的特殊工作困擾",
|
||||
keywords: [],
|
||||
color: "#6B7280",
|
||||
bgColor: "from-gray-500/20 to-slate-600/20",
|
||||
borderColor: "border-gray-400/30",
|
||||
textColor: "text-gray-200",
|
||||
color: "#94A3B8",
|
||||
bgColor: "from-slate-400/20 to-slate-500/20",
|
||||
borderColor: "border-slate-400/40",
|
||||
textColor: "text-slate-200",
|
||||
icon: "❓",
|
||||
},
|
||||
]
|
||||
|
249
lib/content-moderation.ts
Normal file
249
lib/content-moderation.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// 內容審核 AI 系統
|
||||
export interface ModerationResult {
|
||||
isAppropriate: boolean
|
||||
issues: string[]
|
||||
suggestions: string[]
|
||||
severity: "low" | "medium" | "high"
|
||||
blockedWords: string[]
|
||||
}
|
||||
|
||||
// 不雅詞彙和辱罵詞彙庫
|
||||
const inappropriateWords = [
|
||||
// 髒話和不雅詞彙
|
||||
"幹",
|
||||
"靠",
|
||||
"操",
|
||||
"媽的",
|
||||
"他媽的",
|
||||
"去死",
|
||||
"死",
|
||||
"滾",
|
||||
"白痴",
|
||||
"智障",
|
||||
"腦殘",
|
||||
"垃圾",
|
||||
"廢物",
|
||||
"混蛋",
|
||||
"王八蛋",
|
||||
"狗屎",
|
||||
"屎",
|
||||
"婊子",
|
||||
"賤",
|
||||
"爛",
|
||||
"鳥",
|
||||
"屌",
|
||||
"雞掰",
|
||||
"機掰",
|
||||
"北七",
|
||||
|
||||
// 公司辱罵相關
|
||||
"爛公司",
|
||||
"垃圾公司",
|
||||
"黑心公司",
|
||||
"慣老闆",
|
||||
"奴隸主",
|
||||
"血汗工廠",
|
||||
"剝削",
|
||||
"壓榨",
|
||||
"去你的",
|
||||
"見鬼",
|
||||
"該死",
|
||||
"要死",
|
||||
"找死",
|
||||
"活該",
|
||||
"報應",
|
||||
"天殺的",
|
||||
|
||||
// 威脅性詞彙
|
||||
"殺",
|
||||
"打死",
|
||||
"弄死",
|
||||
"搞死",
|
||||
"整死",
|
||||
"報復",
|
||||
"復仇",
|
||||
"毀掉",
|
||||
"搞垮",
|
||||
|
||||
// 歧視性詞彙
|
||||
"歧視",
|
||||
"種族",
|
||||
"性別歧視",
|
||||
"老不死",
|
||||
"死老頭",
|
||||
"死老太婆",
|
||||
"殘廢",
|
||||
"瘸子",
|
||||
|
||||
// 英文不雅詞彙
|
||||
"fuck",
|
||||
"shit",
|
||||
"damn",
|
||||
"bitch",
|
||||
"asshole",
|
||||
"bastard",
|
||||
"crap",
|
||||
"hell",
|
||||
"wtf",
|
||||
"stfu",
|
||||
"bullshit",
|
||||
"motherfucker",
|
||||
"dickhead",
|
||||
"piss",
|
||||
]
|
||||
|
||||
// 負面但可接受的詞彙(會給予建議但不阻擋)
|
||||
const negativeButAcceptableWords = [
|
||||
"討厭",
|
||||
"煩",
|
||||
"累",
|
||||
"辛苦",
|
||||
"困難",
|
||||
"挫折",
|
||||
"失望",
|
||||
"無奈",
|
||||
"痛苦",
|
||||
"壓力",
|
||||
"不滿",
|
||||
"抱怨",
|
||||
"不爽",
|
||||
"生氣",
|
||||
"憤怒",
|
||||
"沮喪",
|
||||
"絕望",
|
||||
"疲憊",
|
||||
"厭倦",
|
||||
]
|
||||
|
||||
// 建設性詞彙建議
|
||||
const constructiveSuggestions = [
|
||||
"建議使用更具體的描述來說明遇到的困難",
|
||||
"可以嘗試描述期望的改善方向",
|
||||
"分享具體的情況會更有助於找到解決方案",
|
||||
"描述問題的影響程度會幫助我們更好地理解",
|
||||
"可以說明這個問題對工作效率的具體影響",
|
||||
]
|
||||
|
||||
export function moderateContent(content: string): ModerationResult {
|
||||
const fullText = content.toLowerCase()
|
||||
const issues: string[] = []
|
||||
const suggestions: string[] = []
|
||||
const blockedWords: string[] = []
|
||||
let severity: "low" | "medium" | "high" = "low"
|
||||
|
||||
// 檢查不雅詞彙
|
||||
inappropriateWords.forEach((word) => {
|
||||
if (fullText.includes(word.toLowerCase())) {
|
||||
blockedWords.push(word)
|
||||
issues.push(`包含不適當詞彙: "${word}"`)
|
||||
}
|
||||
})
|
||||
|
||||
// 檢查負面但可接受的詞彙
|
||||
const negativeWordCount = negativeButAcceptableWords.filter((word) => fullText.includes(word.toLowerCase())).length
|
||||
|
||||
// 判斷嚴重程度
|
||||
if (blockedWords.length > 0) {
|
||||
severity = "high"
|
||||
issues.push("內容包含不雅或辱罵詞彙,無法提交")
|
||||
suggestions.push("請使用更專業和建設性的語言描述遇到的困難")
|
||||
suggestions.push("我們理解工作中的挫折,但希望能以正面的方式表達")
|
||||
} else if (negativeWordCount > 3) {
|
||||
severity = "medium"
|
||||
issues.push("內容情緒較為負面")
|
||||
suggestions.push("建議加入一些具體的改善建議或期望")
|
||||
suggestions.push("描述具體情況會比情緒性詞彙更有幫助")
|
||||
} else if (negativeWordCount > 1) {
|
||||
severity = "low"
|
||||
suggestions.push("可以嘗試更具體地描述遇到的挑戰")
|
||||
}
|
||||
|
||||
// 內容長度檢查
|
||||
if (content.trim().length < 10) {
|
||||
issues.push("內容過於簡短,請提供更詳細的描述")
|
||||
severity = severity === "low" ? "medium" : severity
|
||||
}
|
||||
|
||||
// 重複字符檢查(可能是情緒性表達)
|
||||
const repeatedChars = content.match(/(.)\1{4,}/g)
|
||||
if (repeatedChars) {
|
||||
issues.push("請避免使用過多重複字符")
|
||||
suggestions.push("建議使用清楚的文字描述來表達感受")
|
||||
}
|
||||
|
||||
// 全大寫檢查(可能是憤怒表達)
|
||||
const upperCaseRatio = (content.match(/[A-Z]/g) || []).length / content.length
|
||||
if (upperCaseRatio > 0.5 && content.length > 20) {
|
||||
issues.push("請避免使用過多大寫字母")
|
||||
suggestions.push("正常的大小寫會讓內容更容易閱讀")
|
||||
}
|
||||
|
||||
// 如果沒有具體建議,添加通用建議
|
||||
if (suggestions.length === 0 && severity !== "high") {
|
||||
suggestions.push(...constructiveSuggestions.slice(0, 2))
|
||||
}
|
||||
|
||||
return {
|
||||
isAppropriate: blockedWords.length === 0,
|
||||
issues,
|
||||
suggestions,
|
||||
severity,
|
||||
blockedWords,
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查整個表單內容
|
||||
export function moderateWishForm(formData: {
|
||||
title: string
|
||||
currentPain: string
|
||||
expectedSolution: string
|
||||
expectedEffect: string
|
||||
}): ModerationResult {
|
||||
const allContent = `${formData.title} ${formData.currentPain} ${formData.expectedSolution} ${formData.expectedEffect}`
|
||||
|
||||
const result = moderateContent(allContent)
|
||||
|
||||
// 針對不同欄位給出具體建議
|
||||
const fieldSpecificSuggestions: string[] = []
|
||||
|
||||
if (formData.title.length < 5) {
|
||||
fieldSpecificSuggestions.push('標題建議更具體一些,例如:"資料整理效率低下" 而非 "很煩"')
|
||||
}
|
||||
|
||||
if (formData.currentPain.length < 20) {
|
||||
fieldSpecificSuggestions.push("困擾描述可以更詳細,包括具體情況和影響")
|
||||
}
|
||||
|
||||
if (formData.expectedSolution.length < 15) {
|
||||
fieldSpecificSuggestions.push("期望解決方式可以更具體,這有助於我們提供更好的建議")
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
suggestions: [...result.suggestions, ...fieldSpecificSuggestions],
|
||||
}
|
||||
}
|
||||
|
||||
// 提供正面的表達建議
|
||||
export function getSuggestedPhrases(originalText: string): string[] {
|
||||
const suggestions: string[] = []
|
||||
|
||||
// 根據內容提供建議
|
||||
if (originalText.includes("很煩") || originalText.includes("討厭")) {
|
||||
suggestions.push('可以說:"這個流程讓我感到困擾,希望能夠簡化"')
|
||||
}
|
||||
|
||||
if (originalText.includes("爛") || originalText.includes("垃圾")) {
|
||||
suggestions.push('可以說:"這個系統存在一些問題,影響了工作效率"')
|
||||
}
|
||||
|
||||
if (originalText.includes("老闆") && (originalText.includes("討厌") || originalText.includes("爛"))) {
|
||||
suggestions.push('可以說:"希望能與主管有更好的溝通和協作"')
|
||||
}
|
||||
|
||||
if (originalText.includes("同事")) {
|
||||
suggestions.push('可以說:"團隊協作方面遇到一些挑戰"')
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
60
lib/email-validation.ts
Normal file
60
lib/email-validation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Email 驗證工具
|
||||
export function isValidEmail(email: string): boolean {
|
||||
if (!email) return true // 空值是允許的(可選欄位)
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
export function sanitizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function getEmailDomain(email: string): string {
|
||||
const parts = email.split("@")
|
||||
return parts.length > 1 ? parts[1] : ""
|
||||
}
|
||||
|
||||
// 檢查是否為常見的臨時郵箱服務
|
||||
const temporaryEmailDomains = [
|
||||
"10minutemail.com",
|
||||
"guerrillamail.com",
|
||||
"mailinator.com",
|
||||
"tempmail.org",
|
||||
"throwaway.email",
|
||||
]
|
||||
|
||||
export function isTemporaryEmail(email: string): boolean {
|
||||
if (!email) return false
|
||||
|
||||
const domain = getEmailDomain(email)
|
||||
return temporaryEmailDomains.includes(domain)
|
||||
}
|
||||
|
||||
export function validateEmailForSubmission(email: string): {
|
||||
isValid: boolean
|
||||
message?: string
|
||||
suggestion?: string
|
||||
} {
|
||||
if (!email) {
|
||||
return { isValid: true } // 空值允許
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "請輸入有效的 Email 格式",
|
||||
suggestion: "例如:your.name@company.com",
|
||||
}
|
||||
}
|
||||
|
||||
if (isTemporaryEmail(email)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "請避免使用臨時郵箱服務",
|
||||
suggestion: "建議使用常用的 Email 地址,以便我們能夠聯繫到你",
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
190
lib/image-utils.ts
Normal file
190
lib/image-utils.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// 圖片處理工具
|
||||
export interface ImageFile {
|
||||
id: string
|
||||
file?: File // 可選,因為從 localStorage 恢復時不會有原始 File
|
||||
url: string // 改為 base64 URL
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
base64?: string // 新增 base64 字段
|
||||
}
|
||||
|
||||
export interface ImageValidationResult {
|
||||
isValid: boolean
|
||||
error?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
// 允許的圖片格式
|
||||
export const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]
|
||||
|
||||
// 允許的檔案副檔名
|
||||
export const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]
|
||||
|
||||
// 檔案大小限制 (5MB)
|
||||
export const MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
// 單次上傳數量限制
|
||||
export const MAX_FILES_PER_UPLOAD = 10
|
||||
|
||||
// 總檔案數量限制
|
||||
export const MAX_TOTAL_FILES = 20
|
||||
|
||||
export function validateImageFile(file: File): ImageValidationResult {
|
||||
// 檢查檔案類型
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `不支援的檔案格式: ${file.type}`,
|
||||
suggestion: `請使用 JPG、PNG、WebP 或 GIF 格式`,
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查檔案大小
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
|
||||
return {
|
||||
isValid: false,
|
||||
error: `檔案過大: ${sizeMB}MB`,
|
||||
suggestion: `請壓縮圖片至 5MB 以下`,
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查檔案名稱
|
||||
if (file.name.length > 100) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "檔案名稱過長",
|
||||
suggestion: "請使用較短的檔案名稱",
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
export 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 Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
// 將 File 轉換為 base64
|
||||
export function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error("Failed to convert file to base64"))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 創建圖片文件對象(使用 base64)
|
||||
export async function createImageFile(file: File): Promise<ImageFile> {
|
||||
const base64 = await fileToBase64(file)
|
||||
|
||||
return {
|
||||
id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
url: base64, // 直接使用 base64 作為 URL
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
base64,
|
||||
}
|
||||
}
|
||||
|
||||
// 從儲存的數據恢復圖片對象
|
||||
export function restoreImageFile(data: any): ImageFile {
|
||||
return {
|
||||
id: data.id,
|
||||
url: data.base64 || data.url, // 優先使用 base64,向後兼容
|
||||
name: data.name,
|
||||
size: data.size,
|
||||
type: data.type,
|
||||
base64: data.base64,
|
||||
}
|
||||
}
|
||||
|
||||
// 不再需要 revokeImageUrl,因為使用 base64
|
||||
export function revokeImageUrl(imageFile: ImageFile): void {
|
||||
// base64 不需要手動釋放
|
||||
return
|
||||
}
|
||||
|
||||
// 壓縮圖片並轉為 base64
|
||||
export function compressImage(file: File, maxWidth = 1920, quality = 0.8): Promise<File> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 計算新尺寸
|
||||
let { width, height } = img
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// 繪製壓縮後的圖片
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const compressedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
resolve(compressedFile)
|
||||
} else {
|
||||
resolve(file) // 如果壓縮失敗,返回原檔案
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 生成縮圖
|
||||
export function generateThumbnail(file: File, size = 200): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
// 計算裁切區域 (正方形縮圖)
|
||||
const minDimension = Math.min(img.width, img.height)
|
||||
const x = (img.width - minDimension) / 2
|
||||
const y = (img.height - minDimension) / 2
|
||||
|
||||
ctx?.drawImage(img, x, y, minDimension, minDimension, 0, 0, size, size)
|
||||
|
||||
resolve(canvas.toDataURL(file.type, 0.7))
|
||||
}
|
||||
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
330
lib/supabase-image-utils.ts
Normal file
330
lib/supabase-image-utils.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { supabase } from "./supabase"
|
||||
|
||||
// Supabase 圖片相關的類型定義
|
||||
export interface SupabaseImageFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
storage_path: string // Supabase Storage 中的路徑
|
||||
public_url: string // 公開訪問 URL
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface ImageUploadResult {
|
||||
success: boolean
|
||||
data?: SupabaseImageFile
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BatchUploadResult {
|
||||
successful: SupabaseImageFile[]
|
||||
failed: Array<{ file: File; error: string }>
|
||||
total: number
|
||||
}
|
||||
|
||||
// 圖片上傳服務
|
||||
export class SupabaseImageService {
|
||||
private static readonly BUCKET_NAME = "wish-images"
|
||||
private static readonly MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
private static readonly ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]
|
||||
|
||||
// 驗證圖片文件
|
||||
static validateImageFile(file: File): { isValid: boolean; error?: string } {
|
||||
if (!this.ALLOWED_TYPES.includes(file.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `不支援的檔案格式: ${file.type}。請使用 JPG、PNG、WebP 或 GIF 格式。`,
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
|
||||
return {
|
||||
isValid: false,
|
||||
error: `檔案過大: ${sizeMB}MB。請壓縮圖片至 5MB 以下。`,
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
// 生成唯一的檔案路徑
|
||||
static generateFilePath(file: File): string {
|
||||
const timestamp = Date.now()
|
||||
const randomId = Math.random().toString(36).substring(2, 15)
|
||||
const extension = file.name.split(".").pop()?.toLowerCase() || "jpg"
|
||||
return `${timestamp}_${randomId}.${extension}`
|
||||
}
|
||||
|
||||
// 上傳單個圖片到 Supabase Storage
|
||||
static async uploadImage(file: File): Promise<ImageUploadResult> {
|
||||
try {
|
||||
// 驗證檔案
|
||||
const validation = this.validateImageFile(file)
|
||||
if (!validation.isValid) {
|
||||
return { success: false, error: validation.error }
|
||||
}
|
||||
|
||||
// 生成檔案路徑
|
||||
const filePath = this.generateFilePath(file)
|
||||
|
||||
// 上傳到 Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from(this.BUCKET_NAME)
|
||||
.upload(filePath, file, {
|
||||
cacheControl: "3600",
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error("Upload error:", uploadError)
|
||||
return { success: false, error: `上傳失敗: ${uploadError.message}` }
|
||||
}
|
||||
|
||||
// 獲取公開 URL
|
||||
const { data: urlData } = supabase.storage.from(this.BUCKET_NAME).getPublicUrl(filePath)
|
||||
|
||||
if (!urlData.publicUrl) {
|
||||
return { success: false, error: "無法獲取圖片 URL" }
|
||||
}
|
||||
|
||||
// 創建圖片記錄
|
||||
const imageFile: SupabaseImageFile = {
|
||||
id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
storage_path: filePath,
|
||||
public_url: urlData.publicUrl,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
return { success: true, data: imageFile }
|
||||
} catch (error) {
|
||||
console.error("Image upload error:", error)
|
||||
return { success: false, error: `上傳過程中發生錯誤: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 批量上傳圖片
|
||||
static async uploadImages(files: File[]): Promise<BatchUploadResult> {
|
||||
const result: BatchUploadResult = {
|
||||
successful: [],
|
||||
failed: [],
|
||||
total: files.length,
|
||||
}
|
||||
|
||||
// 並行上傳所有圖片
|
||||
const uploadPromises = files.map(async (file) => {
|
||||
const uploadResult = await this.uploadImage(file)
|
||||
if (uploadResult.success && uploadResult.data) {
|
||||
result.successful.push(uploadResult.data)
|
||||
} else {
|
||||
result.failed.push({
|
||||
file,
|
||||
error: uploadResult.error || "未知錯誤",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(uploadPromises)
|
||||
return result
|
||||
}
|
||||
|
||||
// 刪除圖片
|
||||
static async deleteImage(storagePath: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const { error } = await supabase.storage.from(this.BUCKET_NAME).remove([storagePath])
|
||||
|
||||
if (error) {
|
||||
console.error("Delete error:", error)
|
||||
return { success: false, error: `刪除失敗: ${error.message}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Image delete error:", error)
|
||||
return { success: false, error: `刪除過程中發生錯誤: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 批量刪除圖片
|
||||
static async deleteImages(storagePaths: string[]): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const { error } = await supabase.storage.from(this.BUCKET_NAME).remove(storagePaths)
|
||||
|
||||
if (error) {
|
||||
console.error("Batch delete error:", error)
|
||||
return { success: false, error: `批量刪除失敗: ${error.message}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Batch delete error:", error)
|
||||
return { success: false, error: `批量刪除過程中發生錯誤: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取圖片的公開 URL
|
||||
static getPublicUrl(storagePath: string): string {
|
||||
const { data } = supabase.storage.from(this.BUCKET_NAME).getPublicUrl(storagePath)
|
||||
return data.publicUrl
|
||||
}
|
||||
|
||||
// 檢查存儲桶是否存在並可訪問
|
||||
static async checkStorageHealth(): Promise<{ healthy: boolean; error?: string }> {
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from(this.BUCKET_NAME).list("", { limit: 1 })
|
||||
|
||||
if (error) {
|
||||
return { healthy: false, error: `存儲檢查失敗: ${error.message}` }
|
||||
}
|
||||
|
||||
return { healthy: true }
|
||||
} catch (error) {
|
||||
return { healthy: false, error: `存儲檢查過程中發生錯誤: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取存儲使用統計
|
||||
static async getStorageStats(): Promise<{
|
||||
totalFiles: number
|
||||
totalSize: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from(this.BUCKET_NAME).list("", { limit: 1000 })
|
||||
|
||||
if (error) {
|
||||
return { totalFiles: 0, totalSize: 0, error: error.message }
|
||||
}
|
||||
|
||||
const totalFiles = data?.length || 0
|
||||
const totalSize = data?.reduce((sum, file) => sum + (file.metadata?.size || 0), 0) || 0
|
||||
|
||||
return { totalFiles, totalSize }
|
||||
} catch (error) {
|
||||
return { totalFiles: 0, totalSize: 0, error: `獲取統計失敗: ${error}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 圖片壓縮工具(在上傳前使用)
|
||||
export class ImageCompressionService {
|
||||
// 壓縮圖片
|
||||
static async compressImage(file: File, maxWidth = 1920, quality = 0.8): Promise<File> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 計算新尺寸
|
||||
let { width, height } = img
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width
|
||||
width = maxWidth
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// 繪製壓縮後的圖片
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const compressedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
resolve(compressedFile)
|
||||
} else {
|
||||
resolve(file) // 如果壓縮失敗,返回原檔案
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => resolve(file) // 如果載入失敗,返回原檔案
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 批量壓縮圖片
|
||||
static async compressImages(files: File[]): Promise<File[]> {
|
||||
const compressionPromises = files.map((file) => {
|
||||
// 如果檔案小於 1MB,不需要壓縮
|
||||
if (file.size < 1024 * 1024) {
|
||||
return Promise.resolve(file)
|
||||
}
|
||||
return this.compressImage(file, 1920, 0.8)
|
||||
})
|
||||
|
||||
return Promise.all(compressionPromises)
|
||||
}
|
||||
}
|
||||
|
||||
// 從舊的 base64 格式遷移到 Supabase Storage
|
||||
export class ImageMigrationService {
|
||||
// 將 base64 圖片遷移到 Supabase Storage
|
||||
static async migrateBase64ToStorage(base64Data: string, fileName: string): Promise<ImageUploadResult> {
|
||||
try {
|
||||
// 將 base64 轉換為 Blob
|
||||
const response = await fetch(base64Data)
|
||||
const blob = await response.blob()
|
||||
|
||||
// 創建 File 對象
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
|
||||
// 上傳到 Supabase Storage
|
||||
return await SupabaseImageService.uploadImage(file)
|
||||
} catch (error) {
|
||||
console.error("Base64 migration error:", error)
|
||||
return { success: false, error: `遷移失敗: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 批量遷移圖片
|
||||
static async migrateImagesFromWish(wishImages: any[]): Promise<{
|
||||
successful: SupabaseImageFile[]
|
||||
failed: Array<{ originalImage: any; error: string }>
|
||||
}> {
|
||||
const result = {
|
||||
successful: [] as SupabaseImageFile[],
|
||||
failed: [] as Array<{ originalImage: any; error: string }>,
|
||||
}
|
||||
|
||||
for (const image of wishImages) {
|
||||
try {
|
||||
if (image.base64) {
|
||||
// 遷移 base64 圖片
|
||||
const migrationResult = await this.migrateBase64ToStorage(image.base64, image.name)
|
||||
if (migrationResult.success && migrationResult.data) {
|
||||
result.successful.push(migrationResult.data)
|
||||
} else {
|
||||
result.failed.push({
|
||||
originalImage: image,
|
||||
error: migrationResult.error || "遷移失敗",
|
||||
})
|
||||
}
|
||||
} else if (image.storage_path) {
|
||||
// 已經是 Supabase Storage 格式,直接保留
|
||||
result.successful.push(image as SupabaseImageFile)
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed.push({
|
||||
originalImage: image,
|
||||
error: `處理失敗: ${error}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
299
lib/supabase-service-updated.ts
Normal file
299
lib/supabase-service-updated.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { supabase, type Database } from "./supabase"
|
||||
import { SupabaseImageService, ImageMigrationService, type SupabaseImageFile } from "./supabase-image-utils"
|
||||
|
||||
// 更新的 Wish 類型定義
|
||||
export type Wish = Database["public"]["Tables"]["wishes"]["Row"] & {
|
||||
like_count?: number
|
||||
images?: SupabaseImageFile[] // 使用新的圖片類型
|
||||
}
|
||||
|
||||
export type WishInsert = Database["public"]["Tables"]["wishes"]["Insert"]
|
||||
export type WishLike = Database["public"]["Tables"]["wish_likes"]["Row"]
|
||||
export type UserSettings = Database["public"]["Tables"]["user_settings"]["Row"]
|
||||
|
||||
// 錯誤處理
|
||||
export class SupabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public originalError?: any,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "SupabaseError"
|
||||
}
|
||||
}
|
||||
|
||||
// 更新的困擾案例服務
|
||||
export class WishService {
|
||||
// 獲取所有公開的困擾案例(帶點讚數和圖片)
|
||||
static async getPublicWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
.eq("is_public", true)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw new SupabaseError("獲取公開困擾失敗", error)
|
||||
|
||||
// 轉換圖片格式
|
||||
return (data || []).map((wish) => ({
|
||||
...wish,
|
||||
images: this.parseImages(wish.images),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Error fetching public wishes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取所有困擾案例(用於分析,包含私密的)
|
||||
static async getAllWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw new SupabaseError("獲取所有困擾失敗", error)
|
||||
|
||||
// 轉換圖片格式
|
||||
return (data || []).map((wish) => ({
|
||||
...wish,
|
||||
images: this.parseImages(wish.images),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Error fetching all wishes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 創建新的困擾案例(支持 Supabase Storage 圖片)
|
||||
static async createWish(wishData: {
|
||||
title: string
|
||||
currentPain: string
|
||||
expectedSolution: string
|
||||
expectedEffect?: string
|
||||
isPublic?: boolean
|
||||
email?: string
|
||||
images?: SupabaseImageFile[]
|
||||
}): Promise<Wish> {
|
||||
try {
|
||||
// 準備圖片數據
|
||||
const imageData =
|
||||
wishData.images?.map((img) => ({
|
||||
id: img.id,
|
||||
name: img.name,
|
||||
size: img.size,
|
||||
type: img.type,
|
||||
storage_path: img.storage_path,
|
||||
public_url: img.public_url,
|
||||
uploaded_at: img.uploaded_at,
|
||||
})) || []
|
||||
|
||||
const insertData: WishInsert = {
|
||||
title: wishData.title,
|
||||
current_pain: wishData.currentPain,
|
||||
expected_solution: wishData.expectedSolution,
|
||||
expected_effect: wishData.expectedEffect || null,
|
||||
is_public: wishData.isPublic ?? true,
|
||||
email: wishData.email || null,
|
||||
images: imageData,
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from("wishes").insert(insertData).select().single()
|
||||
|
||||
if (error) throw new SupabaseError("創建困擾失敗", error)
|
||||
|
||||
return {
|
||||
...data,
|
||||
images: this.parseImages(data.images),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating wish:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 解析圖片數據
|
||||
private static parseImages(imagesData: any): SupabaseImageFile[] {
|
||||
if (!imagesData || !Array.isArray(imagesData)) return []
|
||||
|
||||
return imagesData.map((img) => ({
|
||||
id: img.id || `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: img.name || "unknown.jpg",
|
||||
size: img.size || 0,
|
||||
type: img.type || "image/jpeg",
|
||||
storage_path: img.storage_path || "",
|
||||
public_url: img.public_url || img.url || "", // 向後兼容
|
||||
uploaded_at: img.uploaded_at || new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
// 獲取統計數據
|
||||
static async getWishesStats() {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc("get_wishes_stats")
|
||||
|
||||
if (error) throw new SupabaseError("獲取統計數據失敗", error)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Error fetching wishes stats:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 刪除困擾案例(包括相關圖片)
|
||||
static async deleteWish(wishId: number): Promise<boolean> {
|
||||
try {
|
||||
// 先獲取困擾案例的圖片信息
|
||||
const { data: wish, error: fetchError } = await supabase.from("wishes").select("images").eq("id", wishId).single()
|
||||
|
||||
if (fetchError) throw new SupabaseError("獲取困擾案例失敗", fetchError)
|
||||
|
||||
// 刪除相關圖片
|
||||
if (wish.images && Array.isArray(wish.images)) {
|
||||
const storagePaths = wish.images.map((img: any) => img.storage_path).filter((path: string) => path)
|
||||
|
||||
if (storagePaths.length > 0) {
|
||||
await SupabaseImageService.deleteImages(storagePaths)
|
||||
}
|
||||
}
|
||||
|
||||
// 刪除困擾案例記錄
|
||||
const { error: deleteError } = await supabase.from("wishes").delete().eq("id", wishId)
|
||||
|
||||
if (deleteError) throw new SupabaseError("刪除困擾案例失敗", deleteError)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Error deleting wish:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新的數據遷移服務
|
||||
export class MigrationService {
|
||||
// 遷移 localStorage 中的困擾案例到 Supabase(包括圖片遷移)
|
||||
static async migrateWishesFromLocalStorage(): Promise<{
|
||||
success: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
}> {
|
||||
const result = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
}
|
||||
|
||||
try {
|
||||
const localWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
|
||||
if (localWishes.length === 0) {
|
||||
console.log("No local wishes to migrate")
|
||||
return result
|
||||
}
|
||||
|
||||
console.log(`Starting migration of ${localWishes.length} wishes...`)
|
||||
|
||||
for (const wish of localWishes) {
|
||||
try {
|
||||
let migratedImages: SupabaseImageFile[] = []
|
||||
|
||||
// 遷移圖片(如果有的話)
|
||||
if (wish.images && Array.isArray(wish.images) && wish.images.length > 0) {
|
||||
console.log(`Migrating ${wish.images.length} images for wish: ${wish.title}`)
|
||||
|
||||
const imageMigrationResult = await ImageMigrationService.migrateImagesFromWish(wish.images)
|
||||
|
||||
migratedImages = imageMigrationResult.successful
|
||||
|
||||
if (imageMigrationResult.failed.length > 0) {
|
||||
console.warn(`Failed to migrate ${imageMigrationResult.failed.length} images for wish: ${wish.title}`)
|
||||
// 記錄圖片遷移失敗,但不阻止整個 wish 的遷移
|
||||
result.errors.push(`部分圖片遷移失敗 "${wish.title}": ${imageMigrationResult.failed.length} 張圖片`)
|
||||
}
|
||||
}
|
||||
|
||||
// 創建困擾案例
|
||||
await WishService.createWish({
|
||||
title: wish.title,
|
||||
currentPain: wish.currentPain,
|
||||
expectedSolution: wish.expectedSolution,
|
||||
expectedEffect: wish.expectedEffect,
|
||||
isPublic: wish.isPublic !== false,
|
||||
email: wish.email,
|
||||
images: migratedImages,
|
||||
})
|
||||
|
||||
result.success++
|
||||
console.log(`Successfully migrated wish: ${wish.title}`)
|
||||
} catch (error) {
|
||||
result.failed++
|
||||
result.errors.push(`Failed to migrate wish "${wish.title}": ${error}`)
|
||||
console.error(`Failed to migrate wish "${wish.title}":`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration completed: ${result.success} success, ${result.failed} failed`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error)
|
||||
result.errors.push(`Migration process failed: ${error}`)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 localStorage 中的舊數據
|
||||
static clearLocalStorageData(): void {
|
||||
const keysToRemove = ["wishes", "wishLikes", "userLikedWishes"]
|
||||
keysToRemove.forEach((key) => {
|
||||
localStorage.removeItem(key)
|
||||
})
|
||||
console.log("Local storage data cleared")
|
||||
}
|
||||
}
|
||||
|
||||
// 存儲健康檢查服務
|
||||
export class StorageHealthService {
|
||||
// 檢查 Supabase Storage 健康狀態
|
||||
static async checkStorageHealth(): Promise<{
|
||||
healthy: boolean
|
||||
stats?: { totalFiles: number; totalSize: number }
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const healthCheck = await SupabaseImageService.checkStorageHealth()
|
||||
if (!healthCheck.healthy) {
|
||||
return { healthy: false, error: healthCheck.error }
|
||||
}
|
||||
|
||||
const stats = await SupabaseImageService.getStorageStats()
|
||||
return {
|
||||
healthy: true,
|
||||
stats: {
|
||||
totalFiles: stats.totalFiles,
|
||||
totalSize: stats.totalSize,
|
||||
},
|
||||
error: stats.error,
|
||||
}
|
||||
} catch (error) {
|
||||
return { healthy: false, error: `健康檢查失敗: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
// 清理孤立的圖片
|
||||
static async cleanupOrphanedImages(): Promise<{ cleaned: number; error?: string }> {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc("cleanup_orphaned_images")
|
||||
|
||||
if (error) {
|
||||
return { cleaned: 0, error: error.message }
|
||||
}
|
||||
|
||||
return { cleaned: data || 0 }
|
||||
} catch (error) {
|
||||
return { cleaned: 0, error: `清理過程失敗: ${error}` }
|
||||
}
|
||||
}
|
||||
}
|
322
lib/supabase-service.ts
Normal file
322
lib/supabase-service.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { supabase, getUserSession, type Database } from "./supabase"
|
||||
import type { ImageFile } from "./image-utils"
|
||||
|
||||
// 類型定義
|
||||
export type Wish = Database["public"]["Tables"]["wishes"]["Row"] & {
|
||||
like_count?: number
|
||||
}
|
||||
|
||||
export type WishInsert = Database["public"]["Tables"]["wishes"]["Insert"]
|
||||
export type WishLike = Database["public"]["Tables"]["wish_likes"]["Row"]
|
||||
export type UserSettings = Database["public"]["Tables"]["user_settings"]["Row"]
|
||||
|
||||
// 錯誤處理
|
||||
export class SupabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public originalError?: any,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "SupabaseError"
|
||||
}
|
||||
}
|
||||
|
||||
// 困擾案例相關服務
|
||||
export class WishService {
|
||||
// 獲取所有公開的困擾案例(帶點讚數)
|
||||
static async getPublicWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
.eq("is_public", true)
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw new SupabaseError("獲取公開困擾失敗", error)
|
||||
return data || []
|
||||
} catch (error) {
|
||||
console.error("Error fetching public wishes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取所有困擾案例(用於分析,包含私密的)
|
||||
static async getAllWishes(): Promise<Wish[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("wishes_with_likes")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw new SupabaseError("獲取所有困擾失敗", error)
|
||||
return data || []
|
||||
} catch (error) {
|
||||
console.error("Error fetching all wishes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 創建新的困擾案例
|
||||
static async createWish(wishData: {
|
||||
title: string
|
||||
currentPain: string
|
||||
expectedSolution: string
|
||||
expectedEffect?: string
|
||||
isPublic?: boolean
|
||||
email?: string
|
||||
images?: ImageFile[]
|
||||
}): Promise<Wish> {
|
||||
try {
|
||||
// 轉換圖片數據格式
|
||||
const imageData =
|
||||
wishData.images?.map((img) => ({
|
||||
id: img.id,
|
||||
name: img.name,
|
||||
size: img.size,
|
||||
type: img.type,
|
||||
base64: img.base64 || img.url,
|
||||
})) || []
|
||||
|
||||
const insertData: WishInsert = {
|
||||
title: wishData.title,
|
||||
current_pain: wishData.currentPain,
|
||||
expected_solution: wishData.expectedSolution,
|
||||
expected_effect: wishData.expectedEffect || null,
|
||||
is_public: wishData.isPublic ?? true,
|
||||
email: wishData.email || null,
|
||||
images: imageData,
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from("wishes").insert(insertData).select().single()
|
||||
|
||||
if (error) throw new SupabaseError("創建困擾失敗", error)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Error creating wish:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取統計數據
|
||||
static async getWishesStats() {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc("get_wishes_stats")
|
||||
|
||||
if (error) throw new SupabaseError("獲取統計數據失敗", error)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Error fetching wishes stats:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 點讚相關服務
|
||||
export class LikeService {
|
||||
// 為困擾案例點讚
|
||||
static async likeWish(wishId: number): Promise<boolean> {
|
||||
try {
|
||||
const userSession = getUserSession()
|
||||
|
||||
const { error } = await supabase.from("wish_likes").insert({
|
||||
wish_id: wishId,
|
||||
user_session: userSession,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// 如果是重複點讚錯誤,返回 false
|
||||
if (error.code === "23505") {
|
||||
return false
|
||||
}
|
||||
throw new SupabaseError("點讚失敗", error)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Error liking wish:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查用戶是否已點讚
|
||||
static async hasUserLiked(wishId: number): Promise<boolean> {
|
||||
try {
|
||||
const userSession = getUserSession()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("wish_likes")
|
||||
.select("id")
|
||||
.eq("wish_id", wishId)
|
||||
.eq("user_session", userSession)
|
||||
.single()
|
||||
|
||||
if (error && error.code !== "PGRST116") {
|
||||
throw new SupabaseError("檢查點讚狀態失敗", error)
|
||||
}
|
||||
|
||||
return !!data
|
||||
} catch (error) {
|
||||
console.error("Error checking like status:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取困擾案例的點讚數
|
||||
static async getWishLikeCount(wishId: number): Promise<number> {
|
||||
try {
|
||||
const { count, error } = await supabase
|
||||
.from("wish_likes")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("wish_id", wishId)
|
||||
|
||||
if (error) throw new SupabaseError("獲取點讚數失敗", error)
|
||||
return count || 0
|
||||
} catch (error) {
|
||||
console.error("Error fetching like count:", error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取用戶已點讚的困擾 ID 列表
|
||||
static async getUserLikedWishes(): Promise<number[]> {
|
||||
try {
|
||||
const userSession = getUserSession()
|
||||
|
||||
const { data, error } = await supabase.from("wish_likes").select("wish_id").eq("user_session", userSession)
|
||||
|
||||
if (error) throw new SupabaseError("獲取用戶點讚記錄失敗", error)
|
||||
return data?.map((item) => item.wish_id) || []
|
||||
} catch (error) {
|
||||
console.error("Error fetching user liked wishes:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用戶設定相關服務
|
||||
export class UserSettingsService {
|
||||
// 獲取用戶設定
|
||||
static async getUserSettings(): Promise<UserSettings | null> {
|
||||
try {
|
||||
const userSession = getUserSession()
|
||||
|
||||
const { data, error } = await supabase.from("user_settings").select("*").eq("user_session", userSession).single()
|
||||
|
||||
if (error && error.code !== "PGRST116") {
|
||||
throw new SupabaseError("獲取用戶設定失敗", error)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Error fetching user settings:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或創建用戶設定
|
||||
static async updateUserSettings(settings: {
|
||||
backgroundMusicEnabled?: boolean
|
||||
backgroundMusicVolume?: number
|
||||
backgroundMusicPlaying?: boolean
|
||||
}): Promise<UserSettings> {
|
||||
try {
|
||||
const userSession = getUserSession()
|
||||
|
||||
// 先嘗試更新
|
||||
const { data: updateData, error: updateError } = await supabase
|
||||
.from("user_settings")
|
||||
.update({
|
||||
background_music_enabled: settings.backgroundMusicEnabled,
|
||||
background_music_volume: settings.backgroundMusicVolume,
|
||||
background_music_playing: settings.backgroundMusicPlaying,
|
||||
})
|
||||
.eq("user_session", userSession)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError && updateError.code === "PGRST116") {
|
||||
// 如果記錄不存在,創建新記錄
|
||||
const { data: insertData, error: insertError } = await supabase
|
||||
.from("user_settings")
|
||||
.insert({
|
||||
user_session: userSession,
|
||||
background_music_enabled: settings.backgroundMusicEnabled ?? false,
|
||||
background_music_volume: settings.backgroundMusicVolume ?? 0.3,
|
||||
background_music_playing: settings.backgroundMusicPlaying ?? false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) throw new SupabaseError("創建用戶設定失敗", insertError)
|
||||
return insertData
|
||||
}
|
||||
|
||||
if (updateError) throw new SupabaseError("更新用戶設定失敗", updateError)
|
||||
return updateData
|
||||
} catch (error) {
|
||||
console.error("Error updating user settings:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 數據遷移服務(從 localStorage 遷移到 Supabase)
|
||||
export class MigrationService {
|
||||
// 遷移 localStorage 中的困擾案例到 Supabase
|
||||
static async migrateWishesFromLocalStorage(): Promise<{
|
||||
success: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
}> {
|
||||
const result = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
}
|
||||
|
||||
try {
|
||||
const localWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
|
||||
|
||||
if (localWishes.length === 0) {
|
||||
console.log("No local wishes to migrate")
|
||||
return result
|
||||
}
|
||||
|
||||
console.log(`Starting migration of ${localWishes.length} wishes...`)
|
||||
|
||||
for (const wish of localWishes) {
|
||||
try {
|
||||
await WishService.createWish({
|
||||
title: wish.title,
|
||||
currentPain: wish.currentPain,
|
||||
expectedSolution: wish.expectedSolution,
|
||||
expectedEffect: wish.expectedEffect,
|
||||
isPublic: wish.isPublic !== false, // 默認為 true
|
||||
email: wish.email,
|
||||
images: wish.images || [],
|
||||
})
|
||||
result.success++
|
||||
} catch (error) {
|
||||
result.failed++
|
||||
result.errors.push(`Failed to migrate wish "${wish.title}": ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration completed: ${result.success} success, ${result.failed} failed`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("Migration error:", error)
|
||||
result.errors.push(`Migration process failed: ${error}`)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 localStorage 中的舊數據
|
||||
static clearLocalStorageData(): void {
|
||||
const keysToRemove = ["wishes", "wishLikes", "userLikedWishes"]
|
||||
keysToRemove.forEach((key) => {
|
||||
localStorage.removeItem(key)
|
||||
})
|
||||
console.log("Local storage data cleared")
|
||||
}
|
||||
}
|
152
lib/supabase.ts
Normal file
152
lib/supabase.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
|
||||
// Supabase 配置
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
|
||||
// 創建 Supabase 客戶端(單例模式)
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false, // 我們不需要用戶認證
|
||||
},
|
||||
db: {
|
||||
schema: "public",
|
||||
},
|
||||
})
|
||||
|
||||
// 數據庫類型定義
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
wishes: {
|
||||
Row: {
|
||||
id: number
|
||||
title: string
|
||||
current_pain: string
|
||||
expected_solution: string
|
||||
expected_effect: string | null
|
||||
is_public: boolean
|
||||
email: string | null
|
||||
images: any[] | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
title: string
|
||||
current_pain: string
|
||||
expected_solution: string
|
||||
expected_effect?: string | null
|
||||
is_public?: boolean
|
||||
email?: string | null
|
||||
images?: any[] | null
|
||||
}
|
||||
Update: {
|
||||
title?: string
|
||||
current_pain?: string
|
||||
expected_solution?: string
|
||||
expected_effect?: string | null
|
||||
is_public?: boolean
|
||||
email?: string | null
|
||||
images?: any[] | null
|
||||
}
|
||||
}
|
||||
wish_likes: {
|
||||
Row: {
|
||||
id: number
|
||||
wish_id: number
|
||||
user_session: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
wish_id: number
|
||||
user_session: string
|
||||
}
|
||||
Update: {
|
||||
wish_id?: number
|
||||
user_session?: string
|
||||
}
|
||||
}
|
||||
user_settings: {
|
||||
Row: {
|
||||
id: number
|
||||
user_session: string
|
||||
background_music_enabled: boolean
|
||||
background_music_volume: number
|
||||
background_music_playing: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
user_session: string
|
||||
background_music_enabled?: boolean
|
||||
background_music_volume?: number
|
||||
background_music_playing?: boolean
|
||||
}
|
||||
Update: {
|
||||
background_music_enabled?: boolean
|
||||
background_music_volume?: number
|
||||
background_music_playing?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
wishes_with_likes: {
|
||||
Row: {
|
||||
id: number
|
||||
title: string
|
||||
current_pain: string
|
||||
expected_solution: string
|
||||
expected_effect: string | null
|
||||
is_public: boolean
|
||||
email: string | null
|
||||
images: any[] | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
like_count: number
|
||||
}
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
get_wishes_stats: {
|
||||
Args: {}
|
||||
Returns: {
|
||||
total_wishes: number
|
||||
public_wishes: number
|
||||
private_wishes: number
|
||||
this_week: number
|
||||
last_week: number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成用戶會話 ID(用於匿名識別)
|
||||
export function getUserSession(): string {
|
||||
if (typeof window === "undefined") return "server-session"
|
||||
|
||||
let session = localStorage.getItem("user_session")
|
||||
if (!session) {
|
||||
session = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem("user_session", session)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// 測試 Supabase 連接
|
||||
export async function testSupabaseConnection(): Promise<boolean> {
|
||||
try {
|
||||
const { data, error } = await supabase.from("wishes").select("count").limit(1)
|
||||
|
||||
if (error) {
|
||||
console.error("Supabase connection test failed:", error)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log("✅ Supabase connection successful")
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Supabase connection test error:", error)
|
||||
return false
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user