This commit is contained in:
Your Name committed 2026-06-15 01:51:38 -04:00
1 parent 7cb65bf5e7
commit 512a988d17
21 files changed
+516 -159

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ import LazadaOrderPage from './pages/LazadaOrderPage'
import LazadaAnalyticsPage from './pages/LazadaAnalyticsPage'
import LazadaOverviewPage from './pages/LazadaOverviewPage'
import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
function App() {
return (
@@ -52,6 +53,7 @@ function App() {
<Route path="/page-builder" element={<PageBuilderPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="/media" element={<MediaLibraryPage />} />
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} />
@@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon } from 'lucide-react';
interface MediaLibraryModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (url: string) => void;
}
interface MediaItem {
id?: string;
url: string;
filename?: string;
}
export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const fetchMedia = async () => {
try {
setLoading(true);
const res = await fetch(`${API_BASE}/media`);
if (res.ok) {
const data = await res.json();
setMedia(data);
}
} catch (error) {
console.error('Failed to fetch media', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchMedia();
}
}, [isOpen]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
setUploading(true);
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
await fetchMedia(); // Refresh list
if (data.url) {
// Auto-select uploaded image (optional, or let user pick from list)
onSelect(data.url);
onClose();
}
}
} catch (error) {
console.error('Failed to upload', error);
} finally {
setUploading(false);
e.target.value = ''; // Reset input
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
{/* Header */}
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-night-950">
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-cyan-glow" />
Media Library
</h2>
<p className="text-sm text-slate-400 mt-1">Select or upload images for your content.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
<X className="w-6 h-6" />
</button>
</div>
{/* Actions */}
<div className="p-4 border-b border-white/10 bg-night-900/50 flex justify-between items-center">
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm">
{uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading...</>
) : (
<><Upload className="w-4 h-4" /> Upload New Image</>
)}
<input type="file" className="hidden" accept="image/*" onChange={handleUpload} disabled={uploading} />
</label>
<div className="text-sm text-slate-500 font-mono">
{media.length} items found
</div>
</div>
{/* Grid */}
<div className="p-6 flex-1 overflow-y-auto bg-night-950/30">
{loading ? (
<div className="flex flex-col justify-center items-center h-full text-slate-500 space-y-4">
<Loader2 className="w-10 h-10 animate-spin text-cyan-glow" />
<p className="font-mono text-sm tracking-widest uppercase">Loading Media...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{media.map((item, idx) => (
<div
key={item.id || idx}
className="group relative border-2 border-white/5 bg-night-950 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-glow transition-all hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] aspect-square flex flex-col"
onClick={() => {
onSelect(item.url);
onClose();
}}
>
<div className="flex-1 overflow-hidden relative bg-black/50">
<img
src={item.url}
alt={item.filename || 'media'}
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300"
/>
</div>
{item.filename && (
<div className="p-2.5 text-[10px] uppercase tracking-widest font-mono truncate bg-night-900 border-t border-white/5 text-slate-400 group-hover:text-cyan-glow transition-colors text-center" title={item.filename}>
{item.filename}
</div>
)}
</div>
))}
{media.length === 0 && !loading && (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg font-bold">No images found</p>
<p className="text-sm mt-2">Upload your first image to get started.</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -15,7 +15,8 @@ import {
BarChart3,
Layers,
Globe2,
Users
Users,
Image as ImageIcon
} from 'lucide-react'
const MENU_ITEMS = [
@@ -28,6 +29,7 @@ const MENU_ITEMS = [
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
{ icon: ImageIcon, label: 'Media Library', to: '/media' },
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
{ icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' },
+23 -45
View File
@@ -4,6 +4,7 @@ import { API_BASE } from '@homaroma/types'
import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
import MediaLibraryModal from '../components/MediaLibraryModal'
export default function ArticleEditPage() {
const { id } = useParams<{ id: string }>()
@@ -21,6 +22,9 @@ export default function ArticleEditPage() {
const [content, setContent] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
useEffect(() => {
if (isNew) return
fetch(`${API_BASE}/articles/id/${id}`)
@@ -43,24 +47,10 @@ export default function ArticleEditPage() {
}
}, [title, isNew])
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(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) setCoverImage(data.url)
} catch (err) {
console.error(err)
alert('Upload failed')
} finally {
setUploading(false)
}
const openMediaModal = (onSelect: (url: string) => void) => {
setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -82,35 +72,13 @@ export default function ArticleEditPage() {
}
const imageHandler = () => {
const url = prompt('Please enter the Image URL: \n(Or leave blank and click OK to upload from your computer)')
if (url) {
openMediaModal((url) => {
const quill = quillRef.current?.getEditor()
if (quill) {
const range = quill.getSelection(true)
quill.insertEmbed(range?.index || 0, 'image', url)
}
} else if (url === "") {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = () => {
const file = input.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
const quill = quillRef.current?.getEditor()
if (quill) {
const range = quill.getSelection(true)
quill.insertEmbed(range?.index || 0, 'image', e.target?.result)
}
}
reader.readAsDataURL(file)
}
}
}
})
}
const modules = useMemo(() => ({
@@ -171,16 +139,18 @@ export default function ArticleEditPage() {
<div className="space-y-2">
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Cover Image</label>
<div className="relative border-2 border-dashed border-white/10 rounded-xl bg-night-950 hover:border-cyan-glow/50 transition-colors group cursor-pointer overflow-hidden aspect-[4/3] flex flex-col items-center justify-center text-center">
<div
className="relative border-2 border-dashed border-white/10 rounded-xl bg-night-950 hover:border-cyan-glow/50 transition-colors group cursor-pointer overflow-hidden aspect-[4/3] flex flex-col items-center justify-center text-center"
onClick={() => openMediaModal((url) => setCoverImage(url))}
>
{coverImage ? (
<img src={coverImage} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="Cover" />
) : (
<div className="space-y-2 p-4">
{uploading ? <Loader2 className="w-8 h-8 animate-spin mx-auto text-cyan-glow" /> : <ImageIcon className="w-8 h-8 text-slate-500 mx-auto" />}
<p className="text-[10px] text-slate-500 uppercase tracking-widest">Upload Cover</p>
<ImageIcon className="w-8 h-8 text-slate-500 mx-auto group-hover:text-cyan-glow transition-colors" />
<p className="text-[10px] text-slate-500 uppercase tracking-widest group-hover:text-cyan-glow transition-colors">Select Cover Image</p>
</div>
)}
<input type="file" accept="image/*" onChange={handleImageUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
</div>
</div>
</div>
@@ -236,6 +206,14 @@ export default function ArticleEditPage() {
</button>
</div>
</form>
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}
/>
</div>
)
}
+16 -11
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal'
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([])
@@ -11,6 +12,9 @@ export default function CategoriesPage() {
const [activeLang, setActiveLang] = useState('th')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const fetchData = async () => {
setLoading(true)
try {
@@ -246,7 +250,10 @@ export default function CategoriesPage() {
</div>
<div
className="relative w-40 h-40 mx-auto rounded-3xl 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={() => document.getElementById('category-upload')?.click()}
onClick={() => {
setOnSelectMedia(() => (url: string) => setEditingCategory(prev => ({ ...prev, imageUrl: url })))
setIsMediaModalOpen(true)
}}
>
{editingCategory.imageUrl ? (
<>
@@ -267,16 +274,6 @@ export default function CategoriesPage() {
<Loader2 className="w-6 h-6 text-cyan-glow animate-spin" />
</div>
)}
<input
id="category-upload"
type="file"
className="hidden"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
</div>
</div>
@@ -335,6 +332,14 @@ export default function CategoriesPage() {
</div>
)
)}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}
/>
</div>
)
}
+16 -11
View File
@@ -15,6 +15,7 @@ import {
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[]>([])
@@ -23,6 +24,9 @@ export default function HeroSettingsPage() {
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 {
@@ -310,7 +314,10 @@ export default function HeroSettingsPage() {
</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={() => document.getElementById('hero-upload')?.click()}
onClick={() => {
setOnSelectMedia(() => (url: string) => setEditingHero(prev => ({ ...prev, imageUrl: url })))
setIsMediaModalOpen(true)
}}
>
{editingHero.imageUrl ? (
<>
@@ -331,16 +338,6 @@ export default function HeroSettingsPage() {
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
</div>
)}
<input
id="hero-upload"
type="file"
className="hidden"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}}
/>
</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" />
@@ -431,6 +428,14 @@ export default function HeroSettingsPage() {
</div>
)
)}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}
/>
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { API_BASE } from '@homaroma/types';
import { Upload, Loader2, Image as ImageIcon, Trash2 } from 'lucide-react';
interface MediaItem {
id?: string;
url: string;
filename?: string;
}
export default function MediaLibraryPage() {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchMedia = async () => {
try {
setLoading(true);
const res = await fetch(`${API_BASE}/media`);
if (res.ok) {
const data = await res.json();
setMedia(data);
}
} catch (error) {
console.error('Failed to fetch media', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMedia();
}, []);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
setUploading(true);
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
body: formData,
});
if (res.ok) {
await fetchMedia(); // Refresh list
}
} catch (error) {
console.error('Failed to upload', error);
} finally {
setUploading(false);
e.target.value = ''; // Reset input
}
};
const handleDelete = async (id: string | undefined, e: React.MouseEvent) => {
e.stopPropagation();
if (!id) return;
if (!window.confirm('Are you sure you want to delete this image?')) return;
try {
setDeletingId(id);
const res = await fetch(`${API_BASE}/media/${id}`, {
method: 'DELETE',
});
if (res.ok) {
await fetchMedia();
} else {
alert('Failed to delete image');
}
} catch (error) {
console.error('Failed to delete', error);
alert('An error occurred while deleting');
} finally {
setDeletingId(null);
}
};
const copyToClipboard = (url: string, e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(url);
alert('Image URL copied to clipboard!');
};
return (
<div className="p-8 max-w-7xl mx-auto animate-fade-in flex flex-col h-[calc(100vh-4rem)]">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<ImageIcon className="w-8 h-8 text-cyan-glow" />
Media Library
</h1>
<p className="text-slate-400 mt-2">Manage your uploaded images</p>
</div>
<label className="btn-primary cursor-pointer inline-flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm">
{uploading ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Uploading...</>
) : (
<><Upload className="w-4 h-4" /> Upload New Image</>
)}
<input type="file" className="hidden" accept="image/*" onChange={handleUpload} disabled={uploading} />
</label>
</div>
<div className="flex-1 overflow-y-auto bg-night-900 border border-white/5 rounded-2xl p-6">
{loading ? (
<div className="flex flex-col justify-center items-center h-full text-slate-500 space-y-4">
<Loader2 className="w-10 h-10 animate-spin text-cyan-glow" />
<p className="font-mono text-sm tracking-widest uppercase">Loading Media...</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{media.map((item, idx) => (
<div
key={item.id || idx}
className="group relative border-2 border-white/5 bg-night-950 rounded-xl overflow-hidden hover:border-cyan-glow transition-all hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] aspect-square flex flex-col"
onClick={(e) => copyToClipboard(item.url, e)}
>
<div className="flex-1 overflow-hidden relative bg-black/50 cursor-pointer" title="Click to copy URL">
<img
src={item.url}
alt={item.filename || 'media'}
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300"
/>
{/* Hover Overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<span className="text-white text-xs font-bold uppercase tracking-wider">Copy URL</span>
</div>
</div>
{item.filename && (
<div className="p-2.5 text-[10px] uppercase tracking-widest font-mono truncate bg-night-900 border-t border-white/5 text-slate-400 group-hover:text-cyan-glow transition-colors text-center" title={item.filename}>
{item.filename}
</div>
)}
{item.id && (
<button
className="absolute top-2 right-2 p-1.5 bg-rose-500/80 text-white rounded hover:bg-rose-600 transition-colors opacity-0 group-hover:opacity-100"
title="Delete Image"
onClick={(e) => handleDelete(item.id, e)}
disabled={deletingId === item.id}
>
{deletingId === item.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
)}
</div>
))}
{media.length === 0 && !loading && (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg font-bold">No images found</p>
<p className="text-sm mt-2">Upload your first image to get started.</p>
</div>
)}
</div>
)}
</div>
</div>
);
}
+36 -39
View File
@@ -19,6 +19,7 @@ import {
} from 'lucide-react';
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
import { getAdminDisplayString } from '../utils/localization';
import MediaLibraryModal from '../components/MediaLibraryModal';
interface Scent {
id: number;
@@ -38,6 +39,25 @@ export default function PageBuilderPage() {
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
const handleMediaSelect = (url: string) => {
if (uploadTarget?.type === 'slide' && typeof uploadTarget.index === 'number') {
const idx = uploadTarget.index;
setEditingSection(prev => {
if (!prev) return null;
const currentSlides = [...(prev.payload?.slides || [])];
currentSlides[idx] = { ...currentSlides[idx], imageUrl: url };
return { ...prev, payload: { ...(prev.payload || {}), slides: currentSlides } };
});
} else {
setEditingSection(prev => {
if (!prev) return null;
return { ...prev, payload: { ...(prev.payload || {}), imageUrl: url } };
});
}
};
const [editingLang, setEditingLang] = useState('th');
// Helper to read localized string safely
@@ -933,7 +953,7 @@ export default function PageBuilderPage() {
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => {
setUploadTarget({ type: 'main' });
document.getElementById('section-upload')?.click();
setIsMediaModalOpen(true);
}}
>
{editingSection.payload?.imageUrl ? (
@@ -949,11 +969,6 @@ export default function PageBuilderPage() {
<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>
@@ -1120,7 +1135,7 @@ export default function PageBuilderPage() {
className="relative aspect-video rounded-xl overflow-hidden border border-white/10 bg-night-950/30 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => {
setUploadTarget({ type: 'slide', index: idx });
document.getElementById('section-upload')?.click();
setIsMediaModalOpen(true);
}}
>
{slide.imageUrl ? (
@@ -1394,7 +1409,10 @@ export default function PageBuilderPage() {
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Image</label>
<div
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => document.getElementById('section-upload')?.click()}
onClick={() => {
setUploadTarget({ type: 'main' });
setIsMediaModalOpen(true);
}}
>
{editingSection.payload?.imageUrl ? (
<>
@@ -1409,21 +1427,6 @@ export default function PageBuilderPage() {
<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>
)}
<input
id="section-upload"
type="file"
className="hidden"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}}
/>
</div>
</div>
@@ -1701,7 +1704,10 @@ export default function PageBuilderPage() {
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Banner Image</label>
<div
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
onClick={() => document.getElementById('section-upload')?.click()}
onClick={() => {
setUploadTarget({ type: 'main' });
setIsMediaModalOpen(true);
}}
>
{editingSection.payload?.imageUrl ? (
<>
@@ -1716,21 +1722,6 @@ export default function PageBuilderPage() {
<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>
)}
<input
id="section-upload"
type="file"
className="hidden"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}}
/>
</div>
</div>
@@ -2111,6 +2102,12 @@ export default function PageBuilderPage() {
</div>
</div>
)}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={handleMediaSelect}
/>
</div>
);
}
+23 -44
View File
@@ -19,6 +19,7 @@ import {
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type Variant, type ProductImage, type RawMaterial, type BomItem } from '@homaroma/types'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
import MediaLibraryModal from '../components/MediaLibraryModal'
export default function ProductEditPage() {
const { id } = useParams<{ id: string }>()
@@ -43,6 +44,9 @@ export default function ProductEditPage() {
const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<Partial<ProductImage>[]>([])
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const quillRef = useRef<any>(null)
const fixUrl = (url: string) => {
@@ -50,40 +54,19 @@ export default function ProductEditPage() {
return url.replace('/public/uploads/', '/uploads/')
}
const imageHandler = () => {
const url = prompt('Please enter the Image URL: \n(Or leave blank and click OK to upload from your computer)')
const openMediaModal = (onSelect: (url: string) => void) => {
setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
}
// If they typed something and clicked OK
if (url) {
const imageHandler = () => {
openMediaModal((url) => {
const quill = quillRef.current?.getEditor()
if (quill) {
const range = quill.getSelection(true)
quill.insertEmbed(range?.index || 0, 'image', url)
}
}
// If they left it blank and clicked OK
else if (url === "") {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = () => {
const file = input.files?.[0]
if (file) {
// Use standard base64 embedding for normal file uploads
const reader = new FileReader()
reader.onload = (e) => {
const quill = quillRef.current?.getEditor()
if (quill) {
const range = quill.getSelection(true)
quill.insertEmbed(range?.index || 0, 'image', e.target?.result)
}
}
reader.readAsDataURL(file)
}
}
}
})
}
const modules = useMemo(() => ({
@@ -413,25 +396,13 @@ export default function ProductEditPage() {
</div>
<div className="flex gap-2">
<button
onClick={() => document.getElementById('file-upload')?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-slate-300 text-[10px] font-bold uppercase tracking-widest hover:bg-white/10 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Upload Local Image
</button>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*"
onChange={onFileUpload}
/>
<button
onClick={handleAddImage}
onClick={() => openMediaModal((url) => {
setImages(prev => [...prev, { url, order: prev.length }])
})}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
>
<ImagePlus className="w-3.5 h-3.5" />
Add Image URL
Add / Browse Image
</button>
</div>
</div>
@@ -666,6 +637,14 @@ export default function ProductEditPage() {
</div>
</div>
</div>
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
}}
/>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
BACKEND_PUBLIC_URL="https://api.homaroma.shop"
+9
View File
@@ -617,3 +617,12 @@ model MenuItemTranslation {
@@unique([menuItemId, langCode])
}
model Media {
id Int @id @default(autoincrement())
filename String
path String
mimeType String
sizeBytes Int
createdAt DateTime @default(now())
}
File renamed without changes.
+2 -1
View File
@@ -26,6 +26,7 @@ import { profileRoute } from "./routes/profile";
import { lineRoutes } from "./routes/line";
import { menusRoute } from "./routes/menus";
import { newsletterRoute } from "./routes/newsletter";
import { mediaRoute } from "./routes/media";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
@@ -99,7 +100,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)
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)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+42
View File
@@ -0,0 +1,42 @@
import { Elysia } from "elysia";
import prisma from "../lib/prisma";
export const mediaRoute = new Elysia({ prefix: "/media" })
.get("/", async () => {
const media = await prisma.media.findMany({
orderBy: { createdAt: "desc" }
});
const BACKEND_PUBLIC_URL = process.env.BACKEND_PUBLIC_URL || "http://localhost:3051";
return media.map(m => ({
...m,
url: `${BACKEND_PUBLIC_URL}${m.path}`
}));
})
.delete("/:id", async ({ params, set }) => {
try {
const mediaId = parseInt(params.id);
const media = await prisma.media.findUnique({
where: { id: mediaId }
});
if (!media) {
set.status = 404;
return { error: "Media not found" };
}
await prisma.media.delete({
where: { id: mediaId }
});
// We are not removing the physical file right now, but could be added later if needed.
return { success: true, message: "Media deleted successfully" };
} catch (err) {
console.error("[Media] Delete Error:", err);
set.status = 500;
return { error: "Failed to delete media" };
}
});
+18 -3
View File
@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import { join } from "path";
import { mkdir } from "fs/promises";
import prisma from "../lib/prisma";
export const uploadRoute = new Elysia({ prefix: "/upload" })
.post("/", async ({ body, set }) => {
@@ -32,15 +33,29 @@ export const uploadRoute = new Elysia({ prefix: "/upload" })
// Write file using Bun.write
await Bun.write(filePath, await file.arrayBuffer());
const APP_URL = process.env.APP_URL || "http://localhost:3000";
const publicUrl = `${APP_URL}/uploads/${filename}`;
const relativePath = `/uploads/${filename}`;
const mimeType = file.type;
const sizeBytes = file.size;
await prisma.media.create({
data: {
filename,
path: relativePath,
mimeType,
sizeBytes,
}
});
const BACKEND_PUBLIC_URL = process.env.BACKEND_PUBLIC_URL || "http://localhost:3051";
const publicUrl = `${BACKEND_PUBLIC_URL}${relativePath}`;
console.log(`[Upload] Success: ${filename} -> ${publicUrl}`);
return {
success: true,
url: publicUrl,
filename
filename,
path: relativePath
};
} catch (err) {
console.error("[Upload] Error:", err);
+1 -1
View File
@@ -68,7 +68,7 @@ export default function CartDrawer() {
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white border border-spa-stone/10 shadow-sm group relative">
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
<img
src={item.productImage || '/images/oil_ozone.png'}
src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`}
alt={item.productName}
className="w-full h-full object-cover"
/>
+1 -1
View File
@@ -38,7 +38,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-[21/9] flex items-center justify-center overflow-hidden bg-night-900">
<div className="absolute inset-0 z-0">
<img
src={imageUrl || "/images/hero.png"}
src={imageUrl || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/hero.png`}
className="w-full h-full object-cover scale-[1.02]"
alt="Hero Background"
onError={(e) => {
+1 -1
View File
@@ -264,7 +264,7 @@ export default function OrderSuccessPage() {
{order.items.map(item => (
<div key={item.id} className="flex items-center gap-4 text-sm">
<div className="w-12 h-12 rounded-lg bg-spa-stone/10 flex-shrink-0 overflow-hidden border border-spa-stone/10">
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
<img src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`} alt="Product" className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<p className="font-bold text-pop-black">{item.productName}</p>
+1 -1
View File
@@ -481,7 +481,7 @@ export default function ProfilePage() {
{order.items.map(item => (
<div key={item.id} className="flex items-center gap-3 text-xs">
<div className="w-10 h-10 rounded-md bg-spa-stone/10 overflow-hidden border border-spa-stone/15 flex-shrink-0">
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
<img src={item.productImage || `${import.meta.env.VITE_API_URL.replace('/api', '')}/assets/oil_ozone.png`} alt="Product" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-pop-black truncate">{item.productName}</p>