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

No files matched your search

+289 -17
View File
@@ -1,5 +1,17 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { Package, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react' import {
Package,
Download,
Upload,
CheckCircle2,
ChevronLeft,
Loader2,
AlertTriangle,
FileCode,
FolderTree,
Sliders,
Tag,
} from 'lucide-react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types' import { API_BASE } from '@homaroma/types'
@@ -15,16 +27,42 @@ const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
] ]
export default function BackupCatalogPage() { export default function BackupCatalogPage() {
const [activeTab, setActiveTab] = useState<'export' | 'restore'>('export')
// Export states
const [selectedSection, setSelectedSection] = useState<ExportSection>('all') const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
const [format, setFormat] = useState<ExportFormat>('json') const [format, setFormat] = useState<ExportFormat>('json')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) 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 () => { const handleExport = async () => {
setLoading(true) setLoading(true)
setResult(null) setResult(null)
try { 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' })) const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.error || 'Export failed' }) setResult({ success: false, message: err.error || 'Export failed' })
@@ -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 ( return (
<div className="max-w-3xl space-y-8 animate-fade-in"> <div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -64,23 +182,41 @@ export default function BackupCatalogPage() {
<Package className="w-7 h-7 text-cyan-400" /> <Package className="w-7 h-7 text-cyan-400" />
</div> </div>
<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"> <p className="text-slate-400 text-sm mt-0.5">
Export Products, Categories, Attributes และ Brands เป็น JSON หรือ CSV Export หรือ Import ข้อมูลสินค้า, หมวดหมู่, Attributes และ แบรนด์
</p> </p>
</div> </div>
</div> </div>
{/* Phase 2 notice */} {/* Tab Switcher */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3"> <div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" /> <button
<p className="text-xs text-slate-400"> onClick={() => setActiveTab('export')}
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/catalog</code>{' '} activeTab === 'export'
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
</p> : '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>
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
{activeTab === 'export' && (
<div className="admin-card p-6 space-y-6"> <div className="admin-card p-6 space-y-6">
{/* Section Selector */} {/* Section Selector */}
<div className="space-y-3"> <div className="space-y-3">
@@ -100,9 +236,7 @@ export default function BackupCatalogPage() {
> >
<div <div
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${ className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
selectedSection === s.key selectedSection === s.key ? 'border-cyan-400 bg-cyan-400' : 'border-slate-600'
? 'border-cyan-400 bg-cyan-400'
: 'border-slate-600'
}`} }`}
> >
{selectedSection === s.key && ( {selectedSection === s.key && (
@@ -160,7 +294,7 @@ export default function BackupCatalogPage() {
) : ( ) : (
<> <>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find(s => s.key === selectedSection)?.label} Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find((s) => s.key === selectedSection)?.label}
</> </>
)} )}
</button> </button>
@@ -179,6 +313,144 @@ export default function BackupCatalogPage() {
</div> </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 / Import Catalog 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>{' '}
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> </div>
) )
} }
+313 -24
View File
@@ -1,5 +1,18 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { FileJson, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-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 { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types' import { API_BASE } from '@homaroma/types'
@@ -14,15 +27,41 @@ const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
] ]
export default function BackupCmsPage() { export default function BackupCmsPage() {
const [activeTab, setActiveTab] = useState<'export' | 'restore'>('export')
// Export states
const [selectedSection, setSelectedSection] = useState<ExportSection>('all') const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) 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 () => { const handleExport = async () => {
setLoading(true) setLoading(true)
setResult(null) setResult(null)
try { 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' })) const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.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 ( return (
<div className="max-w-3xl space-y-8 animate-fade-in"> <div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -61,36 +184,63 @@ export default function BackupCmsPage() {
<FileJson className="w-7 h-7 text-amber-400" /> <FileJson className="w-7 h-7 text-amber-400" />
</div> </div>
<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"> <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> </p>
</div> </div>
</div> </div>
{/* Phase 2 notice */} {/* Tab Switcher */}
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3"> <div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" /> <button
<p className="text-xs text-slate-400"> onClick={() => setActiveTab('export')}
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '} className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/cms</code>{' '} activeTab === 'export'
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
</p> : '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>
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
{activeTab === 'export' && (
<div className="admin-card p-6 space-y-6"> <div className="admin-card p-6 space-y-6">
{/* What's included detail */} {/* What's included detail */}
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3"> <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> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[ {[
'Home Page sections', 'Shop Page sections', 'Home Page sections',
'Product Detail sections', 'Category Page sections', 'Shop Page sections',
'All dynamic CMS pages', 'Main Navigation tree', 'Product Detail sections',
'Footer Explore menu', 'Footer Support menu', 'Category Page sections',
'Footer Brand info', 'Articles (Scent Guide)', 'All dynamic CMS pages',
'Store Settings', 'Theme tokens & colors', 'Main Navigation tree',
'Newsletter settings', 'Footer copy & translations', 'Footer Explore menu',
'Footer Support menu',
'Footer Brand info',
'Articles (Scent Guide)',
'Store Settings',
'Theme tokens & colors',
'Newsletter settings',
'Footer copy & translations',
].map((item) => ( ].map((item) => (
<div key={item} className="flex items-center gap-2 text-xs text-slate-400"> <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" /> <div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
@@ -134,7 +284,7 @@ export default function BackupCmsPage() {
</div> </div>
</div> </div>
{/* Export Button — JSON only for CMS */} {/* Export Button */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p> <p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p>
<button <button
@@ -150,13 +300,13 @@ export default function BackupCmsPage() {
) : ( ) : (
<> <>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Export {SECTIONS.find(s => s.key === selectedSection)?.label} Export {SECTIONS.find((s) => s.key === selectedSection)?.label}
</> </>
)} )}
</button> </button>
</div> </div>
{/* Result */} {/* Result Banner */}
{result && ( {result && (
<div <div
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${ className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
@@ -170,6 +320,145 @@ export default function BackupCmsPage() {
</div> </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 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
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`
: 'รองรับไฟล์ CMS 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">
ข้อมูลที่พบในไฟล์ 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> </div>
) )
} }
@@ -27,24 +27,23 @@ interface BackupCard {
const BACKUP_CARDS: BackupCard[] = [ const BACKUP_CARDS: BackupCard[] = [
{ {
icon: DatabaseBackup, icon: DatabaseBackup,
title: 'Full Database Backup', title: 'Full Database Backup & Restore',
description: description:
'Export PostgreSQL dump ทั้งหมด (.sql) ครอบคลุมทุกตาราง — ใช้สำหรับ Disaster Recovery', 'ภาพรวมฐานข้อมูล Real-time + คู่มือคำสั่ง Full PostgreSQL Dump & Restore ผ่าน SSH',
to: '/backup/database', to: '/backup/database',
accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20', accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
badgeText: 'CRITICAL', badgeText: 'CRITICAL',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
note: 'ต้องการ backend endpoint (Phase 2)',
}, },
{ {
icon: Package, icon: Package,
title: 'Catalog Export', title: 'Catalog Export & Restore',
description: description:
'Products, Variants, Categories, Attributes, Brands และ Translations ทั้งหมด', 'Export และ Import ข้อมูลสินค้า, Variants, Categories, Attributes, Brands และ Translations',
to: '/backup/catalog', to: '/backup/catalog',
accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
badgeText: 'CRITICAL', badgeText: 'EXPORT + IMPORT',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', badgeColor: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30',
}, },
{ {
icon: ShoppingCart, icon: ShoppingCart,
@@ -53,29 +52,28 @@ const BACKUP_CARDS: BackupCard[] = [
'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter', 'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter',
to: '/backup/orders', to: '/backup/orders',
accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20', accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20',
badgeText: 'CRITICAL', badgeText: 'EXPORT',
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30', badgeColor: 'bg-violet-500/20 text-violet-300 border border-violet-500/30',
}, },
{ {
icon: FileJson, icon: FileJson,
title: 'CMS & Content Export', title: 'CMS & Content Export / Restore',
description: description:
'Pages/Sections, Menus (Main + Footer), Articles, Store Settings', 'Export และ Restore โครงสร้าง Pages/Sections, Menus, Articles, และ Store Settings ผ่านไฟล์ JSON',
to: '/backup/cms', to: '/backup/cms',
accentColor: 'text-amber-400 bg-amber-500/10 border-amber-500/20', 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', badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
}, },
{ {
icon: FolderArchive, icon: FolderArchive,
title: 'Media Files', title: 'Media Files Backup & Restore',
description: description:
'ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ — download เป็น .zip', 'Download และ Restore อัปโหลดไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ ด้วยไฟล์ .zip',
to: '/backup/media', to: '/backup/media',
accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
badgeText: 'HIGH', badgeText: 'ZIP EXPORT + RESTORE',
badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30', badgeColor: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30',
note: 'ต้องการ backend endpoint (Phase 2)',
}, },
] ]
@@ -151,7 +149,7 @@ export default function BackupDashboardPage() {
</div> </div>
<div> <div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Phase</p> <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> </div>
</div> </div>
@@ -197,7 +195,7 @@ export default function BackupDashboardPage() {
{/* Footer Note */} {/* Footer Note */}
<div className="text-xs text-slate-600 text-center pb-4"> <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>
</div> </div>
) )
+221 -26
View File
@@ -1,17 +1,49 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { FolderArchive, Download, ChevronLeft, Info, AlertTriangle, Loader2, CheckCircle2, Image, FileVideo, File } from 'lucide-react' import {
FolderArchive,
Download,
Upload,
ChevronLeft,
AlertTriangle,
Loader2,
CheckCircle2,
Image,
FileVideo,
File,
FileArchive,
} from 'lucide-react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { API_BASE } from '@homaroma/types' import { API_BASE } from '@homaroma/types'
export default function BackupMediaPage() { export default function BackupMediaPage() {
const [activeTab, setActiveTab] = useState<'download' | 'restore'>('download')
// Download states
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) 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 () => { const handleDownload = async () => {
setLoading(true) setLoading(true)
setResult(null) setResult(null)
try { 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' })) const err = await res.json().catch(() => ({ error: 'Download failed' }))
setResult({ success: false, message: err.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 ( return (
<div className="max-w-3xl space-y-8 animate-fade-in"> <div className="max-w-3xl space-y-8 animate-fade-in">
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -50,33 +133,42 @@ export default function BackupMediaPage() {
<FolderArchive className="w-7 h-7 text-emerald-400" /> <FolderArchive className="w-7 h-7 text-emerald-400" />
</div> </div>
<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"> <p className="text-slate-400 text-sm mt-0.5">
Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip ดาวน์โหลด หรือ อัปโหลดคืนค่าไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip
</p> </p>
</div> </div>
</div> </div>
{/* Phase 2 Notice */} {/* Tab Switcher */}
<div className="admin-card p-6 border border-blue-500/20 bg-blue-500/5 space-y-4"> <div className="flex items-center gap-2 p-1 bg-night-950 rounded-xl border border-white/5 w-fit">
<div className="flex items-center gap-3"> <button
<Info className="w-5 h-5 text-blue-400 shrink-0" /> onClick={() => setActiveTab('download')}
<div> className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
<p className="font-semibold text-blue-300 text-sm">Phase 2 Feature</p> activeTab === 'download'
<p className="text-xs text-slate-500 mt-0.5"> ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2 : 'text-slate-400 hover:text-white'
</p> }`}
</div> >
</div> <Download className="w-3.5 h-3.5" />
<p className="text-sm text-slate-400 leading-relaxed"> Download Media (.zip)
เมื่อ Backend endpoint{' '} </button>
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">GET /backup/media/zip</code>{' '} <button
พร้อม ระบบจะ zip โฟลเดอร์{' '} onClick={() => setActiveTab('restore')}
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">public/uploads/</code>{' '} className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
และ stream ไฟล์กลับมา download โดยตรง activeTab === 'restore'
</p> ? '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> </div>
{/* ─── TAB: DOWNLOAD ─────────────────────────────────────────────────────── */}
{activeTab === 'download' && (
<div className="space-y-6">
{/* What's included */} {/* What's included */}
<div className="admin-card p-6 space-y-5"> <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"> <h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
@@ -106,7 +198,7 @@ export default function BackupMediaPage() {
<div className="flex items-start gap-3 text-sm text-amber-400/80"> <div className="flex items-start gap-3 text-sm text-amber-400/80">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-xs"> <p className="text-xs">
ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า ไฟล์ Media อาจมีขนาดใหญ่ตามปริมาณรูปภาพ — ใช้ SSH/SCP สำหรับกรณี Backup ระดับหลายร้อย MB หรือ GB
</p> </p>
</div> </div>
@@ -122,7 +214,7 @@ export default function BackupMediaPage() {
{/* Download Button */} {/* Download Button */}
<div className="admin-card p-6 space-y-4"> <div className="admin-card p-6 space-y-4">
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด กดปุ่มด้านล่างเพื่อสั่งให้ Server บีบอัดโฟลเดอร์ /uploads/ และดาวน์โหลดไฟล์ .zip
</p> </p>
<button <button
onClick={handleDownload} onClick={handleDownload}
@@ -132,7 +224,7 @@ export default function BackupMediaPage() {
{loading ? ( {loading ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
กำลัง Download... กำลัง Download .zip...
</> </>
) : ( ) : (
<> <>
@@ -156,5 +248,108 @@ export default function BackupMediaPage() {
)} )}
</div> </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('from', dateFrom)
params.set('to', dateTo) 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) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Export failed' })) const err = await res.json().catch(() => ({ error: 'Export failed' }))
setResult({ success: false, message: err.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 { adminOnly } from "../middleware/auth";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import os from "os";
// ─── Helper: stream JSON as file download ──────────────────────────────────── // ─── Helper: stream JSON as file download ────────────────────────────────────
@@ -587,4 +588,637 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
set.status = 500; set.status = 500;
return { error: "Database info failed", detail: err?.message }; 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) * 1. NEXT_PUBLIC_API_URL — ตั้งค่าใน .env / .env.production (Client + Server)
* 2. INTERNAL_API_URL — ตั้งค่าใน .env สำหรับ Server-side เท่านั้น * 2. INTERNAL_API_URL — ตั้งค่าใน .env สำหรับ Server-side เท่านั้น
* ใช้เมื่อ Server fetch ภายใน network เดียวกัน * ใช้เมื่อ 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 * Production: NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
*/ */
export const API_BASE = export const API_BASE =
process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL ||
process.env.INTERNAL_API_URL || process.env.INTERNAL_API_URL ||
'http://localhost:3000/api' 'http://localhost:3051/api'