x
This commit is contained in:
1 parent
5b81cc810a
commit
158fa1ac22
7 files changed
+1712
-319
No files matched your search
@@ -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' })
|
||||||
@@ -32,7 +70,7 @@ export default function BackupCatalogPage() {
|
|||||||
}
|
}
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const ext = format === 'json' ? 'json' : 'csv'
|
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 url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
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 (
|
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,121 +182,275 @@ 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>
|
||||||
|
|
||||||
<div className="admin-card p-6 space-y-6">
|
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
|
||||||
{/* Section Selector */}
|
{activeTab === 'export' && (
|
||||||
<div className="space-y-3">
|
<div className="admin-card p-6 space-y-6">
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
{/* Section Selector */}
|
||||||
เลือกข้อมูลที่ต้องการ Export
|
<div className="space-y-3">
|
||||||
</label>
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
<div className="space-y-2">
|
เลือกข้อมูลที่ต้องการ Export
|
||||||
{SECTIONS.map((s) => (
|
</label>
|
||||||
<button
|
<div className="space-y-2">
|
||||||
key={s.key}
|
{SECTIONS.map((s) => (
|
||||||
onClick={() => setSelectedSection(s.key)}
|
<button
|
||||||
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
key={s.key}
|
||||||
selectedSection === s.key
|
onClick={() => setSelectedSection(s.key)}
|
||||||
? 'border-cyan-500/40 bg-cyan-500/10'
|
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||||
: '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
|
selectedSection === s.key
|
||||||
? 'border-cyan-400 bg-cyan-400'
|
? 'border-cyan-500/40 bg-cyan-500/10'
|
||||||
: 'border-slate-600'
|
: 'border-white/5 hover:border-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedSection === s.key && (
|
<div
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
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'
|
||||||
</div>
|
}`}
|
||||||
<div>
|
>
|
||||||
<p className="text-sm font-medium text-white">{s.label}</p>
|
{selectedSection === s.key && (
|
||||||
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
|
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
||||||
</div>
|
)}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Format Selector */}
|
{/* Format Selector */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
Format
|
Format
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{(['json', 'csv'] as ExportFormat[]).map((f) => (
|
{(['json', 'csv'] as ExportFormat[]).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFormat(f)}
|
onClick={() => setFormat(f)}
|
||||||
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
|
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
|
||||||
format === f
|
format === f
|
||||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400'
|
? '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'
|
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
</button>
|
</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>
|
</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 */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={loading}
|
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"
|
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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
{loading ? (
|
||||||
{result.message}
|
<>
|
||||||
|
<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>
|
||||||
)}
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,115 +184,281 @@ 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>
|
||||||
|
|
||||||
<div className="admin-card p-6 space-y-6">
|
{/* ─── TAB: EXPORT ──────────────────────────────────────────────────────── */}
|
||||||
{/* What's included detail */}
|
{activeTab === 'export' && (
|
||||||
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3">
|
<div className="admin-card p-6 space-y-6">
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">ข้อมูลที่รวมอยู่ใน CMS Export</p>
|
{/* What's included detail */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<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">
|
||||||
'Home Page sections', 'Shop Page sections',
|
ข้อมูลที่รวมอยู่ใน CMS Export
|
||||||
'Product Detail sections', 'Category Page sections',
|
</p>
|
||||||
'All dynamic CMS pages', 'Main Navigation tree',
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
'Footer Explore menu', 'Footer Support menu',
|
{[
|
||||||
'Footer Brand info', 'Articles (Scent Guide)',
|
'Home Page sections',
|
||||||
'Store Settings', 'Theme tokens & colors',
|
'Shop Page sections',
|
||||||
'Newsletter settings', 'Footer copy & translations',
|
'Product Detail sections',
|
||||||
].map((item) => (
|
'Category Page sections',
|
||||||
<div key={item} className="flex items-center gap-2 text-xs text-slate-400">
|
'All dynamic CMS pages',
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
|
'Main Navigation tree',
|
||||||
{item}
|
'Footer Explore menu',
|
||||||
</div>
|
'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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Selector */}
|
{/* Section Selector */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
เลือกข้อมูลที่ต้องการ Export
|
เลือกข้อมูลที่ต้องการ Export
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s.key}
|
||||||
onClick={() => setSelectedSection(s.key)}
|
onClick={() => setSelectedSection(s.key)}
|
||||||
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||||
selectedSection === s.key
|
selectedSection === s.key
|
||||||
? 'border-amber-500/40 bg-amber-500/10'
|
? 'border-amber-500/40 bg-amber-500/10'
|
||||||
: 'border-white/5 hover:border-white/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'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedSection === s.key && (
|
<div
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
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'
|
||||||
</div>
|
}`}
|
||||||
<div>
|
>
|
||||||
<p className="text-sm font-medium text-white">{s.label}</p>
|
{selectedSection === s.key && (
|
||||||
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
|
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
||||||
</div>
|
)}
|
||||||
</button>
|
</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>
|
||||||
</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
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={loading}
|
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"
|
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 ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
กำลัง Export...
|
กำลัง Export...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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
|
||||||
|
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
|
<div
|
||||||
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
|
onClick={() => fileInputRef.current?.click()}
|
||||||
result.success
|
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"
|
||||||
? '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" />
|
<input
|
||||||
{result.message}
|
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>
|
||||||
)}
|
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,111 +133,223 @@ 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Manual SCP */}
|
||||||
<div className="admin-card p-6 space-y-5">
|
<div className="admin-card p-5 space-y-3">
|
||||||
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Manual Download (ผ่าน SSH/SCP)</p>
|
||||||
ไฟล์ที่จะถูก Backup
|
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
|
||||||
</h2>
|
<p className="text-slate-500"># rsync media folder จาก production</p>
|
||||||
<div className="space-y-3">
|
<p>{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}</p>
|
||||||
{[
|
|
||||||
{ 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>
|
|
||||||
</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>
|
||||||
)}
|
|
||||||
</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>
|
</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' })
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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'
|
||||||
Reference in new issue
Block a user