From 158fa1ac22dd9bce2e1ea81a55f9823febbaa069 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 26 Aug 2026 06:38:36 +0700 Subject: [PATCH] x --- .../src/pages/backup/BackupCatalogPage.tsx | 478 ++++++++++--- apps/admin/src/pages/backup/BackupCmsPage.tsx | 479 ++++++++++--- .../src/pages/backup/BackupDashboardPage.tsx | 36 +- .../src/pages/backup/BackupMediaPage.tsx | 391 ++++++++--- .../src/pages/backup/BackupOrdersPage.tsx | 7 +- apps/backend/src/routes/backup.ts | 634 ++++++++++++++++++ apps/website/src/lib/api.ts | 6 +- 7 files changed, 1712 insertions(+), 319 deletions(-) diff --git a/apps/admin/src/pages/backup/BackupCatalogPage.tsx b/apps/admin/src/pages/backup/BackupCatalogPage.tsx index d0bf50a..a7230f7 100644 --- a/apps/admin/src/pages/backup/BackupCatalogPage.tsx +++ b/apps/admin/src/pages/backup/BackupCatalogPage.tsx @@ -1,5 +1,17 @@ -import { useState } from 'react' -import { Package, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react' +import { useState, useRef } from 'react' +import { + Package, + Download, + Upload, + CheckCircle2, + ChevronLeft, + Loader2, + AlertTriangle, + FileCode, + FolderTree, + Sliders, + Tag, +} from 'lucide-react' import { NavLink } from 'react-router-dom' import { API_BASE } from '@homaroma/types' @@ -15,16 +27,42 @@ const SECTIONS: { key: ExportSection; label: string; description: string }[] = [ ] export default function BackupCatalogPage() { + const [activeTab, setActiveTab] = useState<'export' | 'restore'>('export') + + // Export states const [selectedSection, setSelectedSection] = useState('all') const [format, setFormat] = useState('json') const [loading, setLoading] = useState(false) const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + // Restore states + const [restoreFile, setRestoreFile] = useState(null) + const [restorePreview, setRestorePreview] = useState<{ + productsCount: number + categoriesCount: number + attributesCount: number + brandsCount: number + rawPayload: any + } | null>(null) + const [restoreLoading, setRestoreLoading] = useState(false) + const [restoreResult, setRestoreResult] = useState<{ + success: boolean + message: string + summary?: any + } | null>(null) + const [confirmRestore, setConfirmRestore] = useState(false) + const fileInputRef = useRef(null) + const handleExport = async () => { setLoading(true) setResult(null) try { - const res = await fetch(`${API_BASE}/backup/catalog?section=${selectedSection}&format=${format}`) + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/catalog?section=${selectedSection}&format=${format}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Export failed' })) setResult({ success: false, message: err.error || 'Export failed' }) @@ -32,7 +70,7 @@ export default function BackupCatalogPage() { } const blob = await res.blob() const ext = format === 'json' ? 'json' : 'csv' - const filename = `homaroma_catalog_${selectedSection}_${new Date().toISOString().slice(0,10)}.${ext}` + const filename = `homaroma_catalog_${selectedSection}_${new Date().toISOString().slice(0, 10)}.${ext}` const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -47,6 +85,86 @@ export default function BackupCatalogPage() { } } + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setRestoreFile(file) + setRestoreResult(null) + setConfirmRestore(false) + + const reader = new FileReader() + reader.onload = (event) => { + try { + const text = event.target?.result as string + const parsed = JSON.parse(text) + const payload = parsed.data || parsed + + const categories = Array.isArray(payload.categories) ? payload.categories : [] + const attributes = Array.isArray(payload.attributes) ? payload.attributes : [] + const brands = Array.isArray(payload.brands) ? payload.brands : [] + const products = Array.isArray(payload.products) + ? payload.products + : Array.isArray(payload) && payload[0]?.variants + ? payload + : [] + + setRestorePreview({ + categoriesCount: categories.length, + attributesCount: attributes.length, + brandsCount: brands.length, + productsCount: products.length, + rawPayload: payload, + }) + } catch { + setRestoreResult({ + success: false, + message: 'ไฟล์ JSON ไม่ถูกต้อง กรุณาเลือกไฟล์ Catalog Backup ที่ถูกต้อง', + }) + setRestorePreview(null) + } + } + reader.readAsText(file) + } + + const handleRestoreSubmit = async () => { + if (!restorePreview?.rawPayload) return + setRestoreLoading(true) + setRestoreResult(null) + try { + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/restore/catalog`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ data: restorePreview.rawPayload }), + }) + const data = await res.json() + if (!res.ok || !data.success) { + setRestoreResult({ + success: false, + message: data.error || data.detail || 'Catalog Restore failed', + }) + return + } + + setRestoreResult({ + success: true, + message: 'นำเข้าและคืนค่า Catalog สำเร็จเรียบร้อย!', + summary: data.summary, + }) + setConfirmRestore(false) + } catch { + setRestoreResult({ + success: false, + message: 'Network error — ไม่สามารถส่งข้อมูลไปยัง backend ได้', + }) + } finally { + setRestoreLoading(false) + } + } + return (
{/* Breadcrumb */} @@ -64,121 +182,275 @@ export default function BackupCatalogPage() {
-

Catalog Export

+

Catalog Backup & Restore

- Export Products, Categories, Attributes และ Brands เป็น JSON หรือ CSV + Export หรือ Import ข้อมูลสินค้า, หมวดหมู่, Attributes และ แบรนด์

- {/* Phase 2 notice */} -
- -

- ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} - GET /backup/catalog{' '} - ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API -

+ {/* Tab Switcher */} +
+ +
-
- {/* Section Selector */} -
- -
- {SECTIONS.map((s) => ( - - ))} +
+ {selectedSection === s.key && ( +
+ )} +
+
+

{s.label}

+

{s.description}

+
+ + ))} +
-
- {/* Format Selector */} -
- -
- {(['json', 'csv'] as ExportFormat[]).map((f) => ( - - ))} + {/* Format Selector */} +
+ +
+ {(['json', 'csv'] as ExportFormat[]).map((f) => ( + + ))} +
+ {format === 'json' && ( +

JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ

+ )} + {format === 'csv' && ( +

CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets

+ )}
- {format === 'json' && ( -

JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ

- )} - {format === 'csv' && ( -

CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets

- )} -
- {/* Export Button */} - - - {/* Result */} - {result && ( -
- - {result.message} + {loading ? ( + <> + + กำลัง Export... + + ) : ( + <> + + Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find((s) => s.key === selectedSection)?.label} + + )} + + + {/* Result */} + {result && ( +
+ + {result.message} +
+ )} +
+ )} + + {/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */} + {activeTab === 'restore' && ( +
+
+ +

+ Restore / Import Catalog from JSON +

- )} -
+ +

+ อัปโหลดไฟล์ .json{' '} + Catalog เพื่อ Upsert สินค้า, หมวดหมู่, Attributes และแบรนด์กลับเข้าระบบ +

+ + {/* File Picker Area */} +
fileInputRef.current?.click()} + className="border-2 border-dashed border-white/10 hover:border-emerald-500/40 rounded-xl p-8 text-center cursor-pointer transition-all bg-white/2 hover:bg-emerald-500/5 group" + > + +
+ +
+

+ {restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ JSON หรือลากไฟล์มาวางที่นี่'} +

+

+ {restoreFile + ? `${(restoreFile.size / 1024).toFixed(1)} KB` + : 'รองรับไฟล์ Catalog Backup (.json)'} +

+
+ + {/* Preview Card */} + {restorePreview && ( +
+
+ + ข้อมูลที่พบในไฟล์ Catalog Backup + + + Valid JSON + +
+ +
+
+ +

{restorePreview.productsCount}

+

Products

+
+
+ +

{restorePreview.categoriesCount}

+

Categories

+
+
+ +

{restorePreview.attributesCount}

+

Attributes

+
+
+ +

{restorePreview.brandsCount}

+

Brands

+
+
+ + {/* Confirmation Checkbox */} +
+ + +
+ + {/* Restore Button */} + +
+ )} + + {/* Restore Result Banner */} + {restoreResult && ( +
+
+ + {restoreResult.message} +
+ {restoreResult.summary && ( +
+

• Products: {restoreResult.summary.products} (Variants: {restoreResult.summary.variants})

+

• Categories: {restoreResult.summary.categories}

+

• Attributes: {restoreResult.summary.attributes}

+

• Brands: {restoreResult.summary.brands}

+
+ )} +
+ )} +
+ )}
) } + diff --git a/apps/admin/src/pages/backup/BackupCmsPage.tsx b/apps/admin/src/pages/backup/BackupCmsPage.tsx index 44ce4cd..0abb29b 100644 --- a/apps/admin/src/pages/backup/BackupCmsPage.tsx +++ b/apps/admin/src/pages/backup/BackupCmsPage.tsx @@ -1,5 +1,18 @@ -import { useState } from 'react' -import { FileJson, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react' +import { useState, useRef } from 'react' +import { + FileJson, + Download, + Upload, + CheckCircle2, + ChevronLeft, + Loader2, + AlertTriangle, + FileCode, + Layers, + Menu as MenuIcon, + BookOpen, + Settings as SettingsIcon, +} from 'lucide-react' import { NavLink } from 'react-router-dom' import { API_BASE } from '@homaroma/types' @@ -14,15 +27,41 @@ const SECTIONS: { key: ExportSection; label: string; description: string }[] = [ ] export default function BackupCmsPage() { + const [activeTab, setActiveTab] = useState<'export' | 'restore'>('export') + + // Export states const [selectedSection, setSelectedSection] = useState('all') const [loading, setLoading] = useState(false) const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + // Restore states + const [restoreFile, setRestoreFile] = useState(null) + const [restorePreview, setRestorePreview] = useState<{ + pagesCount: number + menusCount: number + articlesCount: number + hasSettings: boolean + rawPayload: any + } | null>(null) + const [restoreLoading, setRestoreLoading] = useState(false) + const [restoreResult, setRestoreResult] = useState<{ + success: boolean + message: string + summary?: any + } | null>(null) + const [confirmRestore, setConfirmRestore] = useState(false) + const fileInputRef = useRef(null) + const handleExport = async () => { setLoading(true) setResult(null) try { - const res = await fetch(`${API_BASE}/backup/cms?section=${selectedSection}`) + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/cms?section=${selectedSection}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Export failed' })) setResult({ success: false, message: err.error || 'Export failed' }) @@ -44,6 +83,90 @@ export default function BackupCmsPage() { } } + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setRestoreFile(file) + setRestoreResult(null) + setConfirmRestore(false) + + const reader = new FileReader() + reader.onload = (event) => { + try { + const text = event.target?.result as string + const parsed = JSON.parse(text) + const payload = parsed.data || parsed + + const pages = Array.isArray(payload.pages) + ? payload.pages + : Array.isArray(payload) && payload[0]?.slug && payload[0]?.sections + ? payload + : [] + const menus = Array.isArray(payload.menus) ? payload.menus : [] + const articles = Array.isArray(payload.articles) + ? payload.articles + : Array.isArray(payload) && payload[0]?.slug && payload[0]?.content + ? payload + : [] + const hasSettings = !!(payload.settings || payload.brandName) + + setRestorePreview({ + pagesCount: pages.length, + menusCount: menus.length, + articlesCount: articles.length, + hasSettings, + rawPayload: payload, + }) + } catch { + setRestoreResult({ + success: false, + message: 'ไฟล์ JSON ไม่ถูกต้อง กรุณาเลือกไฟล์ CMS Backup ที่ถูกต้อง', + }) + setRestorePreview(null) + } + } + reader.readAsText(file) + } + + const handleRestoreSubmit = async () => { + if (!restorePreview?.rawPayload) return + setRestoreLoading(true) + setRestoreResult(null) + try { + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/restore/cms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ data: restorePreview.rawPayload }), + }) + const data = await res.json() + if (!res.ok || !data.success) { + setRestoreResult({ + success: false, + message: data.error || data.detail || 'Restore failed', + }) + return + } + + setRestoreResult({ + success: true, + message: 'นำเข้าและคืนค่า CMS Content สำเร็จเรียบร้อย!', + summary: data.summary, + }) + setConfirmRestore(false) + } catch { + setRestoreResult({ + success: false, + message: 'Network error — ไม่สามารถส่งข้อมูลไปยัง backend ได้', + }) + } finally { + setRestoreLoading(false) + } + } + return (
{/* Breadcrumb */} @@ -61,115 +184,281 @@ export default function BackupCmsPage() {
-

CMS & Content Export

+

CMS & Content Backup / Restore

- Export Pages/Sections, Menus, Articles และ Store Settings เป็น JSON + Export หรือ Restore Pages/Sections, Menus, Articles และ Store Settings ด้วยไฟล์ JSON

- {/* Phase 2 notice */} -
- -

- ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} - GET /backup/cms{' '} - ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API -

+ {/* Tab Switcher */} +
+ +
-
- {/* What's included detail */} -
-

ข้อมูลที่รวมอยู่ใน CMS Export

-
- {[ - 'Home Page sections', 'Shop Page sections', - 'Product Detail sections', 'Category Page sections', - 'All dynamic CMS pages', 'Main Navigation tree', - 'Footer Explore menu', 'Footer Support menu', - 'Footer Brand info', 'Articles (Scent Guide)', - 'Store Settings', 'Theme tokens & colors', - 'Newsletter settings', 'Footer copy & translations', - ].map((item) => ( -
-
- {item} -
- ))} + {/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */} + {activeTab === 'export' && ( +
+ {/* What's included detail */} +
+

+ ข้อมูลที่รวมอยู่ใน CMS Export +

+
+ {[ + 'Home Page sections', + 'Shop Page sections', + 'Product Detail sections', + 'Category Page sections', + 'All dynamic CMS pages', + 'Main Navigation tree', + 'Footer Explore menu', + 'Footer Support menu', + 'Footer Brand info', + 'Articles (Scent Guide)', + 'Store Settings', + 'Theme tokens & colors', + 'Newsletter settings', + 'Footer copy & translations', + ].map((item) => ( +
+
+ {item} +
+ ))} +
-
- {/* Section Selector */} -
- -
- {SECTIONS.map((s) => ( - - ))} +
+ {selectedSection === s.key && ( +
+ )} +
+
+

{s.label}

+

{s.description}

+
+ + ))} +
-
- {/* Export Button — JSON only for CMS */} -
-

Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)

- -
+ {/* Export Button */} +
+

Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)

+ +
- {/* Result */} - {result && ( + {/* Result Banner */} + {result && ( +
+ + {result.message} +
+ )} +
+ )} + + {/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */} + {activeTab === 'restore' && ( +
+
+ +

+ Restore CMS from JSON +

+
+ +

+ อัปโหลดไฟล์ .json{' '} + ที่เคย Export ออกจากระบบ เพื่อ Restore หน้าเว็บ, บล็อกส่วนต่างๆ, เมนู, บทความ หรือ Store Settings +

+ + {/* File Picker Area */}
fileInputRef.current?.click()} + className="border-2 border-dashed border-white/10 hover:border-emerald-500/40 rounded-xl p-8 text-center cursor-pointer transition-all bg-white/2 hover:bg-emerald-500/5 group" > - - {result.message} + +
+ +
+

+ {restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ JSON หรือลากไฟล์มาวางที่นี่'} +

+

+ {restoreFile + ? `${(restoreFile.size / 1024).toFixed(1)} KB` + : 'รองรับไฟล์ CMS Backup (.json)'} +

- )} -
+ + {/* Preview Card */} + {restorePreview && ( +
+
+ + ข้อมูลที่พบในไฟล์ Backup + + + Valid JSON + +
+ +
+
+ +

{restorePreview.pagesCount}

+

Pages

+
+
+ +

{restorePreview.menusCount}

+

Menus

+
+
+ +

{restorePreview.articlesCount}

+

Articles

+
+
+ +

+ {restorePreview.hasSettings ? 'YES' : 'NO'} +

+

Store Settings

+
+
+ + {/* Confirmation Checkbox */} +
+ + +
+ + {/* Restore Button */} + +
+ )} + + {/* Restore Result Banner */} + {restoreResult && ( +
+
+ + {restoreResult.message} +
+ {restoreResult.summary && ( +
+

• Pages Restored: {restoreResult.summary.pages} (Sections: {restoreResult.summary.sections})

+

• Menus Restored: {restoreResult.summary.menus} (Items: {restoreResult.summary.menuItems})

+

• Articles Restored: {restoreResult.summary.articles}

+

• Store Settings: {restoreResult.summary.settings ? 'Updated' : 'Unchanged'}

+
+ )} +
+ )} +
+ )}
) } diff --git a/apps/admin/src/pages/backup/BackupDashboardPage.tsx b/apps/admin/src/pages/backup/BackupDashboardPage.tsx index 8013204..fbb394b 100644 --- a/apps/admin/src/pages/backup/BackupDashboardPage.tsx +++ b/apps/admin/src/pages/backup/BackupDashboardPage.tsx @@ -27,24 +27,23 @@ interface BackupCard { const BACKUP_CARDS: BackupCard[] = [ { icon: DatabaseBackup, - title: 'Full Database Backup', + title: 'Full Database Backup & Restore', description: - 'Export PostgreSQL dump ทั้งหมด (.sql) ครอบคลุมทุกตาราง — ใช้สำหรับ Disaster Recovery', + 'ภาพรวมฐานข้อมูล Real-time + คู่มือคำสั่ง Full PostgreSQL Dump & Restore ผ่าน SSH', to: '/backup/database', accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20', badgeText: 'CRITICAL', badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', - note: 'ต้องการ backend endpoint (Phase 2)', }, { icon: Package, - title: 'Catalog Export', + title: 'Catalog Export & Restore', description: - 'Products, Variants, Categories, Attributes, Brands และ Translations ทั้งหมด', + 'Export และ Import ข้อมูลสินค้า, Variants, Categories, Attributes, Brands และ Translations', to: '/backup/catalog', accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', - badgeText: 'CRITICAL', - badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + badgeText: 'EXPORT + IMPORT', + badgeColor: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30', }, { icon: ShoppingCart, @@ -53,29 +52,28 @@ const BACKUP_CARDS: BackupCard[] = [ 'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter', to: '/backup/orders', accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20', - badgeText: 'CRITICAL', - badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', + badgeText: 'EXPORT', + badgeColor: 'bg-violet-500/20 text-violet-300 border border-violet-500/30', }, { icon: FileJson, - title: 'CMS & Content Export', + title: 'CMS & Content Export / Restore', description: - 'Pages/Sections, Menus (Main + Footer), Articles, Store Settings', + 'Export และ Restore โครงสร้าง Pages/Sections, Menus, Articles, และ Store Settings ผ่านไฟล์ JSON', to: '/backup/cms', accentColor: 'text-amber-400 bg-amber-500/10 border-amber-500/20', - badgeText: 'HIGH', + badgeText: 'EXPORT + RESTORE', badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', }, { icon: FolderArchive, - title: 'Media Files', + title: 'Media Files Backup & Restore', description: - 'ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ — download เป็น .zip', + 'Download และ Restore อัปโหลดไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ ด้วยไฟล์ .zip', to: '/backup/media', accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', - badgeText: 'HIGH', - badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', - note: 'ต้องการ backend endpoint (Phase 2)', + badgeText: 'ZIP EXPORT + RESTORE', + badgeColor: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30', }, ] @@ -151,7 +149,7 @@ export default function BackupDashboardPage() {

Phase

-

1 — Manual Export

+

2 — Export & Restore Active

@@ -197,7 +195,7 @@ export default function BackupDashboardPage() { {/* Footer Note */}
- Backup & Restore — Phase 1 (Manual Export) · Phase 2 จะเพิ่ม Scheduled Backup และ Server-side Storage + Backup & Restore — Phase 2 (Full Export & Selective Restore) พร้อมใช้งาน
) diff --git a/apps/admin/src/pages/backup/BackupMediaPage.tsx b/apps/admin/src/pages/backup/BackupMediaPage.tsx index da76895..b09a4ad 100644 --- a/apps/admin/src/pages/backup/BackupMediaPage.tsx +++ b/apps/admin/src/pages/backup/BackupMediaPage.tsx @@ -1,17 +1,49 @@ -import { useState } from 'react' -import { FolderArchive, Download, ChevronLeft, Info, AlertTriangle, Loader2, CheckCircle2, Image, FileVideo, File } from 'lucide-react' +import { useState, useRef } from 'react' +import { + FolderArchive, + Download, + Upload, + ChevronLeft, + AlertTriangle, + Loader2, + CheckCircle2, + Image, + FileVideo, + File, + FileArchive, +} from 'lucide-react' import { NavLink } from 'react-router-dom' import { API_BASE } from '@homaroma/types' export default function BackupMediaPage() { + const [activeTab, setActiveTab] = useState<'download' | 'restore'>('download') + + // Download states const [loading, setLoading] = useState(false) const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) + // Restore states + const [restoreFile, setRestoreFile] = useState(null) + const [restoreLoading, setRestoreLoading] = useState(false) + const [restoreResult, setRestoreResult] = useState<{ + success: boolean + message: string + totalFiles?: number + syncedRecords?: number + } | null>(null) + const [confirmRestore, setConfirmRestore] = useState(false) + const fileInputRef = useRef(null) + const handleDownload = async () => { setLoading(true) setResult(null) try { - const res = await fetch(`${API_BASE}/backup/media/zip`) + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/media/zip`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Download failed' })) setResult({ success: false, message: err.error || 'Download failed' }) @@ -33,6 +65,57 @@ export default function BackupMediaPage() { } } + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setRestoreFile(file) + setRestoreResult(null) + setConfirmRestore(false) + } + + const handleRestoreSubmit = async () => { + if (!restoreFile) return + setRestoreLoading(true) + setRestoreResult(null) + try { + const token = localStorage.getItem('homaroma_admin_token') + const formData = new FormData() + formData.append('file', restoreFile) + + const res = await fetch(`${API_BASE}/backup/restore/media`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }) + + const data = await res.json() + if (!res.ok || !data.success) { + setRestoreResult({ + success: false, + message: data.error || data.detail || 'Media Restore failed', + }) + return + } + + setRestoreResult({ + success: true, + message: data.message || 'แตกไฟล์และกู้คืนรูปภาพเข้าสู่ /uploads เรียบร้อยแล้ว!', + totalFiles: data.totalFiles, + syncedRecords: data.syncedRecords, + }) + setConfirmRestore(false) + } catch { + setRestoreResult({ + success: false, + message: 'Network error — ไม่สามารถอัปโหลดไฟล์ไปยัง backend ได้', + }) + } finally { + setRestoreLoading(false) + } + } + return (
{/* Breadcrumb */} @@ -50,111 +133,223 @@ export default function BackupMediaPage() {
-

Media Files Backup

+

Media Files Backup & Restore

- Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip + ดาวน์โหลด หรือ อัปโหลดคืนค่าไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip

- {/* Phase 2 Notice */} -
-
- -
-

Phase 2 Feature

-

- Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2 + {/* Tab Switcher */} +

+ + +
+ + {/* ─── TAB: DOWNLOAD ─────────────────────────────────────────────────────── */} + {activeTab === 'download' && ( +
+ {/* What's included */} +
+

+ ไฟล์ที่จะถูก Backup +

+
+ {[ + { icon: Image, label: 'Product Images', desc: 'รูปภาพสินค้าทุกตัวที่อัปโหลดผ่าน Admin', color: 'text-cyan-400' }, + { icon: Image, label: 'Category Images', desc: 'รูป Thumbnail ของแต่ละหมวดหมู่สินค้า', color: 'text-violet-400' }, + { icon: Image, label: 'Article Images', desc: 'รูปประกอบบทความ Scent Guide', color: 'text-amber-400' }, + { icon: Image, label: 'CMS Media', desc: 'รูปที่ใช้ใน Page Builder sections', color: 'text-emerald-400' }, + { icon: FileVideo, label: 'Video Assets', desc: 'วิดีโอหรือ GIF ที่ใช้ใน hero sections', color: 'text-rose-400' }, + { icon: File, label: 'Other Assets', desc: 'ไฟล์อื่นๆ ใน public/uploads/', color: 'text-slate-400' }, + ].map(({ icon: Icon, label, desc, color }) => ( +
+ +
+

{label}

+

{desc}

+
+
+ ))} +
+
+ + {/* Size Warning */} +
+ +

+ ไฟล์ Media อาจมีขนาดใหญ่ตามปริมาณรูปภาพ — ใช้ SSH/SCP สำหรับกรณี Backup ระดับหลายร้อย MB หรือ GB

-
-

- เมื่อ Backend endpoint{' '} - GET /backup/media/zip{' '} - พร้อม ระบบจะ zip โฟลเดอร์{' '} - public/uploads/{' '} - และ stream ไฟล์กลับมา download โดยตรง -

-
- {/* What's included */} -
-

- ไฟล์ที่จะถูก Backup -

-
- {[ - { icon: Image, label: 'Product Images', desc: 'รูปภาพสินค้าทุกตัวที่อัปโหลดผ่าน Admin', color: 'text-cyan-400' }, - { icon: Image, label: 'Category Images', desc: 'รูป Thumbnail ของแต่ละหมวดหมู่สินค้า', color: 'text-violet-400' }, - { icon: Image, label: 'Article Images', desc: 'รูปประกอบบทความ Scent Guide', color: 'text-amber-400' }, - { icon: Image, label: 'CMS Media', desc: 'รูปที่ใช้ใน Page Builder sections', color: 'text-emerald-400' }, - { icon: FileVideo, label: 'Video Assets', desc: 'วิดีโอหรือ GIF ที่ใช้ใน hero sections', color: 'text-rose-400' }, - { icon: File, label: 'Other Assets', desc: 'ไฟล์อื่นๆ ใน public/uploads/', color: 'text-slate-400' }, - ].map(({ icon: Icon, label, desc, color }) => ( -
- -
-

{label}

-

{desc}

-
+ {/* Manual SCP */} +
+

Manual Download (ผ่าน SSH/SCP)

+
+

# rsync media folder จาก production

+

{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}

- ))} -
-
- - {/* Size Warning */} -
- -

- ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า -

-
- - {/* Manual SCP */} -
-

Manual Download (ผ่าน SSH/SCP)

-
-

# rsync media folder จาก production

-

{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}

-
-
- - {/* Download Button */} -
-

- เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด -

- - - {result && ( -
- - {result.message}
- )} -
+ + {/* Download Button */} +
+

+ กดปุ่มด้านล่างเพื่อสั่งให้ Server บีบอัดโฟลเดอร์ /uploads/ และดาวน์โหลดไฟล์ .zip +

+ + + {result && ( +
+ + {result.message} +
+ )} +
+
+ )} + + {/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */} + {activeTab === 'restore' && ( +
+
+ +

+ Restore Media from .zip +

+
+ +

+ อัปโหลดไฟล์ .zip{' '} + ที่มีรูปภาพเพื่อแตกไฟล์คืนเข้าโฟลเดอร์ public/uploads/ และลงทะเบียนเข้าฐานข้อมูล Media +

+ + {/* File Picker Area */} +
fileInputRef.current?.click()} + className="border-2 border-dashed border-white/10 hover:border-emerald-500/40 rounded-xl p-8 text-center cursor-pointer transition-all bg-white/2 hover:bg-emerald-500/5 group" + > + +
+ +
+

+ {restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ .zip หรือลากไฟล์มาวางที่นี่'} +

+

+ {restoreFile + ? `${(restoreFile.size / (1024 * 1024)).toFixed(2)} MB` + : 'รองรับไฟล์บีบอัด .zip'} +

+
+ + {restoreFile && ( +
+ {/* Confirmation Checkbox */} +
+ + +
+ + {/* Restore Button */} + +
+ )} + + {/* Restore Result Banner */} + {restoreResult && ( +
+
+ + {restoreResult.message} +
+ {restoreResult.totalFiles !== undefined && ( +

+ • จำนวนไฟล์ปัจจุบันใน /uploads: {restoreResult.totalFiles} ไฟล์ ({restoreResult.syncedRecords} ไฟล์ใหม่ที่ index เข้าฐานข้อมูล) +

+ )} +
+ )} +
+ )}
) } + diff --git a/apps/admin/src/pages/backup/BackupOrdersPage.tsx b/apps/admin/src/pages/backup/BackupOrdersPage.tsx index 4d8cf37..a8b3e11 100644 --- a/apps/admin/src/pages/backup/BackupOrdersPage.tsx +++ b/apps/admin/src/pages/backup/BackupOrdersPage.tsx @@ -36,7 +36,12 @@ export default function BackupOrdersPage() { params.set('from', dateFrom) params.set('to', dateTo) } - const res = await fetch(`${API_BASE}/backup/orders?${params}`) + const token = localStorage.getItem('homaroma_admin_token') + const res = await fetch(`${API_BASE}/backup/orders?${params}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Export failed' })) setResult({ success: false, message: err.error || 'Export failed' }) diff --git a/apps/backend/src/routes/backup.ts b/apps/backend/src/routes/backup.ts index d1cc748..5d3ff4b 100644 --- a/apps/backend/src/routes/backup.ts +++ b/apps/backend/src/routes/backup.ts @@ -3,6 +3,7 @@ import prisma from "../lib/prisma"; import { adminOnly } from "../middleware/auth"; import path from "path"; import fs from "fs"; +import os from "os"; // ─── Helper: stream JSON as file download ──────────────────────────────────── @@ -587,4 +588,637 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) set.status = 500; return { error: "Database info failed", detail: err?.message }; } + }) + + // ── 7. Restore CMS Content ──────────────────────────────────────────────── + /** + * POST /api/backup/restore/cms + * Restores Pages, Sections, Navigation Menus, Articles, and Store Settings from JSON. + */ + .post("/restore/cms", async ({ body, set }: { body: any; set: any }) => { + try { + const payload = body?.data || body; + if (!payload || typeof payload !== "object") { + set.status = 400; + return { error: "Invalid CMS payload: Expected object with pages, menus, articles, or settings" }; + } + + const summary = { + pages: 0, + sections: 0, + menus: 0, + menuItems: 0, + articles: 0, + settings: false, + }; + + // 1. Restore Store Settings + const settings = payload.settings || (payload.brandName ? payload : null); + if (settings) { + const { id, translations, updatedAt, ...settingFields } = settings; + await prisma.storeSettings.upsert({ + where: { id: 1 }, + update: { + ...settingFields, + updatedAt: new Date(), + }, + create: { + id: 1, + ...settingFields, + }, + }); + + if (Array.isArray(translations)) { + for (const t of translations) { + if (t.langCode) { + await prisma.storeSettingsTranslation.upsert({ + where: { + storeSettingsId_langCode: { + storeSettingsId: 1, + langCode: t.langCode, + }, + }, + update: { + footerDescription: t.footerDescription, + newsletterTitle: t.newsletterTitle, + newsletterDescription: t.newsletterDescription, + newsletterPlaceholder: t.newsletterPlaceholder, + newsletterButtonText: t.newsletterButtonText, + }, + create: { + storeSettingsId: 1, + langCode: t.langCode, + footerDescription: t.footerDescription, + newsletterTitle: t.newsletterTitle, + newsletterDescription: t.newsletterDescription, + newsletterPlaceholder: t.newsletterPlaceholder, + newsletterButtonText: t.newsletterButtonText, + }, + }); + } + } + } + summary.settings = true; + } + + // 2. Restore Articles + const articles = Array.isArray(payload.articles) + ? payload.articles + : (Array.isArray(payload) && payload[0]?.slug && payload[0]?.content ? payload : []); + for (const art of articles) { + if (!art.slug || !art.title) continue; + const { id, translations, createdAt, updatedAt, ...artFields } = art; + const savedArticle = await prisma.article.upsert({ + where: { slug: art.slug }, + update: { + ...artFields, + updatedAt: new Date(), + }, + create: { + ...artFields, + }, + }); + + if (Array.isArray(translations)) { + for (const t of translations) { + if (t.langCode) { + await prisma.articleTranslation.upsert({ + where: { + articleId_langCode: { + articleId: savedArticle.id, + langCode: t.langCode, + }, + }, + update: { + title: t.title, + content: t.content, + }, + create: { + articleId: savedArticle.id, + langCode: t.langCode, + title: t.title, + content: t.content, + }, + }); + } + } + } + summary.articles++; + } + + // 3. Restore Menus + const menus = Array.isArray(payload.menus) ? payload.menus : []; + for (const menu of menus) { + if (!menu.name) continue; + const savedMenu = await prisma.menu.upsert({ + where: { name: menu.name }, + update: { + payload: menu.payload, + rootImage: menu.rootImage, + isActive: menu.isActive ?? true, + }, + create: { + name: menu.name, + payload: menu.payload, + rootImage: menu.rootImage, + isActive: menu.isActive ?? true, + }, + }); + + if (Array.isArray(menu.items)) { + await prisma.menuItem.deleteMany({ where: { menuId: savedMenu.id } }); + for (const item of menu.items) { + const createdItem = await prisma.menuItem.create({ + data: { + menuId: savedMenu.id, + label: item.label, + url: item.url, + order: item.order ?? 0, + columnImage: item.columnImage, + hoverImage: item.hoverImage, + }, + }); + if (Array.isArray(item.translations)) { + for (const t of item.translations) { + if (t.langCode) { + await prisma.menuItemTranslation.create({ + data: { + menuItemId: createdItem.id, + langCode: t.langCode, + label: t.label, + }, + }); + } + } + } + summary.menuItems++; + } + } + summary.menus++; + } + + // 4. Restore Pages & Sections + const pages = Array.isArray(payload.pages) + ? payload.pages + : (Array.isArray(payload) && payload[0]?.slug && (payload[0]?.sections || payload[0]?.title) ? payload : []); + for (const pg of pages) { + if (!pg.slug || !pg.title) continue; + const savedPage = await prisma.page.upsert({ + where: { slug: pg.slug }, + update: { + title: pg.title, + isPublished: pg.isPublished ?? true, + }, + create: { + slug: pg.slug, + title: pg.title, + isPublished: pg.isPublished ?? true, + }, + }); + + if (Array.isArray(pg.translations)) { + for (const t of pg.translations) { + if (t.langCode) { + await prisma.pageTranslation.upsert({ + where: { + pageId_langCode: { + pageId: savedPage.id, + langCode: t.langCode, + }, + }, + update: { title: t.title }, + create: { pageId: savedPage.id, langCode: t.langCode, title: t.title }, + }); + } + } + } + + if (Array.isArray(pg.sections)) { + await prisma.pageSection.deleteMany({ where: { pageId: savedPage.id } }); + for (const sec of pg.sections) { + await prisma.pageSection.create({ + data: { + pageId: savedPage.id, + type: sec.type, + order: sec.order ?? 0, + payload: sec.payload ?? {}, + isActive: sec.isActive ?? true, + }, + }); + summary.sections++; + } + } + summary.pages++; + } + + return { + success: true, + message: "CMS restored successfully", + summary, + }; + } catch (err: any) { + console.error("[Backup] CMS restore error:", err); + set.status = 500; + return { error: "CMS restore failed", detail: err?.message }; + } + }) + + // ── 8. Restore Catalog ──────────────────────────────────────────────────── + /** + * POST /api/backup/restore/catalog + * Restores Categories, AttributeDefinitions, Brands, and Products from JSON. + */ + .post("/restore/catalog", async ({ body, set }: { body: any; set: any }) => { + try { + const payload = body?.data || body; + if (!payload || typeof payload !== "object") { + set.status = 400; + return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" }; + } + + const summary = { + categories: 0, + attributes: 0, + brands: 0, + products: 0, + variants: 0, + }; + + // 1. Categories + const categories = Array.isArray(payload.categories) ? payload.categories : []; + const categoryMap = new Map(); + + for (const cat of categories) { + if (!cat.name) continue; + const savedCat = await prisma.category.upsert({ + where: { name: cat.name }, + update: { + imageUrl: cat.imageUrl, + deletedAt: null, + }, + create: { + name: cat.name, + imageUrl: cat.imageUrl, + }, + }); + if (cat.id) categoryMap.set(cat.id, savedCat.id); + + if (Array.isArray(cat.translations)) { + for (const t of cat.translations) { + if (t.langCode && t.name) { + await prisma.categoryTranslation.upsert({ + where: { + categoryId_langCode: { + categoryId: savedCat.id, + langCode: t.langCode, + }, + }, + update: { name: t.name }, + create: { categoryId: savedCat.id, langCode: t.langCode, name: t.name }, + }); + } + } + } + summary.categories++; + } + + // 2. Brands + const brands = Array.isArray(payload.brands) ? payload.brands : []; + const brandMap = new Map(); + + for (const b of brands) { + if (!b.name) continue; + const savedBrand = await prisma.productBrand.upsert({ + where: { name: b.name }, + update: {}, + create: { name: b.name }, + }); + if (b.id) brandMap.set(b.id, savedBrand.id); + + if (Array.isArray(b.translations)) { + for (const t of b.translations) { + if (t.langCode && t.name) { + await prisma.productBrandTranslation.upsert({ + where: { + brandId_langCode: { + brandId: savedBrand.id, + langCode: t.langCode, + }, + }, + update: { name: t.name }, + create: { brandId: savedBrand.id, langCode: t.langCode, name: t.name }, + }); + } + } + } + summary.brands++; + } + + // Fallback category and brand + let defaultCatId = categoryMap.values().next().value; + if (!defaultCatId) { + const firstCat = await prisma.category.findFirst({ where: { deletedAt: null } }); + if (firstCat) { + defaultCatId = firstCat.id; + } else { + const newCat = await prisma.category.create({ data: { name: "General" } }); + defaultCatId = newCat.id; + } + } + + let defaultBrandId = brandMap.values().next().value; + if (!defaultBrandId) { + const firstBrand = await prisma.productBrand.findFirst(); + if (firstBrand) { + defaultBrandId = firstBrand.id; + } else { + const newBrand = await prisma.productBrand.create({ data: { name: "HomAroma" } }); + defaultBrandId = newBrand.id; + } + } + + // 3. Attribute Definitions + const attributes = Array.isArray(payload.attributes) ? payload.attributes : []; + for (const attr of attributes) { + if (!attr.code || !attr.label) continue; + const mappedCatId = attr.categoryId ? (categoryMap.get(attr.categoryId) || defaultCatId) : null; + const savedAttr = await prisma.attributeDefinition.upsert({ + where: { code: attr.code }, + update: { + label: attr.label, + description: attr.description, + type: attr.type || "TEXT", + unit: attr.unit, + options: attr.options, + isRequired: attr.isRequired ?? false, + isFilterable: attr.isFilterable ?? false, + sortOrder: attr.sortOrder ?? 0, + categoryId: mappedCatId, + }, + create: { + code: attr.code, + label: attr.label, + description: attr.description, + type: attr.type || "TEXT", + unit: attr.unit, + options: attr.options, + isRequired: attr.isRequired ?? false, + isFilterable: attr.isFilterable ?? false, + sortOrder: attr.sortOrder ?? 0, + categoryId: mappedCatId, + }, + }); + + if (Array.isArray(attr.translations)) { + for (const t of attr.translations) { + if (t.langCode && t.label) { + await prisma.attributeDefinitionTranslation.upsert({ + where: { + attributeDefinitionId_langCode: { + attributeDefinitionId: savedAttr.id, + langCode: t.langCode, + }, + }, + update: { + label: t.label, + description: t.description, + }, + create: { + attributeDefinitionId: savedAttr.id, + langCode: t.langCode, + label: t.label, + description: t.description, + }, + }); + } + } + } + summary.attributes++; + } + + // 4. Products + const products = Array.isArray(payload.products) + ? payload.products + : (Array.isArray(payload) && payload[0]?.variants ? payload : []); + + for (const prod of products) { + if (!prod.name) continue; + const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId; + const bId = prod.brandId ? (brandMap.get(prod.brandId) || prod.brandId) : defaultBrandId; + + let existing = prod.id ? await prisma.product.findUnique({ where: { id: prod.id } }) : null; + if (!existing && prod.name) { + existing = await prisma.product.findFirst({ where: { name: prod.name } }); + } + + let savedProduct; + if (existing) { + savedProduct = await prisma.product.update({ + where: { id: existing.id }, + data: { + name: prod.name, + description: prod.description, + details: prod.details, + categoryId: catId, + brandId: bId, + options: prod.options ?? [], + }, + }); + } else { + savedProduct = await prisma.product.create({ + data: { + name: prod.name, + description: prod.description, + details: prod.details, + categoryId: catId, + brandId: bId, + options: prod.options ?? [], + }, + }); + } + + // Variants + if (Array.isArray(prod.variants)) { + for (const v of prod.variants) { + const opt1 = v.option1 ?? ""; + const opt2 = v.option2 ?? ""; + const opt3 = v.option3 ?? ""; + + await prisma.productVariant.upsert({ + where: { + productId_option1_option2_option3: { + productId: savedProduct.id, + option1: opt1, + option2: opt2, + option3: opt3, + }, + }, + update: { + sku: v.sku || undefined, + barcode: v.barcode, + price: Number(v.price) || 0, + stock: Number(v.stock) || 0, + }, + create: { + productId: savedProduct.id, + option1: opt1, + option2: opt2, + option3: opt3, + sku: v.sku || undefined, + barcode: v.barcode, + price: Number(v.price) || 0, + stock: Number(v.stock) || 0, + }, + }); + summary.variants++; + } + } + + // Images + if (Array.isArray(prod.images) && prod.images.length > 0) { + await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } }); + for (let i = 0; i < prod.images.length; i++) { + const img = prod.images[i]; + const url = typeof img === "string" ? img : img.url; + if (url) { + await prisma.productImage.create({ + data: { + productId: savedProduct.id, + url, + order: typeof img === "object" && img.order !== undefined ? img.order : i, + }, + }); + } + } + } + + // Translations + if (Array.isArray(prod.translations)) { + for (const t of prod.translations) { + if (t.langCode && t.name) { + await prisma.productTranslation.upsert({ + where: { + productId_langCode: { + productId: savedProduct.id, + langCode: t.langCode, + }, + }, + update: { + name: t.name, + description: t.description, + details: t.details, + }, + create: { + productId: savedProduct.id, + langCode: t.langCode, + name: t.name, + description: t.description, + details: t.details, + }, + }); + } + } + } + + summary.products++; + } + + return { + success: true, + message: "Catalog restored successfully", + summary, + }; + } catch (err: any) { + console.error("[Backup] Catalog restore error:", err); + set.status = 500; + return { error: "Catalog restore failed", detail: err?.message }; + } + }) + + // ── 9. Restore Media Files (.zip) ───────────────────────────────────────── + /** + * POST /api/backup/restore/media + * Accepts a multipart ZIP file, extracts images to /uploads, and registers Media DB records. + */ + .post("/restore/media", async ({ body, set }: { body: any; set: any }) => { + try { + const { file } = body as { file?: File }; + if (!file) { + set.status = 400; + return { error: "No zip file uploaded" }; + } + + const uploadsDir = path.resolve("public/uploads"); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + const tmpZip = path.join(os.tmpdir(), `homaroma_restore_${Date.now()}.zip`); + await Bun.write(tmpZip, await file.arrayBuffer()); + + // Extract ZIP safely + const isWindows = process.platform === "win32"; + if (isWindows) { + const proc = Bun.spawn([ + "powershell", + "-NoProfile", + "-Command", + `Expand-Archive -LiteralPath '${tmpZip}' -DestinationPath '${uploadsDir}' -Force`, + ]); + await proc.exited; + } else { + const proc = Bun.spawn(["unzip", "-o", tmpZip, "-d", uploadsDir]); + await proc.exited; + } + + // Cleanup tmp zip + try { fs.unlinkSync(tmpZip); } catch { /* ignore */ } + + // Scan extracted files and sync with Prisma Media records + const files = fs.readdirSync(uploadsDir); + let syncCount = 0; + + for (const filename of files) { + const filePath = path.join(uploadsDir, filename); + const stat = fs.statSync(filePath); + if (stat.isFile()) { + const relativePath = `/uploads/${filename}`; + const ext = path.extname(filename).toLowerCase().replace(".", ""); + const mimeType = + ext === "png" + ? "image/png" + : ext === "jpg" || ext === "jpeg" + ? "image/jpeg" + : ext === "webp" + ? "image/webp" + : ext === "mp4" + ? "video/mp4" + : "application/octet-stream"; + + const existingMedia = await prisma.media.findFirst({ where: { filename } }); + if (!existingMedia) { + await prisma.media.create({ + data: { + filename, + path: relativePath, + mimeType, + sizeBytes: stat.size, + }, + }); + syncCount++; + } + } + } + + return { + success: true, + message: `Media files restored successfully. Total ${files.length} files in /uploads (${syncCount} newly indexed).`, + totalFiles: files.length, + syncedRecords: syncCount, + }; + } catch (err: any) { + console.error("[Backup] Media restore error:", err); + set.status = 500; + return { error: "Media restore failed", detail: err?.message }; + } }); diff --git a/apps/website/src/lib/api.ts b/apps/website/src/lib/api.ts index 24c5563..eae994f 100644 --- a/apps/website/src/lib/api.ts +++ b/apps/website/src/lib/api.ts @@ -5,12 +5,12 @@ * 1. NEXT_PUBLIC_API_URL — ตั้งค่าใน .env / .env.production (Client + Server) * 2. INTERNAL_API_URL — ตั้งค่าใน .env สำหรับ Server-side เท่านั้น * ใช้เมื่อ Server fetch ภายใน network เดียวกัน - * 3. Fallback dev — http://localhost:3000/api + * 3. Fallback dev — http://localhost:3051/api * - * Dev: NEXT_PUBLIC_API_URL=http://localhost:3000/api + * Dev: NEXT_PUBLIC_API_URL=http://localhost:3051/api * Production: NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api */ export const API_BASE = process.env.NEXT_PUBLIC_API_URL || process.env.INTERNAL_API_URL || - 'http://localhost:3000/api' + 'http://localhost:3051/api'