x
This commit is contained in:
1 parent
7cd288e67e
commit
ed69a4b840
6 files changed
+3
-626
No files matched your search
@@ -5,7 +5,6 @@ import ProductsPage from './pages/ProductsPage'
|
||||
import ProductEditPage from './pages/ProductEditPage'
|
||||
import CategoriesPage from './pages/CategoriesPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import HeroSettingsPage from './pages/HeroSettingsPage'
|
||||
import PageBuilderPage from './pages/PageBuilderPage'
|
||||
import RawMaterialsPage from './pages/RawMaterialsPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
@@ -50,7 +49,6 @@ function App() {
|
||||
<Route path="/products/:id/edit" element={<ProductEditPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<Route path="/hero" element={<HeroSettingsPage />} />
|
||||
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Image as ImageIcon,
|
||||
UploadCloud,
|
||||
Loader2,
|
||||
X,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Info,
|
||||
ClipboardPaste
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type Hero } from '@homaroma/types'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
|
||||
export default function HeroSettingsPage() {
|
||||
const [heroes, setHeroes] = useState<Hero[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [editingHero, setEditingHero] = useState<Partial<Hero> | null>(null)
|
||||
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero/all`)
|
||||
const data = await res.json()
|
||||
setHeroes(data)
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingHero) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingHero)
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false)
|
||||
fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (id: number) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero/${id}/activate`, {
|
||||
method: 'PATCH'
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Activate error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this Hero entry?')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setIsUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setEditingHero(prev => ({ ...prev, imageUrl: data.url }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
for (const item of items) {
|
||||
const imageTypes = item.types.filter(t => t.startsWith('image/'))
|
||||
for (const type of imageTypes) {
|
||||
const blob = await item.getType(type)
|
||||
const file = new File([blob], 'pasted-image.png', { type })
|
||||
handleUpload(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
alert('No image found in clipboard')
|
||||
} catch (err) {
|
||||
console.error('Clipboard error:', err)
|
||||
alert('Please allow clipboard access or copy an image first.')
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingHero({
|
||||
tagline: 'A New Paradigm of Calm',
|
||||
title: 'Inhale [Serenity]',
|
||||
description: 'Crafting premium sensory experiences that transform your space into a sanctuary of peace.',
|
||||
imageUrl: '/images/hero.png',
|
||||
buttonText: 'Explore Collection',
|
||||
buttonLink: '/shop',
|
||||
isActive: false
|
||||
})
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (hero: Hero) => {
|
||||
setEditingHero(hero)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
{!isModalOpen ? (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Hero Management</h1>
|
||||
<p className="text-slate-500 text-sm">Control the first impression of your sanctuary.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New Hero
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-48 admin-card bg-white/5 animate-pulse" />
|
||||
))
|
||||
) : heroes.length === 0 ? (
|
||||
<div className="admin-card py-20 text-center space-y-4">
|
||||
<LayoutDashboard className="w-12 h-12 text-slate-700 mx-auto" />
|
||||
<p className="text-slate-500 italic">No hero entries found. Create your first one!</p>
|
||||
</div>
|
||||
) : (
|
||||
heroes.map((hero) => (
|
||||
<div
|
||||
key={hero.id}
|
||||
className={`admin-card overflow-hidden group border-l-4 transition-all duration-500 ${
|
||||
hero.isActive ? 'border-l-cyan-glow' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Preview Image */}
|
||||
<div className="lg:w-96 aspect-video rounded-2xl overflow-hidden border border-white/5 bg-night-950/50 flex-shrink-0 relative">
|
||||
<img
|
||||
src={hero.imageUrl}
|
||||
className={`w-full h-full object-cover transition-all duration-700 ${hero.isActive ? 'grayscale-0' : 'grayscale opacity-40'}`}
|
||||
alt="Hero Preview"
|
||||
/>
|
||||
{!hero.isActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 backdrop-blur-[2px]">
|
||||
<span className="px-3 py-1 bg-night-950 border border-white/10 rounded-full text-[10px] font-bold uppercase tracking-widest text-slate-500">Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col justify-between py-2">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-2 py-1 bg-cyan-glow/10 text-cyan-glow text-[10px] font-bold uppercase tracking-widest border border-cyan-glow/20 rounded-md">
|
||||
{hero.tagline}
|
||||
</span>
|
||||
{hero.isActive && (
|
||||
<span className="flex items-center gap-1.5 text-cyan-glow text-[10px] font-bold uppercase tracking-widest animate-pulse">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Live on Site
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 font-mono">ID: HERO-{hero.id.toString().padStart(3, '0')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold flex items-baseline gap-2">
|
||||
{hero.title.split('[').map((part, i) => {
|
||||
if (part.includes(']')) {
|
||||
const [accent, rest] = part.split(']')
|
||||
return (
|
||||
<span key={i}>
|
||||
<span className="text-cyan-glow italic font-serif">{accent}</span>
|
||||
{rest}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{part}</span>
|
||||
})}
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl line-clamp-2">
|
||||
{hero.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 pt-2">
|
||||
<div className="flex items-center gap-2 group/btn">
|
||||
<span className="w-8 h-px bg-white/10 group-hover/btn:bg-cyan-glow/30 transition-colors" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-cyan-glow">{hero.buttonText}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-600 text-[10px] font-bold">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{hero.buttonLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-6 lg:pt-0">
|
||||
{!hero.isActive && (
|
||||
<button
|
||||
onClick={() => handleActivate(hero.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-white text-xs font-bold transition-all border border-white/5"
|
||||
>
|
||||
<Circle className="w-4 h-4" />
|
||||
Set Active
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(hero)}
|
||||
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
||||
title="Edit Hero"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(hero.id)}
|
||||
className="p-2.5 rounded-xl bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white transition-all border border-rose-500/10"
|
||||
title="Delete Entry"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
editingHero && (
|
||||
<div className="w-full bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-fade-in">
|
||||
<div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<LayoutDashboard className="w-5 h-5 text-cyan-glow" />
|
||||
{editingHero.id ? 'Edit Hero Section' : 'Create New Hero'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="p-2 rounded-xl hover:bg-white/5 text-slate-500 hover:text-white transition-all"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Image Upload Area */}
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Background Image</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasteFromClipboard}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20"
|
||||
>
|
||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||
Paste Image
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/5 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||
onClick={() => {
|
||||
setOnSelectMedia(() => (url: string) => setEditingHero(prev => ({ ...prev, imageUrl: url })))
|
||||
setIsMediaModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{editingHero.imageUrl ? (
|
||||
<>
|
||||
<img src={editingHero.imageUrl} className="w-full h-full object-cover" alt="Hero Background" />
|
||||
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||
<UploadCloud className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-night-950/60 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-[10px] text-slate-500">
|
||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
<p>Recommended size: 1920x1080px. High quality but optimized for web.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Fields */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Tagline</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingHero.tagline || ''}
|
||||
onChange={(e) => setEditingHero({ ...editingHero, tagline: e.target.value })}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. A New Paradigm of Calm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingHero.title || ''}
|
||||
onChange={(e) => setEditingHero({ ...editingHero, title: e.target.value })}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Inhale [Serenity]"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-600 font-medium tracking-wide">Use [brackets] for the accent serif font.</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Description</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
required
|
||||
value={editingHero.description || ''}
|
||||
onChange={(e) => setEditingHero({ ...editingHero, description: e.target.value })}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="Tell the story of your sanctuary..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingHero.buttonText || ''}
|
||||
onChange={(e) => setEditingHero({ ...editingHero, buttonText: e.target.value })}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Explore Collection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Link</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingHero.buttonLink || ''}
|
||||
onChange={(e) => setEditingHero({ ...editingHero, buttonLink: e.target.value })}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. /shop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-white/5 flex flex-col md:flex-row gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUploading}
|
||||
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
{editingHero.id ? 'Update Hero Settings' : 'Create Hero Entry'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-8 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<MediaLibraryModal
|
||||
isOpen={isMediaModalOpen}
|
||||
onClose={() => setIsMediaModalOpen(false)}
|
||||
onSelect={(url) => {
|
||||
if (onSelectMedia) onSelectMedia(url)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -147,34 +147,6 @@ model CustomerAddress {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Hero {
|
||||
id Int @id @default(autoincrement())
|
||||
tagline String
|
||||
title String
|
||||
description String
|
||||
imageUrl String
|
||||
buttonText String @default("Explore Collection")
|
||||
buttonLink String @default("/shop")
|
||||
isActive Boolean @default(true)
|
||||
translations HeroTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model HeroTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
heroId Int
|
||||
hero Hero @relation(fields: [heroId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
tagline String?
|
||||
title String?
|
||||
description String?
|
||||
buttonText String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([heroId, langCode])
|
||||
}
|
||||
|
||||
model Coupon {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
@@ -7,7 +7,6 @@ import { categoriesRoute } from "./routes/categories";
|
||||
import { productsRoute } from "./routes/products";
|
||||
import { authRoute } from "./routes/auth";
|
||||
import { uploadRoute } from "./routes/upload";
|
||||
import { heroRoute } from "./routes/hero";
|
||||
import { ordersRoute } from "./routes/orders";
|
||||
import { settingsRoute } from "./routes/settings";
|
||||
import { articlesRoute } from "./routes/articles";
|
||||
@@ -126,7 +125,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const heroRoute = new Elysia({ prefix: "/hero" })
|
||||
.get("/", async () => {
|
||||
return await prisma.hero.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
}) || await prisma.hero.findFirst({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.get("/all", async () => {
|
||||
return await prisma.hero.findMany({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
const { id, ...heroData } = data;
|
||||
|
||||
if (id) {
|
||||
return await prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: heroData
|
||||
});
|
||||
} else {
|
||||
return await prisma.hero.create({
|
||||
data: heroData
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Hero] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save hero data" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
tagline: t.String(),
|
||||
title: t.String(),
|
||||
description: t.String(),
|
||||
imageUrl: t.String(),
|
||||
buttonText: t.String(),
|
||||
buttonLink: t.String(),
|
||||
isActive: t.Boolean()
|
||||
})
|
||||
})
|
||||
.patch("/:id/activate", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.hero.updateMany({
|
||||
data: { isActive: false }
|
||||
}),
|
||||
prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: { isActive: true }
|
||||
})
|
||||
]);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to activate hero" };
|
||||
}
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.hero.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete hero" };
|
||||
}
|
||||
})
|
||||
.patch("/:id/translations", async ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid hero ID" };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const tagline = typeof payload.tagline === 'string' ? (lang === 'th' ? payload.tagline : null) : payload.tagline?.[lang];
|
||||
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
|
||||
const description = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description?.[lang];
|
||||
const buttonText = typeof payload.buttonText === 'string' ? (lang === 'th' ? payload.buttonText : null) : payload.buttonText?.[lang];
|
||||
|
||||
if (!tagline && !title && !description && !buttonText) continue;
|
||||
|
||||
const existing = await prisma.heroTranslation.findUnique({
|
||||
where: { heroId_langCode: { heroId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
const updateData: any = {};
|
||||
if (tagline) updateData.tagline = tagline;
|
||||
if (title) updateData.title = title;
|
||||
if (description) updateData.description = description;
|
||||
if (buttonText) updateData.buttonText = buttonText;
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.heroTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.heroTranslation.create({
|
||||
data: { heroId: id, langCode: lang, ...updateData }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.hero.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, hero: updated };
|
||||
});
|
||||
@@ -158,22 +158,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["id", "translations"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_hero_translation",
|
||||
description: "Add or update translations for a specific hero.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Hero ID" },
|
||||
lang: { type: "string", description: "Language code" },
|
||||
tagline: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
buttonText: { type: "string" }
|
||||
},
|
||||
required: ["id", "lang"]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
name: "update_menu_item_translation",
|
||||
description: "Add or update translations for a specific menu item.",
|
||||
@@ -522,16 +507,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, pageId: id, translations: res.data?.translations ?? res.data, page: res.data?.page ?? res.data }, null, 2) }] };
|
||||
}
|
||||
case "update_hero_translation": {
|
||||
const { id, lang, tagline, title, description, buttonText } = params as any;
|
||||
const payload: any = {};
|
||||
if (tagline) payload.tagline = { [lang]: tagline };
|
||||
if (title) payload.title = { [lang]: title };
|
||||
if (description) payload.description = { [lang]: description };
|
||||
if (buttonText) payload.buttonText = { [lang]: buttonText };
|
||||
const res = await api.patch(`/hero/${id}/translations`, payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
case "update_menu_item_translation": {
|
||||
const { id, lang, label } = params as any;
|
||||
const res = await api.patch(`/menus/items/${id}/translations`, {
|
||||
|
||||
Reference in new issue
Block a user