796 lines
56 KiB
TypeScript
796 lines
56 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { Link } from 'react-router-dom';
|
||
import { useStore } from '../src/context/StoreContext';
|
||
|
||
type Tab = 'dashboard' | 'shop' | 'editorial' | 'orders';
|
||
|
||
type Section = {
|
||
id: string;
|
||
type: 'text' | 'image';
|
||
content: string;
|
||
};
|
||
|
||
// Mock Data Types
|
||
type Product = { id?: number; title: string; price: number; image: string; images: string[]; description?: string; details?: string[] };
|
||
type Article = { id?: number; title: string; date: string; image: string; sections: Section[]; category?: string; isFeatured?: boolean };
|
||
|
||
const Admin: React.FC = () => {
|
||
const [activeTab, setActiveTab] = useState<Tab>('dashboard');
|
||
|
||
// Modal State
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
|
||
const [editType, setEditType] = useState<'shop' | 'editorial' | null>(null);
|
||
|
||
const [productForm, setProductForm] = useState<Product>({ id: '', title: '', price: 0, image: '', images: [], details: [] });
|
||
const [articleForm, setArticleForm] = useState<Article>({ id: '', title: '', date: '', image: '', sections: [], isFeatured: false });
|
||
const [pendingSectionId, setPendingSectionId] = useState<string | null>(null);
|
||
|
||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||
const galleryInputRef = React.useRef<HTMLInputElement>(null);
|
||
|
||
// Handlers
|
||
const openAddModal = (type: 'shop' | 'editorial') => {
|
||
setEditType(type);
|
||
setModalMode('add');
|
||
// Reset forms
|
||
setProductForm({ title: '', price: 0, image: '', images: [], details: [] });
|
||
setArticleForm({ title: '', date: new Date().toLocaleDateString('en-US', { month: 'short', day: '2-digit' }), image: '', sections: [], isFeatured: false });
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const openEditModal = (type: 'shop' | 'editorial', item: any) => {
|
||
setEditType(type);
|
||
setModalMode('edit');
|
||
if (type === 'shop') {
|
||
setProductForm({ ...item, images: item.images || [], details: item.details || [] });
|
||
} else {
|
||
setArticleForm({
|
||
...item,
|
||
sections: item.sections || [],
|
||
isFeatured: !!item.isFeatured
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const addSection = (type: 'text' | 'image') => {
|
||
setArticleForm(prev => ({
|
||
...prev,
|
||
sections: [...prev.sections, { id: Math.random().toString(), type, content: '' }]
|
||
}));
|
||
};
|
||
|
||
const updateSection = (id: string, content: string) => {
|
||
setArticleForm(prev => ({
|
||
...prev,
|
||
sections: prev.sections.map(s => s.id === id ? { ...s, content } : s)
|
||
}));
|
||
};
|
||
|
||
const removeSection = (id: string) => {
|
||
setArticleForm(prev => ({
|
||
...prev,
|
||
sections: prev.sections.filter(s => s.id !== id)
|
||
}));
|
||
};
|
||
|
||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>, isGallery: boolean = false) => {
|
||
const files = e.target.files;
|
||
if (files && files[0]) {
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
const url = event.target?.result as string;
|
||
|
||
if (pendingSectionId) {
|
||
setArticleForm(prev => ({
|
||
...prev,
|
||
sections: prev.sections.map(s => s.id === pendingSectionId ? { ...s, content: url } : s)
|
||
}));
|
||
setPendingSectionId(null);
|
||
} else if (isGallery) {
|
||
// For gallery we handle multiple if needed, but let's stick to simple one at a time for base64
|
||
setProductForm(prev => ({ ...prev, images: [...prev.images, url] }));
|
||
} else {
|
||
if (editType === 'shop') {
|
||
setProductForm(prev => ({ ...prev, image: url }));
|
||
} else {
|
||
setArticleForm(prev => ({ ...prev, image: url }));
|
||
}
|
||
}
|
||
};
|
||
reader.readAsDataURL(files[0]);
|
||
}
|
||
};
|
||
|
||
const {
|
||
products,
|
||
articles,
|
||
orders,
|
||
fetchOrders,
|
||
updateOrderStatus,
|
||
addProduct,
|
||
updateProduct,
|
||
deleteProduct,
|
||
addArticle,
|
||
updateArticle,
|
||
deleteArticle
|
||
} = useStore();
|
||
|
||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||
|
||
React.useEffect(() => {
|
||
if (activeTab === 'orders') {
|
||
fetchOrders();
|
||
}
|
||
}, [activeTab]);
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
if (editType === 'shop') {
|
||
const newProduct = {
|
||
...productForm,
|
||
id: modalMode === 'add' ? undefined : productForm.id,
|
||
slug: productForm.title.toLowerCase().replace(/ /g, '-').replace(/[^\w-]/g, ''),
|
||
number: '01',
|
||
images: productForm.images.includes(productForm.image) ? productForm.images : [productForm.image, ...productForm.images.filter(img => img !== productForm.image)],
|
||
aspectRatio: 'aspect-[4/5]',
|
||
details: productForm.details || []
|
||
};
|
||
|
||
if (modalMode === 'add') {
|
||
await addProduct(newProduct as any);
|
||
} else {
|
||
await updateProduct(newProduct as any);
|
||
}
|
||
} else {
|
||
const newArticle = {
|
||
...articleForm,
|
||
id: modalMode === 'add' ? undefined : articleForm.id,
|
||
slug: articleForm.title.toLowerCase().replace(/ /g, '-').replace(/[^\w-]/g, ''),
|
||
category: articleForm.category || 'Studio Life',
|
||
description: articleForm.sections.find(s => s.type === 'text')?.content?.substring(0, 150) || 'New article...',
|
||
isFeatured: articleForm.isFeatured
|
||
};
|
||
|
||
if (modalMode === 'add') {
|
||
await addArticle(newArticle as any);
|
||
} else {
|
||
await updateArticle(newArticle as any);
|
||
}
|
||
}
|
||
alert('Saved successfully!');
|
||
setIsModalOpen(false);
|
||
} catch (err) {
|
||
console.error('Save failed:', err);
|
||
alert('Failed to save. Please make sure the server is running.');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-screen bg-stone-100 dark:bg-stone-900 font-body relative overflow-hidden">
|
||
<AnimatePresence>
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||
/>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
||
className="bg-white dark:bg-stone-900 w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-lg shadow-2xl relative z-10 flex flex-col"
|
||
>
|
||
{/* Modal Header */}
|
||
<div className="p-8 border-b border-stone-100 dark:border-stone-800 flex justify-between items-center sticky top-0 bg-white dark:bg-stone-900 z-20">
|
||
<div>
|
||
<span className="text-xs font-bold uppercase tracking-widest text-stone-400">
|
||
{modalMode === 'add' ? 'Create New' : 'Editing'}
|
||
</span>
|
||
<h2 className="font-display text-3xl text-text-main dark:text-white mt-1">
|
||
{editType === 'shop' ? (modalMode === 'add' ? 'Product' : productForm.title) : (modalMode === 'add' ? 'Article' : articleForm.title)}
|
||
</h2>
|
||
</div>
|
||
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors">
|
||
<svg className="w-6 h-6 text-stone-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
|
||
<div className="p-8 space-y-8 flex-1">
|
||
{editType === 'shop' ? (
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||
<div className="space-y-6">
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Product Title</label>
|
||
<input
|
||
type="text"
|
||
value={productForm.title}
|
||
onChange={(e) => setProductForm({ ...productForm, title: e.target.value })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-4 text-lg focus:outline-none focus:border-stone-400 rounded-sm"
|
||
placeholder="e.g. Speckled Vase"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Price ($)</label>
|
||
<input
|
||
type="number"
|
||
value={productForm.price || ''}
|
||
onChange={(e) => setProductForm({ ...productForm, price: parseFloat(e.target.value) })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-4 text-lg focus:outline-none focus:border-stone-400 rounded-sm"
|
||
placeholder="0.00"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Description</label>
|
||
<textarea
|
||
value={productForm.description || ''}
|
||
onChange={(e) => setProductForm({ ...productForm, description: e.target.value })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400 h-32 rounded-sm resize-none"
|
||
placeholder="Describe the product details..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Product Details (List)</label>
|
||
<div className="space-y-2">
|
||
{(productForm.details || []).map((detail, idx) => (
|
||
<div key={idx} className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={detail}
|
||
onChange={(e) => {
|
||
const newDetails = [...(productForm.details || [])];
|
||
newDetails[idx] = e.target.value;
|
||
setProductForm({ ...productForm, details: newDetails });
|
||
}}
|
||
className="flex-1 bg-white dark:bg-black border border-stone-200 dark:border-stone-800 p-2 text-sm rounded-sm"
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
const newDetails = productForm.details?.filter((_, i) => i !== idx);
|
||
setProductForm({ ...productForm, details: newDetails });
|
||
}}
|
||
className="text-red-400 hover:text-red-600 px-2"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
onClick={() => setProductForm({ ...productForm, details: [...(productForm.details || []), ''] })}
|
||
className="text-xs uppercase tracking-widest text-stone-400 hover:text-black dark:hover:text-white"
|
||
>
|
||
+ Add Detail
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Shop Image Uploader */}
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Main Image</label>
|
||
<div
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="border-2 border-dashed border-stone-200 dark:border-stone-800 rounded-lg h-64 flex flex-col items-center justify-center text-stone-400 hover:border-stone-400 hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-all cursor-pointer group relative overflow-hidden"
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={(e) => handleImageUpload(e)}
|
||
className="hidden"
|
||
accept="image/*"
|
||
/>
|
||
{productForm.image ? (
|
||
<img src={productForm.image} alt="Preview" className="h-full w-full object-cover" />
|
||
) : (
|
||
<>
|
||
<svg className="w-10 h-10 mb-4 text-stone-300 group-hover:text-stone-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
<span className="text-sm font-medium">Upload Main Image</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Gallery Images</label>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{productForm.images.map((img, idx) => (
|
||
<div key={idx} className="aspect-square bg-stone-100 rounded-lg overflow-hidden relative group">
|
||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setProductForm(prev => ({ ...prev, images: prev.images.filter((_, i) => i !== idx) }));
|
||
}}
|
||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||
</button>
|
||
</div>
|
||
))}
|
||
<div
|
||
onClick={() => galleryInputRef.current?.click()}
|
||
className="aspect-square border-2 border-dashed border-stone-200 dark:border-stone-800 rounded-lg flex flex-col items-center justify-center text-stone-400 hover:border-stone-400 hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-all cursor-pointer"
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={galleryInputRef}
|
||
onChange={(e) => handleImageUpload(e, true)}
|
||
className="hidden"
|
||
accept="image/*"
|
||
multiple
|
||
/>
|
||
<span className="text-2xl">+</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||
<div className="col-span-2 space-y-6">
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Article Title</label>
|
||
<input
|
||
type="text"
|
||
value={articleForm.title}
|
||
onChange={(e) => setArticleForm({ ...articleForm, title: e.target.value })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-4 text-2xl font-display focus:outline-none focus:border-stone-400 rounded-sm"
|
||
placeholder="Enter an engaging title..."
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Publish Date</label>
|
||
<input
|
||
type="text"
|
||
value={articleForm.date}
|
||
onChange={(e) => setArticleForm({ ...articleForm, date: e.target.value })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-3 text-sm focus:outline-none focus:border-stone-400 rounded-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Category</label>
|
||
<select
|
||
value={articleForm.category || 'Studio Life'}
|
||
onChange={(e) => setArticleForm({ ...articleForm, category: e.target.value })}
|
||
className="w-full bg-stone-50 dark:bg-black border border-stone-200 dark:border-stone-800 p-3 text-sm focus:outline-none focus:border-stone-400 rounded-sm"
|
||
>
|
||
<option>Guide</option>
|
||
<option>Studio Life</option>
|
||
<option>Technique</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center space-x-3 pt-6">
|
||
<input
|
||
type="checkbox"
|
||
id="isFeatured"
|
||
checked={articleForm.isFeatured}
|
||
onChange={(e) => setArticleForm({ ...articleForm, isFeatured: e.target.checked })}
|
||
className="w-4 h-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
|
||
/>
|
||
<label htmlFor="isFeatured" className="text-xs uppercase tracking-widest text-stone-500 cursor-pointer">Featured Article</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cover Image */}
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500 mb-2">Cover Image</label>
|
||
<div
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="border-2 border-dashed border-stone-200 dark:border-stone-800 rounded-lg h-48 flex flex-col items-center justify-center text-stone-400 hover:border-stone-400 hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-all cursor-pointer"
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={(e) => handleImageUpload(e)}
|
||
className="hidden"
|
||
accept="image/*"
|
||
/>
|
||
{articleForm.image ? (
|
||
<img src={articleForm.image} alt="Cover" className="h-full w-full object-cover rounded-lg" />
|
||
) : (
|
||
<span className="text-xs">Upload Cover</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr className="border-stone-100 dark:border-stone-800" />
|
||
|
||
{/* Content Builder */}
|
||
<div>
|
||
<div className="flex justify-between items-center mb-6">
|
||
<label className="block text-xs uppercase tracking-widest text-stone-500">Content Sections</label>
|
||
</div>
|
||
|
||
<div className="space-y-4 mb-6">
|
||
{articleForm.sections.map((section, index) => (
|
||
<div key={section.id} className="group relative border border-stone-200 dark:border-stone-800 rounded-md p-4 bg-stone-50/50 dark:bg-stone-900/50">
|
||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button onClick={() => removeSection(section.id)} className="text-red-400 hover:text-red-600 text-xs uppercase font-bold p-2">Remove</button>
|
||
</div>
|
||
<span className="text-[10px] uppercase text-stone-400 mb-2 block tracking-wider">{index + 1}. {section.type}</span>
|
||
|
||
{section.type === 'text' ? (
|
||
<textarea
|
||
value={section.content}
|
||
onChange={(e) => updateSection(section.id, e.target.value)}
|
||
className="w-full bg-white dark:bg-black border border-stone-200 dark:border-stone-800 p-3 text-sm focus:outline-none focus:border-stone-400 rounded-sm resize-y"
|
||
rows={3}
|
||
placeholder="Write your paragraph here..."
|
||
/>
|
||
) : (
|
||
<div
|
||
onClick={() => {
|
||
setPendingSectionId(section.id);
|
||
fileInputRef.current?.click();
|
||
}}
|
||
className="border border-dashed border-stone-300 dark:border-stone-700 bg-white dark:bg-black p-4 rounded-sm flex items-center justify-center h-24 text-stone-400 hover:border-stone-500 cursor-pointer overflow-hidden"
|
||
>
|
||
{section.content ? (
|
||
<img src={section.content} alt="Section" className="h-full w-full object-cover" />
|
||
) : (
|
||
<span className="text-xs">Select Image</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{articleForm.sections.length === 0 && (
|
||
<div className="text-center py-12 border-2 border-dashed border-stone-100 dark:border-stone-800 rounded-lg">
|
||
<p className="text-stone-400 text-sm">Start building your story</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-4 justify-center">
|
||
<button onClick={() => addSection('text')} className="flex items-center gap-2 px-4 py-2 bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 rounded-full text-xs font-bold uppercase tracking-wider transition-colors">
|
||
<span>+ Add Text</span>
|
||
</button>
|
||
<button onClick={() => addSection('image')} className="flex items-center gap-2 px-4 py-2 bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 rounded-full text-xs font-bold uppercase tracking-wider transition-colors">
|
||
<span>+ Add Image</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
|
||
<div className="p-8 border-t border-stone-100 dark:border-stone-800 bg-stone-50 dark:bg-stone-900/50 flex justify-end gap-4 rounded-b-lg">
|
||
<button
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="px-6 py-3 text-xs uppercase tracking-widest text-stone-500 hover:text-black dark:hover:text-white transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
className="bg-black dark:bg-white text-white dark:text-black px-8 py-3 text-xs uppercase tracking-widest font-bold hover:opacity-90 transition-opacity shadow-lg"
|
||
>
|
||
{modalMode === 'add' ? 'Create Item' : 'Save Changes'}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Sidebar */}
|
||
<aside className="w-64 bg-white dark:bg-black border-r border-stone-200 dark:border-stone-800 flex flex-col pt-32 pb-6 px-6 fixed h-full z-10">
|
||
<Link to="/" className="font-display text-2xl mb-12 block hover:text-stone-500 transition-colors">
|
||
← Back to Site
|
||
</Link>
|
||
|
||
<nav className="space-y-2">
|
||
<button
|
||
onClick={() => setActiveTab('dashboard')}
|
||
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${activeTab === 'dashboard' ? 'bg-stone-100 dark:bg-stone-800 font-medium' : 'hover:bg-stone-50 dark:hover:bg-stone-900 text-stone-500'}`}
|
||
>
|
||
Dashboard
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('shop')}
|
||
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${activeTab === 'shop' ? 'bg-stone-100 dark:bg-stone-800 font-medium' : 'hover:bg-stone-50 dark:hover:bg-stone-900 text-stone-500'}`}
|
||
>
|
||
Shop Products
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('editorial')}
|
||
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${activeTab === 'editorial' ? 'bg-stone-100 dark:bg-stone-800 font-medium' : 'hover:bg-stone-50 dark:hover:bg-stone-900 text-stone-500'}`}
|
||
>
|
||
Editorial
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('orders')}
|
||
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${activeTab === 'orders' ? 'bg-stone-100 dark:bg-stone-800 font-medium' : 'hover:bg-stone-50 dark:hover:bg-stone-900 text-stone-500'}`}
|
||
>
|
||
Orders
|
||
</button>
|
||
</nav>
|
||
|
||
<div className="mt-auto text-xs text-stone-400">
|
||
Admin v0.2.0
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main Content */}
|
||
<main className="flex-1 ml-64 p-12 overflow-y-auto pt-32 h-full">
|
||
<div className="max-w-6xl mx-auto">
|
||
<h1 className="font-display text-4xl mb-8 capitalize">{activeTab}</h1>
|
||
|
||
{activeTab === 'dashboard' && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||
<div className="bg-white dark:bg-black p-8 rounded-sm shadow-sm border border-stone-100 dark:border-stone-800">
|
||
<h3 className="text-stone-500 uppercase tracking-widest text-xs mb-2">Total Products</h3>
|
||
<p className="font-display text-6xl">{products.length}</p>
|
||
</div>
|
||
<div className="bg-white dark:bg-black p-8 rounded-sm shadow-sm border border-stone-100 dark:border-stone-800">
|
||
<h3 className="text-stone-500 uppercase tracking-widest text-xs mb-2">Published Articles</h3>
|
||
<p className="font-display text-6xl">{articles.length}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'shop' && (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => openAddModal('shop')}
|
||
className="bg-black dark:bg-white text-white dark:text-black px-6 py-3 uppercase tracking-widest text-xs font-bold hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||
>
|
||
+ Add Product
|
||
</button>
|
||
</div>
|
||
<div className="bg-white dark:bg-black rounded-sm shadow-sm border border-stone-100 dark:border-stone-800 overflow-hidden">
|
||
<table className="w-full text-left">
|
||
<thead className="bg-stone-50 dark:bg-stone-900 text-xs uppercase tracking-widest text-stone-500">
|
||
<tr>
|
||
<th className="px-6 py-4">Product</th>
|
||
<th className="px-6 py-4">Price</th>
|
||
<th className="px-6 py-4 text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stone-100 dark:divide-stone-800">
|
||
{products.map(item => (
|
||
<tr key={item.id} className="hover:bg-stone-50 dark:hover:bg-stone-900 transition-colors">
|
||
<td className="px-6 py-4 flex items-center space-x-4">
|
||
<div className="w-12 h-12 bg-stone-100 rounded-sm overflow-hidden border border-stone-200 dark:border-stone-800">
|
||
<img src={item.image} alt="" className="w-full h-full object-cover" />
|
||
</div>
|
||
<span className="font-medium">{item.title}</span>
|
||
</td>
|
||
<td className="px-6 py-4 font-light">${item.price}</td>
|
||
<td className="px-6 py-4 text-right space-x-4">
|
||
<button
|
||
onClick={() => openEditModal('shop', item)}
|
||
className="text-xs uppercase tracking-wider text-stone-400 hover:text-black dark:hover:text-white transition-colors"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => { if (confirm('Delete this product?')) deleteProduct(item.id); }}
|
||
className="text-xs uppercase tracking-wider text-red-300 hover:text-red-500 transition-colors"
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'editorial' && (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => openAddModal('editorial')}
|
||
className="bg-black dark:bg-white text-white dark:text-black px-6 py-3 uppercase tracking-widest text-xs font-bold hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||
>
|
||
+ Add Article
|
||
</button>
|
||
</div>
|
||
<div className="bg-white dark:bg-black rounded-sm shadow-sm border border-stone-100 dark:border-stone-800 overflow-hidden">
|
||
<table className="w-full text-left">
|
||
<thead className="bg-stone-50 dark:bg-stone-900 text-xs uppercase tracking-widest text-stone-500">
|
||
<tr>
|
||
<th className="px-6 py-4">Status</th>
|
||
<th className="px-6 py-4">Title</th>
|
||
<th className="px-6 py-4">Date</th>
|
||
<th className="px-6 py-4 text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stone-100 dark:divide-stone-800">
|
||
{articles.map(post => (
|
||
<tr key={post.id} className="hover:bg-stone-50 dark:hover:bg-stone-900 transition-colors">
|
||
<td className="px-6 py-4"><span className="inline-block w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]"></span></td>
|
||
<td className="px-6 py-4 font-medium">
|
||
<div className="flex items-center space-x-2">
|
||
{post.isFeatured && <span className="text-yellow-500 text-xs">★</span>}
|
||
<span>{post.title}</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-stone-500 text-sm">{post.date}</td>
|
||
<td className="px-6 py-4 text-right space-x-4">
|
||
{!post.isFeatured && (
|
||
<button
|
||
onClick={() => updateArticle({ ...post, isFeatured: true })}
|
||
className="text-xs uppercase tracking-wider text-yellow-600 hover:text-yellow-700 transition-colors font-medium"
|
||
>
|
||
Feature
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => openEditModal('editorial', post)}
|
||
className="text-xs uppercase tracking-wider text-stone-400 hover:text-black dark:hover:text-white transition-colors"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => { if (confirm('Delete this article?')) deleteArticle(post.id); }}
|
||
className="text-xs uppercase tracking-wider text-red-300 hover:text-red-500 transition-colors"
|
||
>
|
||
Delete
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{activeTab === 'orders' && (
|
||
<div className="space-y-6">
|
||
<div className="bg-white dark:bg-black rounded-sm shadow-sm border border-stone-100 dark:border-stone-800 overflow-hidden">
|
||
<table className="w-full text-left">
|
||
<thead className="bg-stone-50 dark:bg-stone-900 text-xs uppercase tracking-widest text-stone-500">
|
||
<tr>
|
||
<th className="px-6 py-4">Order ID</th>
|
||
<th className="px-6 py-4">Customer</th>
|
||
<th className="px-6 py-4 text-center">Total</th>
|
||
<th className="px-6 py-4 text-center">Status</th>
|
||
<th className="px-6 py-4 text-center">Date</th>
|
||
<th className="px-6 py-4 text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stone-100 dark:divide-stone-800">
|
||
{orders.map(order => (
|
||
<tr key={order.id} className="hover:bg-stone-50 dark:hover:bg-stone-900 transition-colors">
|
||
<td className="px-6 py-4 font-medium">#{order.id}</td>
|
||
<td className="px-6 py-4 font-light">{order.customer_name}</td>
|
||
<td className="px-6 py-4 font-light text-center">${order.total_amount}</td>
|
||
<td className="px-6 py-4 text-center">
|
||
<span className={`px-2 py-1 text-[10px] uppercase font-bold tracking-tighter rounded-sm ${order.shipping_status === 'shipped' ? 'bg-blue-100 text-blue-600' : order.shipping_status === 'delivered' ? 'bg-green-100 text-green-600' : 'bg-stone-100 text-stone-600'}`}>
|
||
{order.shipping_status}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-stone-400 font-light text-center">{new Date(order.created_at).toLocaleDateString()}</td>
|
||
<td className="px-6 py-4 text-right">
|
||
<button
|
||
onClick={() => setSelectedOrder(order)}
|
||
className="text-stone-400 hover:text-black dark:hover:text-white transition-colors"
|
||
>
|
||
<span className="material-symbols-outlined text-base">visibility</span>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{orders.length === 0 && (
|
||
<tr>
|
||
<td colSpan={6} className="px-6 py-24 text-center text-stone-400 font-light">No orders found.</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<AnimatePresence>
|
||
{selectedOrder && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={() => setSelectedOrder(null)}
|
||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||
/>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
||
className="bg-white dark:bg-stone-900 w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-lg shadow-2xl relative z-10 flex flex-col"
|
||
>
|
||
<div className="p-8 border-b border-stone-100 dark:border-stone-800 flex justify-between items-center sticky top-0 bg-white dark:bg-stone-900 z-20">
|
||
<div>
|
||
<span className="text-xs font-bold uppercase tracking-widest text-stone-400">Order #{selectedOrder.id}</span>
|
||
<h2 className="font-display text-3xl text-text-main dark:text-white mt-1">Fulfillment Details</h2>
|
||
</div>
|
||
<button onClick={() => setSelectedOrder(null)} className="p-2 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors">
|
||
<span className="material-symbols-outlined text-stone-500">close</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-8 flex-1 grid grid-cols-1 md:grid-cols-2 gap-12">
|
||
<div className="space-y-8">
|
||
<section>
|
||
<h3 className="text-[10px] uppercase font-bold tracking-widest text-stone-400 mb-4">Customer Info</h3>
|
||
<div className="space-y-1 text-sm font-light">
|
||
<p className="font-medium">{selectedOrder.customer_name}</p>
|
||
<p>{selectedOrder.customer_email}</p>
|
||
<p className="pt-2">{selectedOrder.shipping_address.address}</p>
|
||
<p>{selectedOrder.shipping_address.city}, {selectedOrder.shipping_address.postalCode}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="text-[10px] uppercase font-bold tracking-widest text-stone-400 mb-4">Items Summary</h3>
|
||
<div className="space-y-4">
|
||
{selectedOrder.items.map((item: any, idx: number) => (
|
||
<div key={idx} className="flex justify-between text-sm">
|
||
<span className="font-light">{item.title} x {item.quantity}</span>
|
||
<span className="font-medium">${(item.price * item.quantity).toFixed(2)}</span>
|
||
</div>
|
||
))}
|
||
<div className="pt-4 border-t border-stone-100 dark:border-stone-800 flex justify-between items-center font-display text-2xl">
|
||
<span>Total</span>
|
||
<span>${selectedOrder.total_amount}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="space-y-8">
|
||
<section>
|
||
<h3 className="text-[10px] uppercase font-bold tracking-widest text-stone-400 mb-4">Manage Fulfillment</h3>
|
||
<div className="grid grid-cols-1 gap-2">
|
||
{['pending', 'shipped', 'delivered'].map((status) => (
|
||
<button
|
||
key={status}
|
||
onClick={() => updateOrderStatus(selectedOrder.id, status)}
|
||
className={`w-full py-4 text-[10px] uppercase tracking-widest font-bold border transition-all ${selectedOrder.shipping_status === status ? 'bg-black text-white dark:bg-white dark:text-black border-transparent' : 'bg-transparent border-stone-200 dark:border-stone-800 text-stone-400 hover:border-stone-400'}`}
|
||
>
|
||
Mark as {status}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="bg-stone-50 dark:bg-stone-900/50 p-6 rounded-sm">
|
||
<h3 className="text-[10px] uppercase font-bold tracking-widest text-stone-400 mb-2">Internal Note</h3>
|
||
<p className="text-xs text-stone-500 leading-relaxed italic">Payment confirmed via mock provider. Order is ready for processing.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Admin;
|