This commit is contained in:
Your Name committed 2026-08-26 06:38:36 +07:00
1 parent 5b81cc810a
commit 158fa1ac22
7 files changed
+1712 -319

No files matched your search

+375 -103
View File
@@ -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<ExportSection>('all')
const [format, setFormat] = useState<ExportFormat>('json')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
// Restore states
const [restoreFile, setRestoreFile] = useState<File | null>(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<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
@@ -64,121 +182,275 @@ export default function BackupCatalogPage() {
<Package className="w-7 h-7 text-cyan-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Catalog Export</h1>
<h1 className="text-3xl font-bold text-white">Catalog Backup & Restore</h1>
<p className="text-slate-400 text-sm mt-0.5">
Export Products, Categories, Attributes และ Brands เป็น JSON หรือ CSV
Export หรือ Import ข้อมูลสินค้า, หมวดหมู่, Attributes และ แบรนด์
</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/catalog</code>{' '}
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
</p>
{/* Tab Switcher */}
<div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<button
onClick={() => setActiveTab('export')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'export'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Download className="w-3.5 h-3.5" />
Export Catalog (ดาวน์โหลด)
</button>
<button
onClick={() => setActiveTab('restore')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'restore'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Restore / Import Catalog (JSON)
</button>
</div>
<div className="admin-card p-6 space-y-6">
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-cyan-500/40 bg-cyan-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
{activeTab === 'export' && (
<div className="admin-card p-6 space-y-6">
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-cyan-400 bg-cyan-400'
: 'border-slate-600'
? 'border-cyan-500/40 bg-cyan-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key ? 'border-cyan-400 bg-cyan-400' : 'border-slate-600'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
</div>
</div>
</div>
{/* Format Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Format
</label>
<div className="flex gap-3">
{(['json', 'csv'] as ExportFormat[]).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
format === f
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400'
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
}`}
>
{f}
</button>
))}
{/* Format Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Format
</label>
<div className="flex gap-3">
{(['json', 'csv'] as ExportFormat[]).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
format === f
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400'
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
}`}
>
{f}
</button>
))}
</div>
{format === 'json' && (
<p className="text-xs text-slate-500">JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ</p>
)}
{format === 'csv' && (
<p className="text-xs text-slate-500">CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets</p>
)}
</div>
{format === 'json' && (
<p className="text-xs text-slate-500">JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ</p>
)}
{format === 'csv' && (
<p className="text-xs text-slate-500">CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets</p>
)}
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-sm font-bold uppercase tracking-widest hover:bg-cyan-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find(s => s.key === selectedSection)?.label}
</>
)}
</button>
{/* Result */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
{/* Export Button */}
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-sm font-bold uppercase tracking-widest hover:bg-cyan-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find((s) => s.key === selectedSection)?.label}
</>
)}
</button>
{/* Result */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
)}
{/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */}
{activeTab === 'restore' && (
<div className="admin-card p-6 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<Upload className="w-5 h-5 text-emerald-400" />
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
Restore / Import Catalog from JSON
</h2>
</div>
)}
</div>
<p className="text-xs text-slate-400">
อัปโหลดไฟล์ <code className="text-emerald-400 bg-white/5 px-1 py-0.5 rounded">.json</code>{' '}
Catalog เพื่อ Upsert สินค้า, หมวดหมู่, Attributes และแบรนด์กลับเข้าระบบ
</p>
{/* File Picker Area */}
<div
onClick={() => 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"
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".json"
className="hidden"
/>
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<FileCode className="w-6 h-6 text-emerald-400" />
</div>
<p className="text-sm font-semibold text-white">
{restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ JSON หรือลากไฟล์มาวางที่นี่'}
</p>
<p className="text-xs text-slate-500 mt-1">
{restoreFile
? `${(restoreFile.size / 1024).toFixed(1)} KB`
: 'รองรับไฟล์ Catalog Backup (.json)'}
</p>
</div>
{/* Preview Card */}
{restorePreview && (
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">
ข้อมูลที่พบในไฟล์ Catalog Backup
</span>
<span className="text-xs text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Valid JSON
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<Package className="w-4 h-4 text-cyan-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.productsCount}</p>
<p className="text-[11px] text-slate-500">Products</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<FolderTree className="w-4 h-4 text-amber-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.categoriesCount}</p>
<p className="text-[11px] text-slate-500">Categories</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<Sliders className="w-4 h-4 text-violet-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.attributesCount}</p>
<p className="text-[11px] text-slate-500">Attributes</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<Tag className="w-4 h-4 text-rose-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.brandsCount}</p>
<p className="text-[11px] text-slate-500">Brands</p>
</div>
</div>
{/* Confirmation Checkbox */}
<div className="p-3 bg-amber-500/10 rounded-lg border border-amber-500/20 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<label className="text-xs text-slate-300 cursor-pointer select-none">
<input
type="checkbox"
checked={confirmRestore}
onChange={(e) => setConfirmRestore(e.target.checked)}
className="mr-2 accent-amber-500 rounded"
/>
ฉันเข้าใจว่าการ Import/Restore จะทำการ Upsert (อัปเดตข้อมูลสินค้า/หมวดหมู่เดิม หรือสร้างใหม่หากยังไม่มี)
</label>
</div>
{/* Restore Button */}
<button
onClick={handleRestoreSubmit}
disabled={!confirmRestore || restoreLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-300 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{restoreLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Import Catalog...
</>
) : (
<>
<Upload className="w-4 h-4" />
ยืนยันการ Import Catalog ตอนนี้
</>
)}
</button>
</div>
)}
{/* Restore Result Banner */}
{restoreResult && (
<div
className={`rounded-xl p-4 space-y-2 text-sm ${
restoreResult.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 shrink-0" />
{restoreResult.message}
</div>
{restoreResult.summary && (
<div className="text-xs text-slate-400 pl-6 space-y-0.5">
<p>• Products: {restoreResult.summary.products} (Variants: {restoreResult.summary.variants})</p>
<p>• Categories: {restoreResult.summary.categories}</p>
<p>• Attributes: {restoreResult.summary.attributes}</p>
<p>• Brands: {restoreResult.summary.brands}</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
+384 -95
View File
@@ -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<ExportSection>('all')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
// Restore states
const [restoreFile, setRestoreFile] = useState<File | null>(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<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
@@ -61,115 +184,281 @@ export default function BackupCmsPage() {
<FileJson className="w-7 h-7 text-amber-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">CMS & Content Export</h1>
<h1 className="text-3xl font-bold text-white">CMS & Content Backup / Restore</h1>
<p className="text-slate-400 text-sm mt-0.5">
Export Pages/Sections, Menus, Articles และ Store Settings เป็น JSON
Export หรือ Restore Pages/Sections, Menus, Articles และ Store Settings ด้วยไฟล์ JSON
</p>
</div>
</div>
{/* Phase 2 notice */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-slate-400">
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/cms</code>{' '}
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
</p>
{/* Tab Switcher */}
<div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<button
onClick={() => setActiveTab('export')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'export'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Download className="w-3.5 h-3.5" />
Export CMS (ดาวน์โหลด)
</button>
<button
onClick={() => setActiveTab('restore')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'restore'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Restore CMS (นำเข้า JSON)
</button>
</div>
<div className="admin-card p-6 space-y-6">
{/* What's included detail */}
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">ข้อมูลที่รวมอยู่ใน CMS Export</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
'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) => (
<div key={item} className="flex items-center gap-2 text-xs text-slate-400">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
{item}
</div>
))}
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
{activeTab === 'export' && (
<div className="admin-card p-6 space-y-6">
{/* What's included detail */}
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
ข้อมูลที่รวมอยู่ใน CMS Export
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
'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) => (
<div key={item} className="flex items-center gap-2 text-xs text-slate-400">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
{item}
</div>
))}
</div>
</div>
</div>
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-amber-500/40 bg-amber-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key ? 'border-amber-400 bg-amber-400' : 'border-slate-600'
{/* Section Selector */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
เลือกข้อมูลที่ต้องการ Export
</label>
<div className="space-y-2">
{SECTIONS.map((s) => (
<button
key={s.key}
onClick={() => setSelectedSection(s.key)}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
selectedSection === s.key
? 'border-amber-500/40 bg-amber-500/10'
: 'border-white/5 hover:border-white/10'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
<div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key ? 'border-amber-400 bg-amber-400' : 'border-slate-600'
}`}
>
{selectedSection === s.key && (
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
)}
</div>
<div>
<p className="text-sm font-medium text-white">{s.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
</div>
</button>
))}
</div>
</div>
</div>
{/* Export Button — JSON only for CMS */}
<div className="space-y-2">
<p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p>
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm font-bold uppercase tracking-widest hover:bg-amber-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {SECTIONS.find(s => s.key === selectedSection)?.label}
</>
)}
</button>
</div>
{/* Export Button */}
<div className="space-y-2">
<p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p>
<button
onClick={handleExport}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm font-bold uppercase tracking-widest hover:bg-amber-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Export...
</>
) : (
<>
<Download className="w-4 h-4" />
Export {SECTIONS.find((s) => s.key === selectedSection)?.label}
</>
)}
</button>
</div>
{/* Result */}
{result && (
{/* Result Banner */}
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
)}
{/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */}
{activeTab === 'restore' && (
<div className="admin-card p-6 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<Upload className="w-5 h-5 text-emerald-400" />
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
Restore CMS from JSON
</h2>
</div>
<p className="text-xs text-slate-400">
อัปโหลดไฟล์ <code className="text-emerald-400 bg-white/5 px-1 py-0.5 rounded">.json</code>{' '}
ที่เคย Export ออกจากระบบ เพื่อ Restore หน้าเว็บ, บล็อกส่วนต่างๆ, เมนู, บทความ หรือ Store Settings
</p>
{/* File Picker Area */}
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
onClick={() => 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"
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".json"
className="hidden"
/>
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<FileCode className="w-6 h-6 text-emerald-400" />
</div>
<p className="text-sm font-semibold text-white">
{restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ JSON หรือลากไฟล์มาวางที่นี่'}
</p>
<p className="text-xs text-slate-500 mt-1">
{restoreFile
? `${(restoreFile.size / 1024).toFixed(1)} KB`
: 'รองรับไฟล์ CMS Backup (.json)'}
</p>
</div>
)}
</div>
{/* Preview Card */}
{restorePreview && (
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">
ข้อมูลที่พบในไฟล์ Backup
</span>
<span className="text-xs text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Valid JSON
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<Layers className="w-4 h-4 text-cyan-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.pagesCount}</p>
<p className="text-[11px] text-slate-500">Pages</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<MenuIcon className="w-4 h-4 text-amber-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.menusCount}</p>
<p className="text-[11px] text-slate-500">Menus</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<BookOpen className="w-4 h-4 text-violet-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">{restorePreview.articlesCount}</p>
<p className="text-[11px] text-slate-500">Articles</p>
</div>
<div className="bg-night-950 p-3 rounded-lg border border-white/5 text-center">
<SettingsIcon className="w-4 h-4 text-rose-400 mx-auto mb-1" />
<p className="text-lg font-bold text-white">
{restorePreview.hasSettings ? 'YES' : 'NO'}
</p>
<p className="text-[11px] text-slate-500">Store Settings</p>
</div>
</div>
{/* Confirmation Checkbox */}
<div className="p-3 bg-amber-500/10 rounded-lg border border-amber-500/20 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<label className="text-xs text-slate-300 cursor-pointer select-none">
<input
type="checkbox"
checked={confirmRestore}
onChange={(e) => setConfirmRestore(e.target.checked)}
className="mr-2 accent-amber-500 rounded"
/>
ฉันเข้าใจว่าการ Restore จะทำการเขียนทับ/อัปเดตข้อมูล Pages, Menus, หรือ Settings ที่มีชื่อ/Slug ซ้ำกับไฟล์นี้
</label>
</div>
{/* Restore Button */}
<button
onClick={handleRestoreSubmit}
disabled={!confirmRestore || restoreLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-300 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{restoreLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Restore ข้อมูล CMS...
</>
) : (
<>
<Upload className="w-4 h-4" />
ยืนยันการ Restore CMS ตอนนี้
</>
)}
</button>
</div>
)}
{/* Restore Result Banner */}
{restoreResult && (
<div
className={`rounded-xl p-4 space-y-2 text-sm ${
restoreResult.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 shrink-0" />
{restoreResult.message}
</div>
{restoreResult.summary && (
<div className="text-xs text-slate-400 pl-6 space-y-0.5">
<p>• Pages Restored: {restoreResult.summary.pages} (Sections: {restoreResult.summary.sections})</p>
<p>• Menus Restored: {restoreResult.summary.menus} (Items: {restoreResult.summary.menuItems})</p>
<p>• Articles Restored: {restoreResult.summary.articles}</p>
<p>• Store Settings: {restoreResult.summary.settings ? 'Updated' : 'Unchanged'}</p>
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
@@ -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() {
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Phase</p>
<p className="text-sm font-semibold text-blue-400">1 — Manual Export</p>
<p className="text-sm font-semibold text-emerald-400">2 — Export & Restore Active</p>
</div>
</div>
</div>
@@ -197,7 +195,7 @@ export default function BackupDashboardPage() {
{/* Footer Note */}
<div className="text-xs text-slate-600 text-center pb-4">
Backup & Restore — Phase 1 (Manual Export) · Phase 2 จะเพิ่ม Scheduled Backup และ Server-side Storage
Backup & Restore — Phase 2 (Full Export & Selective Restore) พร้อมใช้งาน
</div>
</div>
)
+293 -98
View File
@@ -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<File | null>(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<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */}
@@ -50,111 +133,223 @@ export default function BackupMediaPage() {
<FolderArchive className="w-7 h-7 text-emerald-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Media Files Backup</h1>
<h1 className="text-3xl font-bold text-white">Media Files Backup & Restore</h1>
<p className="text-slate-400 text-sm mt-0.5">
Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip
ดาวน์โหลด หรือ อัปโหลดคืนค่าไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip
</p>
</div>
</div>
{/* Phase 2 Notice */}
<div className="admin-card p-6 border border-blue-500/20 bg-blue-500/5 space-y-4">
<div className="flex items-center gap-3">
<Info className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<p className="font-semibold text-blue-300 text-sm">Phase 2 Feature</p>
<p className="text-xs text-slate-500 mt-0.5">
Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2
{/* Tab Switcher */}
<div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<button
onClick={() => setActiveTab('download')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'download'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Download className="w-3.5 h-3.5" />
Download Media (.zip)
</button>
<button
onClick={() => setActiveTab('restore')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
activeTab === 'restore'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Upload className="w-3.5 h-3.5" />
Restore Media (.zip Upload)
</button>
</div>
{/* ─── TAB: DOWNLOAD ─────────────────────────────────────────────────────── */}
{activeTab === 'download' && (
<div className="space-y-6">
{/* What's included */}
<div className="admin-card p-6 space-y-5">
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
ไฟล์ที่จะถูก Backup
</h2>
<div className="space-y-3">
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-3 py-2 border-b border-white/5 last:border-0">
<Icon className={`w-4 h-4 shrink-0 ${color}`} />
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-slate-500">{desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Size Warning */}
<div className="flex items-start gap-3 text-sm text-amber-400/80">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-xs">
ไฟล์ Media อาจมีขนาดใหญ่ตามปริมาณรูปภาพ — ใช้ SSH/SCP สำหรับกรณี Backup ระดับหลายร้อย MB หรือ GB
</p>
</div>
</div>
<p className="text-sm text-slate-400 leading-relaxed">
เมื่อ Backend endpoint{' '}
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">GET /backup/media/zip</code>{' '}
พร้อม ระบบจะ zip โฟลเดอร์{' '}
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">public/uploads/</code>{' '}
และ stream ไฟล์กลับมา download โดยตรง
</p>
</div>
{/* What's included */}
<div className="admin-card p-6 space-y-5">
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
ไฟล์ที่จะถูก Backup
</h2>
<div className="space-y-3">
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-3 py-2 border-b border-white/5 last:border-0">
<Icon className={`w-4 h-4 shrink-0 ${color}`} />
<div>
<p className="text-sm font-medium text-white">{label}</p>
<p className="text-xs text-slate-500">{desc}</p>
</div>
{/* Manual SCP */}
<div className="admin-card p-5 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Manual Download (ผ่าน SSH/SCP)</p>
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
<p className="text-slate-500"># rsync media folder จาก production</p>
<p>{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}</p>
</div>
))}
</div>
</div>
{/* Size Warning */}
<div className="flex items-start gap-3 text-sm text-amber-400/80">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-xs">
ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า
</p>
</div>
{/* Manual SCP */}
<div className="admin-card p-5 space-y-3">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Manual Download (ผ่าน SSH/SCP)</p>
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
<p className="text-slate-500"># rsync media folder จาก production</p>
<p>{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}</p>
</div>
</div>
{/* Download Button */}
<div className="admin-card p-6 space-y-4">
<p className="text-sm text-slate-400">
เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด
</p>
<button
onClick={handleDownload}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Download...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Media Files (.zip)
</>
)}
</button>
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
{/* Download Button */}
<div className="admin-card p-6 space-y-4">
<p className="text-sm text-slate-400">
กดปุ่มด้านล่างเพื่อสั่งให้ Server บีบอัดโฟลเดอร์ /uploads/ และดาวน์โหลดไฟล์ .zip
</p>
<button
onClick={handleDownload}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลัง Download .zip...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Media Files (.zip)
</>
)}
</button>
{result && (
<div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
result.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<CheckCircle2 className="w-4 h-4 shrink-0" />
{result.message}
</div>
)}
</div>
</div>
)}
{/* ─── TAB: RESTORE ─────────────────────────────────────────────────────── */}
{activeTab === 'restore' && (
<div className="admin-card p-6 space-y-6">
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
<Upload className="w-5 h-5 text-emerald-400" />
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
Restore Media from .zip
</h2>
</div>
<p className="text-xs text-slate-400">
อัปโหลดไฟล์ <code className="text-emerald-400 bg-white/5 px-1 py-0.5 rounded">.zip</code>{' '}
ที่มีรูปภาพเพื่อแตกไฟล์คืนเข้าโฟลเดอร์ <code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded">public/uploads/</code> และลงทะเบียนเข้าฐานข้อมูล Media
</p>
{/* File Picker Area */}
<div
onClick={() => 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"
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".zip"
className="hidden"
/>
<div className="w-12 h-12 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<FileArchive className="w-6 h-6 text-emerald-400" />
</div>
<p className="text-sm font-semibold text-white">
{restoreFile ? restoreFile.name : 'คลิกเพื่อเลือกไฟล์ .zip หรือลากไฟล์มาวางที่นี่'}
</p>
<p className="text-xs text-slate-500 mt-1">
{restoreFile
? `${(restoreFile.size / (1024 * 1024)).toFixed(2)} MB`
: 'รองรับไฟล์บีบอัด .zip'}
</p>
</div>
{restoreFile && (
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-4 animate-fade-in">
{/* Confirmation Checkbox */}
<div className="p-3 bg-amber-500/10 rounded-lg border border-amber-500/20 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<label className="text-xs text-slate-300 cursor-pointer select-none">
<input
type="checkbox"
checked={confirmRestore}
onChange={(e) => setConfirmRestore(e.target.checked)}
className="mr-2 accent-amber-500 rounded"
/>
ฉันเข้าใจว่าไฟล์ภาพใน .zip จะถูกแตกและเขียนทับไฟล์ชื่อเดียวกันในโฟลเดอร์ /uploads
</label>
</div>
{/* Restore Button */}
<button
onClick={handleRestoreSubmit}
disabled={!confirmRestore || restoreLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-300 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{restoreLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
กำลังอัปโหลดและแตกไฟล์ .zip...
</>
) : (
<>
<Upload className="w-4 h-4" />
ยืนยันการ Restore Media .zip
</>
)}
</button>
</div>
)}
{/* Restore Result Banner */}
{restoreResult && (
<div
className={`rounded-xl p-4 space-y-1 text-sm ${
restoreResult.success
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
: 'bg-red-500/10 border border-red-500/20 text-red-300'
}`}
>
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 shrink-0" />
{restoreResult.message}
</div>
{restoreResult.totalFiles !== undefined && (
<p className="text-xs text-slate-400 pl-6">
• จำนวนไฟล์ปัจจุบันใน /uploads: {restoreResult.totalFiles} ไฟล์ ({restoreResult.syncedRecords} ไฟล์ใหม่ที่ index เข้าฐานข้อมูล)
</p>
)}
</div>
)}
</div>
)}
</div>
)
}
@@ -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' })
+634
View File
@@ -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<number, number>();
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<number, number>();
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 };
}
});
+3 -3
View File
@@ -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'