x
This commit is contained in:
1 parent
7cb65bf5e7
commit
512a988d17
21 files changed
+516
-159
No files matched your search
@@ -32,6 +32,7 @@ import LazadaOrderPage from './pages/LazadaOrderPage'
|
|||||||
import LazadaAnalyticsPage from './pages/LazadaAnalyticsPage'
|
import LazadaAnalyticsPage from './pages/LazadaAnalyticsPage'
|
||||||
import LazadaOverviewPage from './pages/LazadaOverviewPage'
|
import LazadaOverviewPage from './pages/LazadaOverviewPage'
|
||||||
import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
|
import GlobalAnalyticsPage from './pages/GlobalAnalyticsPage'
|
||||||
|
import MediaLibraryPage from './pages/MediaLibraryPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -52,6 +53,7 @@ function App() {
|
|||||||
<Route path="/page-builder" element={<PageBuilderPage />} />
|
<Route path="/page-builder" element={<PageBuilderPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||||
|
<Route path="/media" element={<MediaLibraryPage />} />
|
||||||
<Route path="/promotions" element={<PromotionsPage />} />
|
<Route path="/promotions" element={<PromotionsPage />} />
|
||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Layers,
|
Layers,
|
||||||
Globe2,
|
Globe2,
|
||||||
Users
|
Users,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
@@ -28,6 +29,7 @@ const MENU_ITEMS = [
|
|||||||
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
|
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
|
||||||
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
|
||||||
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
|
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
|
||||||
|
{ icon: ImageIcon, label: 'Media Library', to: '/media' },
|
||||||
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
|
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
|
||||||
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
|
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
|
||||||
{ icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' },
|
{ icon: TrendingUp, label: 'TikTok Analysis', to: '/tiktok-analysis' },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { API_BASE } from '@homaroma/types'
|
|||||||
import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react'
|
import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react'
|
||||||
import ReactQuill from 'react-quill-new'
|
import ReactQuill from 'react-quill-new'
|
||||||
import 'react-quill-new/dist/quill.snow.css'
|
import 'react-quill-new/dist/quill.snow.css'
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||||
|
|
||||||
export default function ArticleEditPage() {
|
export default function ArticleEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -21,6 +22,9 @@ export default function ArticleEditPage() {
|
|||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [isPublished, setIsPublished] = useState(false)
|
const [isPublished, setIsPublished] = useState(false)
|
||||||
|
|
||||||
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||||
|
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
fetch(`${API_BASE}/articles/id/${id}`)
|
fetch(`${API_BASE}/articles/id/${id}`)
|
||||||
@@ -43,24 +47,10 @@ export default function ArticleEditPage() {
|
|||||||
}
|
}
|
||||||
}, [title, isNew])
|
}, [title, isNew])
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const openMediaModal = (onSelect: (url: string) => void) => {
|
||||||
const file = e.target.files?.[0]
|
setOnSelectMedia(() => onSelect)
|
||||||
if (!file) return
|
setIsMediaModalOpen(true)
|
||||||
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 handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -82,35 +72,13 @@ export default function ArticleEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageHandler = () => {
|
const imageHandler = () => {
|
||||||
const url = prompt('Please enter the Image URL: \n(Or leave blank and click OK to upload from your computer)')
|
openMediaModal((url) => {
|
||||||
|
|
||||||
if (url) {
|
|
||||||
const quill = quillRef.current?.getEditor()
|
const quill = quillRef.current?.getEditor()
|
||||||
if (quill) {
|
if (quill) {
|
||||||
const range = quill.getSelection(true)
|
const range = quill.getSelection(true)
|
||||||
quill.insertEmbed(range?.index || 0, 'image', url)
|
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(() => ({
|
const modules = useMemo(() => ({
|
||||||
@@ -171,16 +139,18 @@ export default function ArticleEditPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Cover Image</label>
|
<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 ? (
|
{coverImage ? (
|
||||||
<img src={coverImage} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt="Cover" />
|
<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">
|
<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" />}
|
<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">Upload Cover</p>
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest group-hover:text-cyan-glow transition-colors">Select Cover Image</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input type="file" accept="image/*" onChange={handleImageUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,6 +206,14 @@ export default function ArticleEditPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (onSelectMedia) onSelectMedia(url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
|
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 { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react'
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
@@ -11,6 +12,9 @@ export default function CategoriesPage() {
|
|||||||
const [activeLang, setActiveLang] = useState('th')
|
const [activeLang, setActiveLang] = useState('th')
|
||||||
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']
|
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 () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -246,7 +250,10 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<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"
|
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 ? (
|
{editingCategory.imageUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -267,16 +274,6 @@ export default function CategoriesPage() {
|
|||||||
<Loader2 className="w-6 h-6 text-cyan-glow animate-spin" />
|
<Loader2 className="w-6 h-6 text-cyan-glow animate-spin" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -335,6 +332,14 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (onSelectMedia) onSelectMedia(url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ClipboardPaste
|
ClipboardPaste
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, type Hero } from '@homaroma/types'
|
import { API_BASE, type Hero } from '@homaroma/types'
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||||
|
|
||||||
export default function HeroSettingsPage() {
|
export default function HeroSettingsPage() {
|
||||||
const [heroes, setHeroes] = useState<Hero[]>([])
|
const [heroes, setHeroes] = useState<Hero[]>([])
|
||||||
@@ -23,6 +24,9 @@ export default function HeroSettingsPage() {
|
|||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [editingHero, setEditingHero] = useState<Partial<Hero> | null>(null)
|
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 () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -310,7 +314,10 @@ export default function HeroSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<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"
|
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 ? (
|
{editingHero.imageUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -331,16 +338,6 @@ export default function HeroSettingsPage() {
|
|||||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-start gap-2 text-[10px] text-slate-500">
|
<div className="flex items-start gap-2 text-[10px] text-slate-500">
|
||||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||||
@@ -431,6 +428,14 @@ export default function HeroSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (onSelectMedia) onSelectMedia(url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
||||||
import { getAdminDisplayString } from '../utils/localization';
|
import { getAdminDisplayString } from '../utils/localization';
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal';
|
||||||
|
|
||||||
interface Scent {
|
interface Scent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,6 +39,25 @@ export default function PageBuilderPage() {
|
|||||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
|
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
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');
|
const [editingLang, setEditingLang] = useState('th');
|
||||||
|
|
||||||
// Helper to read localized string safely
|
// 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"
|
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={() => {
|
onClick={() => {
|
||||||
setUploadTarget({ type: 'main' });
|
setUploadTarget({ type: 'main' });
|
||||||
document.getElementById('section-upload')?.click();
|
setIsMediaModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editingSection.payload?.imageUrl ? (
|
{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>
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
||||||
</div>
|
</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>
|
||||||
</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"
|
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={() => {
|
onClick={() => {
|
||||||
setUploadTarget({ type: 'slide', index: idx });
|
setUploadTarget({ type: 'slide', index: idx });
|
||||||
document.getElementById('section-upload')?.click();
|
setIsMediaModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slide.imageUrl ? (
|
{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>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Image</label>
|
||||||
<div
|
<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"
|
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 ? (
|
{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>
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Banner Image</label>
|
||||||
<div
|
<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"
|
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 ? (
|
{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>
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Select Image</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2111,6 +2102,12 @@ export default function PageBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={handleMediaSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 { 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 ReactQuill from 'react-quill-new'
|
||||||
import 'react-quill-new/dist/quill.snow.css'
|
import 'react-quill-new/dist/quill.snow.css'
|
||||||
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||||
|
|
||||||
export default function ProductEditPage() {
|
export default function ProductEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -43,6 +44,9 @@ export default function ProductEditPage() {
|
|||||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||||
const [images, setImages] = useState<Partial<ProductImage>[]>([])
|
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 quillRef = useRef<any>(null)
|
||||||
|
|
||||||
const fixUrl = (url: string) => {
|
const fixUrl = (url: string) => {
|
||||||
@@ -50,40 +54,19 @@ export default function ProductEditPage() {
|
|||||||
return url.replace('/public/uploads/', '/uploads/')
|
return url.replace('/public/uploads/', '/uploads/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageHandler = () => {
|
const openMediaModal = (onSelect: (url: string) => void) => {
|
||||||
const url = prompt('Please enter the Image URL: \n(Or leave blank and click OK to upload from your computer)')
|
setOnSelectMedia(() => onSelect)
|
||||||
|
setIsMediaModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
// If they typed something and clicked OK
|
const imageHandler = () => {
|
||||||
if (url) {
|
openMediaModal((url) => {
|
||||||
const quill = quillRef.current?.getEditor()
|
const quill = quillRef.current?.getEditor()
|
||||||
if (quill) {
|
if (quill) {
|
||||||
const range = quill.getSelection(true)
|
const range = quill.getSelection(true)
|
||||||
quill.insertEmbed(range?.index || 0, 'image', url)
|
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(() => ({
|
const modules = useMemo(() => ({
|
||||||
@@ -413,25 +396,13 @@ export default function ProductEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => document.getElementById('file-upload')?.click()}
|
onClick={() => openMediaModal((url) => {
|
||||||
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"
|
setImages(prev => [...prev, { url, order: prev.length }])
|
||||||
>
|
})}
|
||||||
<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}
|
|
||||||
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"
|
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" />
|
<ImagePlus className="w-3.5 h-3.5" />
|
||||||
Add Image URL
|
Add / Browse Image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,6 +637,14 @@ export default function ProductEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
if (onSelectMedia) onSelectMedia(url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
BACKEND_PUBLIC_URL="https://api.homaroma.shop"
|
||||||
@@ -617,3 +617,12 @@ model MenuItemTranslation {
|
|||||||
|
|
||||||
@@unique([menuItemId, langCode])
|
@@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.
File renamed without changes.
File renamed without changes.
@@ -26,6 +26,7 @@ import { profileRoute } from "./routes/profile";
|
|||||||
import { lineRoutes } from "./routes/line";
|
import { lineRoutes } from "./routes/line";
|
||||||
import { menusRoute } from "./routes/menus";
|
import { menusRoute } from "./routes/menus";
|
||||||
import { newsletterRoute } from "./routes/newsletter";
|
import { newsletterRoute } from "./routes/newsletter";
|
||||||
|
import { mediaRoute } from "./routes/media";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||||
@@ -99,7 +100,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.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" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { mkdir } from "fs/promises";
|
import { mkdir } from "fs/promises";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
export const uploadRoute = new Elysia({ prefix: "/upload" })
|
export const uploadRoute = new Elysia({ prefix: "/upload" })
|
||||||
.post("/", async ({ body, set }) => {
|
.post("/", async ({ body, set }) => {
|
||||||
@@ -32,15 +33,29 @@ export const uploadRoute = new Elysia({ prefix: "/upload" })
|
|||||||
// Write file using Bun.write
|
// Write file using Bun.write
|
||||||
await Bun.write(filePath, await file.arrayBuffer());
|
await Bun.write(filePath, await file.arrayBuffer());
|
||||||
|
|
||||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
const relativePath = `/uploads/${filename}`;
|
||||||
const publicUrl = `${APP_URL}/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}`);
|
console.log(`[Upload] Success: ${filename} -> ${publicUrl}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
filename
|
filename,
|
||||||
|
path: relativePath
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Upload] Error:", err);
|
console.error("[Upload] Error:", err);
|
||||||
|
|||||||
@@ -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 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' }}>
|
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
||||||
<img
|
<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}
|
alt={item.productName}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<div className="absolute inset-0 z-0">
|
||||||
<img
|
<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]"
|
className="w-full h-full object-cover scale-[1.02]"
|
||||||
alt="Hero Background"
|
alt="Hero Background"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export default function OrderSuccessPage() {
|
|||||||
{order.items.map(item => (
|
{order.items.map(item => (
|
||||||
<div key={item.id} className="flex items-center gap-4 text-sm">
|
<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">
|
<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>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-bold text-pop-black">{item.productName}</p>
|
<p className="font-bold text-pop-black">{item.productName}</p>
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ export default function ProfilePage() {
|
|||||||
{order.items.map(item => (
|
{order.items.map(item => (
|
||||||
<div key={item.id} className="flex items-center gap-3 text-xs">
|
<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">
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-bold text-pop-black truncate">{item.productName}</p>
|
<p className="font-bold text-pop-black truncate">{item.productName}</p>
|
||||||
|
|||||||
Reference in new issue
Block a user