Shop integration
|
|
@ -6,8 +6,22 @@ build/
|
|||
|
||||
# Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
input/
|
||||
output/
|
||||
|
||||
# Distribution / Packaging
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg
|
||||
.eggs/
|
||||
|
||||
# System
|
||||
.DS_Store
|
||||
|
|
@ -15,3 +29,4 @@ Thumbs.db
|
|||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -2,31 +2,51 @@ import React, { Suspense, lazy } from 'react';
|
|||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import Cart from './components/Cart';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import RouteTransition from './components/RouteTransition';
|
||||
import { StoreProvider } from './src/context/StoreContext';
|
||||
|
||||
// Lazy load pages for better performance
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Collections = lazy(() => import('./pages/Collections'));
|
||||
const Atelier = lazy(() => import('./pages/Atelier'));
|
||||
const Editorial = lazy(() => import('./pages/Editorial'));
|
||||
const ProductPhotography = lazy(() => import('./pages/Journal/ProductPhotography'));
|
||||
const PackagingGuide = lazy(() => import('./pages/Journal/PackagingGuide'));
|
||||
const MotivationInClay = lazy(() => import('./pages/Journal/MotivationInClay'));
|
||||
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
|
||||
const ArticleDetail = lazy(() => import('./pages/ArticleDetail'));
|
||||
const Checkout = lazy(() => import('./pages/Checkout'));
|
||||
const MockPayment = lazy(() => import('./pages/MockPayment'));
|
||||
const Success = lazy(() => import('./pages/Success'));
|
||||
const Admin = lazy(() => import('./pages/Admin'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<StoreProvider>
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
<Cart />
|
||||
<RouteTransition>
|
||||
<Suspense fallback={<div className="h-screen w-full bg-stone-100 dark:bg-stone-900" />}>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/collections" element={<Collections />} />
|
||||
<Route path="/collections/:slug" element={<ProductDetail />} />
|
||||
<Route path="/atelier" element={<Atelier />} />
|
||||
<Route path="/editorial" element={<Editorial />} />
|
||||
<Route path="/editorial/:slug" element={<ArticleDetail />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/mock-payment" element={<MockPayment />} />
|
||||
<Route path="/success" element={<Success />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</RouteTransition>
|
||||
<Footer />
|
||||
</StoreProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
interface BlogPostLayoutProps {
|
||||
title: string;
|
||||
category: string;
|
||||
date: string;
|
||||
image: string;
|
||||
imageAlt: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BlogPostLayout: React.FC<BlogPostLayoutProps> = ({
|
||||
title,
|
||||
category,
|
||||
date,
|
||||
image,
|
||||
imageAlt,
|
||||
children,
|
||||
}) => {
|
||||
const { articles } = useStore();
|
||||
// Scroll to top on mount
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
const nextArticles = articles.filter(post => post.title !== title).slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="bg-stone-50 dark:bg-black min-h-screen font-body transition-colors duration-500">
|
||||
|
||||
<main className="pt-32 pb-24">
|
||||
{/* Article Header */}
|
||||
<article className="max-w-4xl mx-auto px-6 md:px-12">
|
||||
<div className="flex items-center space-x-4 mb-8 justify-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-text-muted border border-text-muted/30 px-3 py-1 rounded-full">{category}</span>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-text-muted">{date}</span>
|
||||
</div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="font-display text-5xl md:text-6xl lg:text-7xl text-center text-text-main dark:text-white mb-16 leading-tight"
|
||||
>
|
||||
{title}
|
||||
</motion.h1>
|
||||
|
||||
{/* Hero Image */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="w-full h-[40vh] md:h-[50vh] relative mb-16 overflow-hidden shadow-xl rounded-sm"
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="prose prose-stone dark:prose-invert max-w-none mx-auto prose-headings:font-display prose-headings:font-light prose-p:font-light prose-p:leading-loose prose-a:text-terracotta hover:prose-a:text-terracotta-dark prose-img:rounded-sm">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Read Next Section */}
|
||||
{nextArticles.length > 0 && (
|
||||
<div className="mt-24 pt-16 border-t border-stone-200 dark:border-stone-800">
|
||||
<h3 className="font-display text-3xl text-center mb-12 text-text-main dark:text-white">Read Next</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{nextArticles.map((post) => (
|
||||
<Link key={post.id} to={`/editorial/${post.slug}`} className="group block">
|
||||
<div className="aspect-[3/2] overflow-hidden bg-stone-100 mb-4">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<h4 className="font-display text-2xl text-text-main dark:text-white group-hover:underline decoration-1 underline-offset-4 mb-2">
|
||||
{post.title}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 text-sm text-stone-500 uppercase tracking-widest">
|
||||
<span>{post.category}</span>
|
||||
<span>—</span>
|
||||
<span>{post.date}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back Link */}
|
||||
<div className="mt-20 text-center">
|
||||
<Link to="/editorial" className="inline-block border-b border-black dark:border-white pb-1 text-sm uppercase tracking-widest hover:text-stone-500 transition-colors">
|
||||
Back to Editorial
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogPostLayout;
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const Cart: React.FC = () => {
|
||||
const { cart, isCartOpen, setCartOpen, removeFromCart, updateQuantity } = useStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const subtotal = cart.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
|
||||
const handleCheckout = () => {
|
||||
setCartOpen(false);
|
||||
navigate('/checkout');
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isCartOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setCartOpen(false)}
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-[60]"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="fixed top-0 right-0 h-full w-full max-w-md bg-white dark:bg-stone-950 z-[70] shadow-2xl flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-stone-100 dark:border-stone-900 flex justify-between items-center">
|
||||
<h2 className="font-display text-2xl uppercase tracking-widest text-text-main dark:text-white">Your Bag</h2>
|
||||
<button
|
||||
onClick={() => setCartOpen(false)}
|
||||
className="p-2 hover:bg-stone-50 dark:hover:bg-stone-900 rounded-full transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-stone-500">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="flex-1 overflow-y-auto p-8 space-y-8">
|
||||
{cart.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-stone-400 space-y-4">
|
||||
<span className="material-symbols-outlined text-4xl font-light">shopping_bag</span>
|
||||
<p className="font-light tracking-wide uppercase text-xs">Your bag is empty</p>
|
||||
<button
|
||||
onClick={() => setCartOpen(false)}
|
||||
className="text-text-main dark:text-white underline underline-offset-4 text-xs uppercase tracking-widest pt-4"
|
||||
>
|
||||
Start Shopping
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
cart.map((item) => (
|
||||
<div key={item.id} className="flex gap-6 group">
|
||||
<div className="w-24 aspect-[4/5] bg-stone-100 dark:bg-stone-900 overflow-hidden rounded-sm flex-shrink-0">
|
||||
<img src={item.image} alt={item.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between py-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-display text-lg text-text-main dark:text-white">{item.title}</h3>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Ref. {item.number}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="text-stone-300 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div className="flex items-center border border-stone-100 dark:border-stone-800 rounded-sm">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
className="px-2 py-1 hover:bg-stone-50 dark:hover:bg-stone-900 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xs">remove</span>
|
||||
</button>
|
||||
<span className="px-3 text-xs font-medium w-8 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="px-2 py-1 hover:bg-stone-50 dark:hover:bg-stone-900 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xs">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-light text-text-main dark:text-white">${(item.price * item.quantity).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{cart.length > 0 && (
|
||||
<div className="p-8 border-t border-stone-100 dark:border-stone-900 space-y-6">
|
||||
<div className="flex justify-between items-center bg-stone-50 dark:bg-stone-900/50 p-6 rounded-sm">
|
||||
<span className="text-xs uppercase tracking-widest text-stone-500">Subtotal</span>
|
||||
<span className="text-xl font-display text-text-main dark:text-white">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
type="button"
|
||||
className="w-full bg-black dark:bg-white text-white dark:text-black py-5 uppercase tracking-[0.3em] text-xs font-bold hover:opacity-90 transition-opacity shadow-xl"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
<p className="text-[10px] text-center text-stone-400 uppercase tracking-widest italic">
|
||||
Shipping & taxes calculated at checkout
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cart;
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { useScrollFadeIn } from '../hooks/useScrollAnimations';
|
||||
|
||||
interface FAQItemProps {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const FAQItem: React.FC<FAQItemProps> = ({ question, answer }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-stone-200 dark:border-stone-700">
|
||||
<button
|
||||
className="w-full py-6 flex justify-between items-center text-left focus:outline-none"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<h3 className="text-lg font-display text-text-main dark:text-white">{question}</h3>
|
||||
<span className={`transform transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-500 ease-in-out ${isOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
>
|
||||
<p className="pb-6 text-text-muted dark:text-gray-400 font-body leading-relaxed max-w-2xl">
|
||||
{answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FAQ: React.FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useScrollFadeIn(containerRef);
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "Do you ship your ceramics internationally?",
|
||||
answer: "Currently, we ship our handmade pottery mainly within Texas and the United States. We occasionally open international shipping spots for specific drops. Sign up for our newsletter to be notified."
|
||||
},
|
||||
{
|
||||
question: "Are your pieces dishwasher and microwave safe?",
|
||||
answer: "Yes! Our functional stoneware, including mugs, plates, and bowls, is fired to cone 6 oxidation, making it durable for daily use. However, hand washing is always recommended to prolong the life of your unique handmade ceramics."
|
||||
},
|
||||
{
|
||||
question: "Where is your studio located?",
|
||||
answer: "Our studio is based in the heart of Corpus Christi, Texas. We take inspiration from the Gulf Coast landscape. We offer local pickup for our Corpus Christi neighbors!"
|
||||
},
|
||||
{
|
||||
question: "Do you offer pottery classes in Corpus Christi?",
|
||||
answer: "We are working on bringing intimate wheel-throwing workshops to our Corpus Christi studio soon. Check our 'Atelier' page or follow us on Instagram for announcements."
|
||||
},
|
||||
{
|
||||
question: "Do you take custom orders or commissions?",
|
||||
answer: "We accept a limited number of custom dinnerware commissions each year. If you are looking for a bespoke set for your home or restaurant, please contact us directly."
|
||||
},
|
||||
{
|
||||
question: "How often do you restock the shop?",
|
||||
answer: "We work in small batches and typically release a new 'Sandstone' or 'Seafoam' collection every 4-6 weeks. Join our email list to get early access to the next kiln opening."
|
||||
},
|
||||
{
|
||||
question: "What clay bodies and glazes do you use?",
|
||||
answer: "We use a proprietary blend of stoneware clay that mimics the texture of Texas limestone. Our glazes are formulated in-house to reflect colors of the sea and sand."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="py-24 px-6 md:px-20 bg-stone-50 dark:bg-stone-900/50">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<span className="block font-body text-xs uppercase tracking-[0.2em] text-text-muted mb-8">
|
||||
Common Questions
|
||||
</span>
|
||||
<h2 className="font-display text-4xl md:text-5xl text-text-main dark:text-white mb-16">
|
||||
Studio FAQ
|
||||
</h2>
|
||||
<div className="flex flex-col">
|
||||
{faqs.map((faq, index) => (
|
||||
<FAQItem key={index} question={faq.question} answer={faq.answer} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQ;
|
||||
|
|
@ -10,7 +10,7 @@ const Footer: React.FC = () => {
|
|||
<div className="lg:col-span-5 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<h2 className="font-display text-6xl md:text-8xl leading-none tracking-tighter mb-8 bg-gradient-to-br from-white to-stone-400 bg-clip-text text-transparent">
|
||||
HOTCHPOTSH
|
||||
HOTSCHPOTSH
|
||||
</h2>
|
||||
<p className="font-body text-lg font-light text-stone-400 leading-relaxed max-w-md">
|
||||
Handcrafted ceramics for the modern home. Created with intention, fired with patience, and delivered with care.
|
||||
|
|
@ -78,11 +78,12 @@ const Footer: React.FC = () => {
|
|||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-white/10 pt-12 flex flex-col md:flex-row justify-between items-center text-xs text-stone-500 tracking-widest uppercase font-light">
|
||||
<p>© 2024 HOTCHPOTSH Ceramics. All rights reserved.</p>
|
||||
<p>© 2024 HOTSCHPOTSH Ceramics. All rights reserved.</p>
|
||||
<div className="flex space-x-8 mt-6 md:mt-0">
|
||||
<a className="hover:text-white transition-colors" href="#">Privacy</a>
|
||||
<a className="hover:text-white transition-colors" href="#">Terms</a>
|
||||
<a className="hover:text-white transition-colors" href="#">Cookies</a>
|
||||
<a className="hover:text-white transition-colors" href="/admin">Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||
import { NAV_ITEMS } from '../constants';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { cart, setCartOpen } = useStore();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
|
|
@ -38,7 +40,7 @@ const Header: React.FC = () => {
|
|||
{/* Logo */}
|
||||
<div className="flex-shrink-0 relative group cursor-pointer">
|
||||
<Link className="font-display text-4xl md:text-5xl font-light tracking-widest uppercase text-text-main dark:text-white" to="/">
|
||||
HOTCHPOTSH
|
||||
HOTSCHPOTSH
|
||||
</Link>
|
||||
{/* Subtle glow effect on hover */}
|
||||
<div className="absolute -inset-4 bg-white/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-full" />
|
||||
|
|
@ -64,10 +66,18 @@ const Header: React.FC = () => {
|
|||
<button className="hover:scale-110 transition-transform duration-300 hidden sm:block p-2">
|
||||
<span className="material-symbols-outlined text-xl font-light">search</span>
|
||||
</button>
|
||||
<a className="hover:scale-110 transition-transform duration-300 relative group p-2" href="#">
|
||||
<button
|
||||
onClick={() => setCartOpen(true)}
|
||||
className="hover:scale-110 transition-transform duration-300 relative group p-2"
|
||||
type="button"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl font-light">shopping_bag</span>
|
||||
<span className="absolute top-0 right-0 bg-black dark:bg-white text-white dark:text-black text-[9px] w-4 h-4 flex items-center justify-center rounded-full opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300">2</span>
|
||||
</a>
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-black dark:bg-white text-white dark:text-black text-[9px] w-4 h-4 flex items-center justify-center rounded-full opacity-100 scale-100 transition-all duration-300">
|
||||
{cart.reduce((total, item) => total + item.quantity, 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ const Hero: React.FC = () => {
|
|||
New Collection 2024
|
||||
</span>
|
||||
<h1 className="font-display text-6xl md:text-7xl lg:text-8xl xl:text-9xl text-text-main dark:text-white font-thin leading-[0.9] mb-10">
|
||||
Form <br /><span className="italic pl-12 md:pl-20 text-text-muted">of</span> Earth
|
||||
Earth <br /><span className="italic pl-12 md:pl-20 text-text-muted">of</span> Ocean
|
||||
</h1>
|
||||
<p className="font-body text-text-muted dark:text-gray-400 text-sm md:text-base font-light mb-12 max-w-sm leading-loose ml-1">
|
||||
Discover the imperfect perfection of hand-thrown stoneware. Pieces that bring silence and intention to your daily rituals.
|
||||
Handcrafted ceramics from the Texas Coast. Functional art inspired by the raw textures and colors of Corpus Christi. Small batch, slow-made.
|
||||
</p>
|
||||
<div className="ml-1">
|
||||
<a className="inline-block border-b border-text-main dark:border-white pb-1 text-text-main dark:text-white font-body text-xs uppercase tracking-[0.2em] hover:text-text-muted transition-colors duration-300" href="#">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { JOURNAL_ENTRIES } from '../constants';
|
||||
|
||||
const JournalSection: React.FC = () => {
|
||||
|
|
@ -13,12 +14,13 @@ const JournalSection: React.FC = () => {
|
|||
</div>
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12 relative z-10">
|
||||
<div className="flex justify-between items-baseline mb-20 border-b border-text-main/20 dark:border-gray-800 pb-6">
|
||||
<h2 className="font-display text-6xl font-thin text-text-main dark:text-white">The <span className="italic">Journal</span></h2>
|
||||
<a className="text-xs uppercase tracking-[0.2em] text-text-main dark:text-white hover:text-text-muted transition-colors" href="#">View Archive</a>
|
||||
<h2 className="font-display text-6xl font-thin text-text-main dark:text-white">Editorial</h2>
|
||||
<Link className="text-xs uppercase tracking-[0.2em] text-text-main dark:text-white hover:text-text-muted transition-colors" to="/editorial">View Archive</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{JOURNAL_ENTRIES.map((entry) => (
|
||||
<article key={entry.id} className={`group cursor-pointer ${entry.marginTop ? 'lg:mt-20' : ''}`}>
|
||||
<Link key={entry.id} to={entry.slug} className={`group cursor-pointer block ${entry.marginTop ? 'lg:mt-20' : ''}`}>
|
||||
<article>
|
||||
<div className="relative h-[500px] overflow-hidden mb-8 shadow-md">
|
||||
<img
|
||||
alt={entry.title}
|
||||
|
|
@ -40,6 +42,7 @@ const JournalSection: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,43 +10,67 @@ export const COLLECTIONS: CollectionItem[] = [
|
|||
{
|
||||
id: 1,
|
||||
title: 'Tableware',
|
||||
slug: 'tableware-set',
|
||||
number: '01',
|
||||
image: '/collection-tableware.png',
|
||||
images: ['/collection-tableware.png', '/pottery-plates.png', '/ceramic-cups.png'],
|
||||
price: 185,
|
||||
description: 'A complete hand-thrown tableware set for four. Finished in our signature matte white glaze with raw clay rims.',
|
||||
aspectRatio: 'aspect-[3/4]',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lighting',
|
||||
slug: 'ceramic-lighting',
|
||||
number: '04',
|
||||
image: '/collection-lighting.png',
|
||||
images: ['/collection-lighting.png', '/pottery-studio.png', '/collection-vases.png'],
|
||||
price: 240,
|
||||
description: 'Sculptural ceramic pendant lights that bring warmth and texture to any space. Each piece is unique.',
|
||||
aspectRatio: 'aspect-[4/3]',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Vases',
|
||||
slug: 'organic-vases',
|
||||
number: '02',
|
||||
image: '/collection-vases.png',
|
||||
images: ['/collection-vases.png', '/pottery-vase.png', '/collection-lighting.png'],
|
||||
price: 95,
|
||||
description: 'Organic forms inspired by the dunes of Padre Island. Perfect for dried stems or fresh bouquets.',
|
||||
aspectRatio: 'aspect-square',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Serving',
|
||||
slug: 'serving-bowls',
|
||||
number: '05',
|
||||
image: '/pottery-bowls.png',
|
||||
images: ['/pottery-bowls.png', '/collection-kitchenware.png', '/pottery-plates.png'],
|
||||
price: 120,
|
||||
description: 'Large, shallow serving bowls designed for communal gatherings. Durable and dishwasher safe.',
|
||||
aspectRatio: 'aspect-[3/4]',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Kitchenware',
|
||||
slug: 'kitchen-essentials',
|
||||
number: '03',
|
||||
image: '/collection-kitchenware.png',
|
||||
images: ['/collection-kitchenware.png', '/ceramic-cups.png', '/pottery-bowls.png'],
|
||||
price: 65,
|
||||
description: 'Everyday essentials including berry bowls, spoon rests, and utensil holders. Functional beauty.',
|
||||
aspectRatio: 'aspect-[3/5]',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Textiles',
|
||||
slug: 'linen-textiles',
|
||||
number: '06',
|
||||
image: '/pottery-plates.png',
|
||||
images: ['/pottery-plates.png', '/collection-tableware.png', '/collection-vases.png'],
|
||||
price: 45,
|
||||
description: 'Natural linen napkins and runners that perfectly complement our stoneware ceramics.',
|
||||
aspectRatio: 'aspect-square',
|
||||
},
|
||||
];
|
||||
|
|
@ -57,6 +81,7 @@ export const JOURNAL_ENTRIES: JournalEntry[] = [
|
|||
category: 'Studio',
|
||||
date: 'Oct 03',
|
||||
title: 'Product Photography for Small Businesses',
|
||||
slug: '/editorial/product-photography-for-small-businesses',
|
||||
description: "Learning that beautiful products aren't enough on their own — you also need beautiful photos to tell the story.",
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAipMlYLTcRT_hdc3VePfFIlrA56VzZ5G2y3gcRfmIZMERwGFKq2N19Gqo6mw7uZowXmjl2eJ89TI3Mcud2OyOfadO3mPVF_v0sI0OHupqM49WEFcWzH-Wbu3DL6bQ46F2Y8SIAk-NUQy8psjcIdBKRrM8fqdn4eOPANYTXpVxkLMAm4R0Axy4aEKNdmj917ZKKTxvXB-J8nGlITJkJ-ua7XcZOwGnfK5ttzyWW35A0oOSffCf972gmpV27wrVQgYJNLS7UyDdyQIQ',
|
||||
},
|
||||
|
|
@ -65,6 +90,7 @@ export const JOURNAL_ENTRIES: JournalEntry[] = [
|
|||
category: 'Guide',
|
||||
date: 'Jul 15',
|
||||
title: 'The Art of Packaging',
|
||||
slug: '/editorial/the-art-of-packaging',
|
||||
description: "A practical guide for potters who want to package and send their handmade ceramics with care and confidence.",
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAaWGnX_NYT3S_lOflL2NJZGbWge4AAkvra4ymvF8ag-c1UKsOAIB-rsLVQXW5xIlPZipDiK8-ysPyv22xdgsvzs4EOXSSCcrT4Lb2YCe0u5orxRaZEA5TgxeoKq15zaWKSlmnHyPGjPd_7yglpfO13eZmbU5KaxFJ1KGO0UAxoO9BpsyCYgbgINMoSz3epGe5ZdwBWRH-5KCzjoLuXimFTLcd5bqg9T1YofTxgy2hWBMJzKkafyEniq8dP6hMmfNCLVcCHHHx0hRU',
|
||||
marginTop: true,
|
||||
|
|
@ -74,6 +100,7 @@ export const JOURNAL_ENTRIES: JournalEntry[] = [
|
|||
category: 'Wellness',
|
||||
date: 'Jun 11',
|
||||
title: 'Finding Motivation in Clay',
|
||||
slug: '/editorial/finding-motivation-in-clay',
|
||||
description: "10 gentle, practical tips to help potters find motivation during slow or uncertain moments in the creative process.",
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuB8NOE5fGfN4d87cbcB27_Sh-nrlZlqxsTlYKbCZk98SoL-gHsPSWFNuxd1DxBq0g8Qysh0RBZ_btu-_WaH68UjV8SXPUalyxREvUqao4oXmra--pWAsaooWwKvWCzReYZ8kj7G-KIYIAo5mqudzB8n9C6-HVTNPPx9QgZHr_YsojMxlmmVcQ5bqk7-Lp0KtSAiVIPD2-1UE1dMGnkVSLUXKdgA65JIh8M3TtNkaJTGONuFKoTERrYOWe7u2BILnqyukTzlNcvK7Sc',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>HOTCHPOTSH Ceramics - Editorial Collection</title>
|
||||
<title>HOTSCHPOTSH Ceramics - Editorial Collection</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=Manrope:wght@200;300;400;500&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,795 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
import BlogPostLayout from '../components/BlogPostLayout';
|
||||
|
||||
const ArticleDetail: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { articles } = useStore();
|
||||
|
||||
const article = articles.find(a => a.slug === slug);
|
||||
|
||||
if (!article) {
|
||||
// You might want to show a 404 or just redirect back
|
||||
return <Navigate to="/editorial" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BlogPostLayout
|
||||
title={article.title}
|
||||
category={article.category || 'Studio Life'}
|
||||
date={article.date}
|
||||
image={article.image}
|
||||
imageAlt={article.title}
|
||||
>
|
||||
<div className="space-y-12">
|
||||
{article.sections && article.sections.length > 0 ? (
|
||||
article.sections.map((section: any) => (
|
||||
<div key={section.id}>
|
||||
{section.type === 'text' ? (
|
||||
<p className="mb-6 leading-relaxed text-lg font-light text-stone-600 dark:text-stone-300">
|
||||
{section.content}
|
||||
</p>
|
||||
) : (
|
||||
<div className="my-12">
|
||||
<img
|
||||
src={section.content}
|
||||
alt="Article detail"
|
||||
className="w-full shadow-lg rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="italic text-stone-400">No content available for this article yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</BlogPostLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetail;
|
||||
|
|
@ -30,7 +30,7 @@ const Atelier: React.FC = () => {
|
|||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
className="font-body text-lg font-light text-stone-500 leading-relaxed max-w-lg"
|
||||
>
|
||||
Our atelier is a sanctuary of slow creation. Located in the quiet hills, we practice the ancient art of wheel-throwing, honoring the raw beauty of natural clay.
|
||||
Our atelier is a sanctuary of slow creation. Located in the heart of Corpus Christi, we practice the ancient art of wheel-throwing, honoring the raw beauty of the Texas Coast.
|
||||
</motion.p>
|
||||
</div>
|
||||
<div className="md:col-span-12 lg:col-span-6 relative h-[600px] lg:h-[800px] w-full">
|
||||
|
|
@ -40,7 +40,7 @@ const Atelier: React.FC = () => {
|
|||
transition={{ delay: 0.2, duration: 1.5, ease: "easeOut" }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<img src="/pottery-studio.png" alt="Atelier Studio" className="w-full h-full object-cover" />
|
||||
<img src="/pottery-studio.png" alt="Pottery Studio in Corpus Christi" className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -48,9 +48,9 @@ const Atelier: React.FC = () => {
|
|||
{/* Philosophy Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 border-t border-stone-200 dark:border-stone-800 pt-24">
|
||||
{[
|
||||
{ title: "Material", text: "We work exclusively with locally sourced stoneware clay bodies, rich in iron and character." },
|
||||
{ title: "Process", text: "Every piece is wheel-thrown, trimmed, and glazed by hand, ensuring no two objects are identical." },
|
||||
{ title: "Function", text: "Designed to be used and loved. Our ceramics are durable, food-safe, and meant for daily rituals." }
|
||||
{ title: "Coastal Clay", text: "We work with stoneware clay bodies that reflect the sandy textures of the Gulf Coast." },
|
||||
{ title: "Electric Firing", text: "Fired in oxidation to cone 6, creating durable surfaces that mimic the bleached colors of driftwood and shell." },
|
||||
{ title: "Functional Art", text: "Designed to be used and loved. Our ceramics are durable, dishwasher safe, and meant for daily coastal living." }
|
||||
].map((item, idx) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const Checkout: React.FC = () => {
|
||||
const { cart } = useStore();
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postalCode: ''
|
||||
});
|
||||
|
||||
const subtotal = cart.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
const shipping = subtotal > 150 ? 0 : 15;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const handleProceed = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const orderData = {
|
||||
customer_email: formData.email,
|
||||
customer_name: `${formData.firstName} ${formData.lastName}`,
|
||||
shipping_address: {
|
||||
address: formData.address,
|
||||
city: formData.city,
|
||||
postalCode: formData.postalCode
|
||||
},
|
||||
items: cart.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
quantity: item.quantity,
|
||||
price: item.price
|
||||
})),
|
||||
total_amount: total
|
||||
};
|
||||
|
||||
navigate('/mock-payment', { state: { orderData } });
|
||||
};
|
||||
|
||||
if (cart.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen pt-48 flex flex-col items-center justify-center text-text-main dark:text-white px-6">
|
||||
<h2 className="font-display text-4xl mb-8">Your bag is empty</h2>
|
||||
<Link to="/collections" className="text-xs uppercase tracking-widest underline underline-offset-8 hover:text-stone-500 transition-colors">
|
||||
View Collections
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-stone-950 min-h-screen pt-32 pb-24">
|
||||
<div className="max-w-[1400px] mx-auto px-6 md:px-12">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-display text-5xl md:text-7xl font-light text-text-main dark:text-white mb-16"
|
||||
>
|
||||
Checkout
|
||||
</motion.h1>
|
||||
|
||||
<form onSubmit={handleProceed} className="grid grid-cols-1 lg:grid-cols-12 gap-16 xl:gap-24">
|
||||
{/* Order Summary Form */}
|
||||
<div className="lg:col-span-7 space-y-12">
|
||||
<section>
|
||||
<h3 className="text-xs uppercase tracking-[0.3em] text-stone-400 mb-8 border-b border-stone-100 dark:border-stone-900 pb-4">Contact Information</h3>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<input
|
||||
required
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email Address"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs uppercase tracking-[0.3em] text-stone-400 mb-8 border-b border-stone-100 dark:border-stone-900 pb-4">Shipping Address</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<input required name="firstName" type="text" placeholder="First Name" value={formData.firstName} onChange={handleInputChange} className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400" />
|
||||
<input required name="lastName" type="text" placeholder="Last Name" value={formData.lastName} onChange={handleInputChange} className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400" />
|
||||
<div className="md:col-span-2">
|
||||
<input required name="address" type="text" placeholder="Address" value={formData.address} onChange={handleInputChange} className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400" />
|
||||
</div>
|
||||
<input required name="city" type="text" placeholder="City" value={formData.city} onChange={handleInputChange} className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400" />
|
||||
<input required name="postalCode" type="text" placeholder="Postal Code" value={formData.postalCode} onChange={handleInputChange} className="w-full bg-stone-50 dark:bg-stone-900 border border-stone-200 dark:border-stone-800 p-4 text-sm focus:outline-none focus:border-stone-400" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" className="w-full bg-black dark:bg-white text-white dark:text-black py-5 uppercase tracking-[0.3em] text-xs font-bold hover:opacity-90 transition-opacity flex items-center justify-center gap-4 shadow-xl">
|
||||
Proceed to Payment
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Preview Sidebar */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-32 bg-stone-50 dark:bg-stone-900/40 p-8 md:p-12 rounded-sm border border-stone-100 dark:border-stone-900">
|
||||
<h3 className="text-xs uppercase tracking-[0.3em] text-stone-400 mb-8">In your bag</h3>
|
||||
|
||||
<div className="space-y-8 mb-12 max-h-96 overflow-y-auto pr-4 custom-scrollbar">
|
||||
{cart.map((item) => (
|
||||
<div key={item.id} className="flex gap-6">
|
||||
<div className="w-20 aspect-[4/5] bg-stone-200 dark:bg-stone-800 flex-shrink-0 overflow-hidden">
|
||||
<img src={item.image} alt={item.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="font-display text-lg text-text-main dark:text-white">{item.title}</h4>
|
||||
<p className="text-xs text-stone-500 uppercase tracking-widest">Qty: {item.quantity}</p>
|
||||
<p className="text-sm font-light pt-2">${(item.price * item.quantity).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-8 border-t border-stone-200 dark:border-stone-800">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500 font-light">Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500 font-light">Shipping</span>
|
||||
<span>{shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-display pt-4">
|
||||
<span>Total</span>
|
||||
<span className="text-text-main dark:text-white">${total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkout;
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { COLLECTIONS } from '../constants';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const { products } = useStore();
|
||||
return (
|
||||
<>
|
||||
<section className="pt-32 pb-24 px-6 md:px-12 bg-stone-50 dark:bg-stone-900 min-h-screen">
|
||||
<div className="max-w-[1920px] mx-auto">
|
||||
{/* Header */}
|
||||
|
|
@ -15,7 +16,7 @@ const Collections: React.FC = () => {
|
|||
transition={{ delay: 0.5, duration: 0.8 }}
|
||||
className="font-display text-5xl md:text-7xl font-light mb-6 text-text-main dark:text-white"
|
||||
>
|
||||
Collections
|
||||
Shop Collection
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
|
|
@ -23,45 +24,49 @@ const Collections: React.FC = () => {
|
|||
transition={{ delay: 0.7, duration: 0.8 }}
|
||||
className="font-body text-stone-500 max-w-xl mx-auto text-lg font-light leading-relaxed"
|
||||
>
|
||||
Curated series of functional objects. Each collection explores a distinct form language and glaze palette.
|
||||
Curated series of functional objects. From our 'Sandstone' mugs to 'Seafoam' vases, each collection celebrates the palette of the Texas coast.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
|
||||
{COLLECTIONS.map((item, index) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-16 lg:gap-x-16 px-4">
|
||||
{products.map((collection, index) => (
|
||||
<Link to={`/collections/${collection.slug}`} key={collection.id} className="block group cursor-pointer">
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 + (index * 0.1), duration: 0.8, ease: "easeOut" }}
|
||||
className="group cursor-pointer"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
{/* Image Container with Darker Background for Contrast */}
|
||||
<div className={`relative overflow-hidden mb-6 ${item.aspectRatio || 'aspect-[3/4]'} bg-stone-200 dark:bg-stone-800`}>
|
||||
<motion.img
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105 relative z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
<div className="relative overflow-hidden mb-6 aspect-[4/5] bg-stone-100">
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors duration-500 z-10" />
|
||||
<img
|
||||
src={collection.image}
|
||||
alt={collection.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
|
||||
/>
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500 z-20 pointer-events-none" />
|
||||
{/* Quick overlay info */}
|
||||
<div className="absolute bottom-6 left-6 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span className="bg-white dark:bg-black px-4 py-2 text-xs uppercase tracking-widest text-text-main dark:text-white">View Item</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end border-b border-stone-200 dark:border-stone-800 pb-4">
|
||||
<div className="flex justify-between items-baseline pr-2">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest text-stone-400 mb-1 block">{item.number}</span>
|
||||
<h3 className="font-display text-2xl text-text-main dark:text-white">{item.title}</h3>
|
||||
<h2 className="font-display text-3xl font-light text-text-main dark:text-white mb-1 group-hover:underline decoration-1 underline-offset-4">
|
||||
{collection.title}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="material-symbols-outlined opacity-0 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 text-stone-400">arrow_forward</span>
|
||||
<span className="text-lg font-light text-text-main dark:text-white">
|
||||
${collection.price}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,95 +1,105 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { JOURNAL_ENTRIES } from '../constants';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const Editorial: React.FC = () => {
|
||||
const { articles, isLoading } = useStore();
|
||||
|
||||
if (isLoading) return <div className="min-h-screen flex items-center justify-center pt-24 font-light text-stone-400">Loading Journal...</div>;
|
||||
if (!articles || articles.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-black min-h-screen pt-32 pb-24">
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
<div className="text-center mb-24">
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="font-display text-xs tracking-[0.3em] uppercase mb-4 block text-stone-400"
|
||||
>
|
||||
The Journal
|
||||
</motion.span>
|
||||
<motion.h1
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="font-display text-6xl md:text-9xl font-light text-text-main dark:text-white"
|
||||
>
|
||||
Editorial
|
||||
</motion.h1>
|
||||
<div className="bg-white dark:bg-black min-h-screen pt-32 pb-24 text-center">
|
||||
<h1 className="font-display text-4xl mb-8">Editorial</h1>
|
||||
<p className="font-body text-stone-500">No stories yet. Stay tuned!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Featured Article */}
|
||||
<div className="relative w-full h-[70vh] mb-24 cursor-pointer group overflow-hidden">
|
||||
// Sort: Featured first, then rest
|
||||
const featuredArticle = articles.find(a => a.isFeatured) || articles[0];
|
||||
const otherArticles = articles.filter(a => a.id !== featuredArticle.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-32 pb-24 bg-white dark:bg-stone-950 transition-colors duration-500">
|
||||
{/* Featured Post */}
|
||||
<section className="px-6 mb-32">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<Link to={`/editorial/${featuredArticle.slug}`} className="group block">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
<div className="lg:col-span-8 overflow-hidden rounded-sm aspect-[16/9]">
|
||||
<motion.img
|
||||
initial={{ scale: 1.1 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
src={JOURNAL_ENTRIES[0].image}
|
||||
alt="Featured Article"
|
||||
className="w-full h-full object-cover transition-transform duration-[2s] group-hover:scale-105"
|
||||
initial={{ scale: 1.1, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
viewport={{ once: true }}
|
||||
src={featuredArticle.image}
|
||||
alt={featuredArticle.title}
|
||||
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-700"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors duration-500" />
|
||||
<div className="absolute bottom-0 left-0 p-8 md:p-16 text-white w-full md:w-2/3">
|
||||
<span className="uppercase tracking-widest text-xs border border-white/30 px-3 py-1 mb-6 inline-block backdrop-blur-sm">Featured Story</span>
|
||||
<h2 className="font-display text-4xl md:text-6xl mb-6 leading-tight">{JOURNAL_ENTRIES[0].title}</h2>
|
||||
<p className="font-body text-lg md:text-xl font-light opacity-90 max-w-xl">{JOURNAL_ENTRIES[0].description}</p>
|
||||
<div className="mt-8 flex items-center space-x-2 text-xs uppercase tracking-widest">
|
||||
<span>Read Article</span>
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</div>
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-stone-400">{featuredArticle.category}</span>
|
||||
<span className="w-8 h-[1px] bg-stone-200"></span>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-stone-400">{featuredArticle.date}</span>
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-light leading-tight tracking-tight text-stone-900 dark:text-stone-100">
|
||||
{featuredArticle.title}
|
||||
</h2>
|
||||
<p className="text-stone-500 font-light leading-relaxed max-w-md">
|
||||
{featuredArticle.description}
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<span className="inline-block text-[10px] uppercase tracking-[0.3em] font-medium border-b border-stone-200 pb-2 group-hover:border-stone-900 transition-colors">
|
||||
Read Story
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Article Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-20 max-w-5xl mx-auto">
|
||||
{JOURNAL_ENTRIES.slice(1).map((entry, idx) => (
|
||||
{/* Other Articles Grid */}
|
||||
<section className="px-6">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
|
||||
{otherArticles.map((entry, idx) => (
|
||||
<Link key={entry.id} to={`/editorial/${entry.slug}`} className="group block">
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.2 }}
|
||||
className="group cursor-pointer"
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden mb-8">
|
||||
<img src={entry.image} alt={entry.title} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
|
||||
<div className="aspect-[4/3] overflow-hidden rounded-sm">
|
||||
<img
|
||||
src={entry.image}
|
||||
alt={entry.title}
|
||||
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mb-4 text-xs uppercase tracking-widest text-stone-400">
|
||||
<span>{entry.category}</span>
|
||||
<span className="w-1 h-1 bg-stone-300 rounded-full" />
|
||||
<span>{entry.date}</span>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-stone-400">{entry.category}</span>
|
||||
<span className="w-4 h-[1px] bg-stone-200"></span>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-stone-400">{entry.date}</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-light text-stone-900 dark:text-stone-100 group-hover:text-stone-500 transition-colors">
|
||||
{entry.title}
|
||||
</h3>
|
||||
<p className="text-stone-500 font-light text-sm leading-relaxed line-clamp-2">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="font-display text-3xl mb-4 text-text-main dark:text-white group-hover:underline decoration-1 underline-offset-4">{entry.title}</h3>
|
||||
<p className="font-body font-light text-stone-500">{entry.description}</p>
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
{/* Dummy extra entry to fill grid */}
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<div className="aspect-[4/3] overflow-hidden mb-8 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
||||
<img src="/collection-tableware.png" alt="Archive" className="w-full h-full object-cover opacity-80 transition-transform duration-700 group-hover:scale-105" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mb-4 text-xs uppercase tracking-widest text-stone-400">
|
||||
<span>Archive</span>
|
||||
<span className="w-1 h-1 bg-stone-300 rounded-full" />
|
||||
<span>2023</span>
|
||||
</div>
|
||||
<h3 className="font-display text-3xl mb-4 text-text-main dark:text-white group-hover:underline decoration-1 underline-offset-4">Explore Past Issues</h3>
|
||||
<p className="font-body font-light text-stone-500">Dive into our archive of stories, guides, and studio updates.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import QuoteSection from '../components/QuoteSection';
|
|||
import JournalSection from '../components/JournalSection';
|
||||
import GallerySection from '../components/GallerySection';
|
||||
|
||||
import FAQ from '../components/FAQ';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
return (
|
||||
<main>
|
||||
|
|
@ -14,9 +16,10 @@ const Home: React.FC = () => {
|
|||
<FeatureSection />
|
||||
<HorizontalScrollSection />
|
||||
<Collections />
|
||||
<QuoteSection />
|
||||
<JournalSection />
|
||||
<GallerySection />
|
||||
<JournalSection />
|
||||
<QuoteSection />
|
||||
<FAQ />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import BlogPostLayout from '../../components/BlogPostLayout';
|
||||
|
||||
const MotivationInClay: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
document.title = "Creative Block for Potters: 10 Tips for Motivation | Hotchpotsh";
|
||||
let meta = document.querySelector('meta[name="description"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'description');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', 'Overcoming Creative Block for Potters is possible. Use these 10 gentle, practical tips to rediscover your motivation and love for clay. Read more now.');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BlogPostLayout
|
||||
title="Creative Block for Potters: 10 Tips for Motivation"
|
||||
category="Wellness"
|
||||
date="Jun 11"
|
||||
image="https://lh3.googleusercontent.com/aida-public/AB6AXuB8NOE5fGfN4d87cbcB27_Sh-nrlZlqxsTlYKbCZk98SoL-gHsPSWFNuxd1DxBq0g8Qysh0RBZ_btu-_WaH68UjV8SXPUalyxREvUqao4oXmra--pWAsaooWwKvWCzReYZ8kj7G-KIYIAo5mqudzB8n9C6-HVTNPPx9QgZHr_YsojMxlmmVcQ5bqk7-Lp0KtSAiVIPD2-1UE1dMGnkVSLUXKdgA65JIh8M3TtNkaJTGONuFKoTERrYOWe7u2BILnqyukTzlNcvK7Sc"
|
||||
imageAlt="Creative Block for Potters tips"
|
||||
>
|
||||
<p className="lead text-xl text-stone-600 dark:text-stone-300 italic mb-8">
|
||||
Dealing with <strong>Creative Block for Potters</strong> (and finding new <strong>Pottery Inspiration</strong>) is a common struggle in the studio. Where the physical labor is intense and the failure rate is high, burnout is real. Whether you are facing general exhaustion or a specific artistic wall, know that this season is part of the cycle.
|
||||
</p>
|
||||
|
||||
<p className="mb-6">
|
||||
Here is how to overcome <strong>Creative Block for Potters</strong> and find your flow again.
|
||||
</p>
|
||||
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1459156212016-c812468e2115?q=80&w=2574&auto=format&fit=crop"
|
||||
alt="Creative Block for Potters guide"
|
||||
className="w-full my-12 shadow-lg"
|
||||
/>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">1. Play without Purpose</h2>
|
||||
<p className="mb-6">
|
||||
Stop making <Link to="/collections">Collections</Link>. Stop thinking about what will sell. Grab a lump of clay and just <em>pinch</em>. When you remove the pressure, you often solve the <strong>Creative Block for Potters</strong> naturally.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">2. Switch Your Technique</h2>
|
||||
<p className="mb-6">
|
||||
If you are a wheel thrower, try <strong>hand building</strong>. Changing your physical movements can unlock new neural pathways.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">3. The "100 Pattern" Challenge</h2>
|
||||
<p className="mb-6">
|
||||
Commit to making 100 small test tiles. Constraints actually breed creativity.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">4. Clean Your Studio (Reset)</h2>
|
||||
<p className="mb-6">
|
||||
A cluttered space leads to a cluttered mind. Spend a day organizing your <Link to="/atelier">Atelier</Link>. A fresh, clean bat on the wheel is an invitation.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">5. Look Outside of Pottery</h2>
|
||||
<p className="mb-6">
|
||||
Don't look at other potters on Instagram. That leads to comparison. instead, look at:
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Architecture</strong>: for structural shapes.</li>
|
||||
<li><strong>Nature</strong>: for textures (tree bark, river stones).</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">6. Take a Class</h2>
|
||||
<p className="mb-6">
|
||||
Even masters are students. Taking a workshop puts you back in the "beginner's mind," which is a fertile place for ideas.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">7. Revisit Your "Why"</h2>
|
||||
<p className="mb-6">
|
||||
Look at the very first pot you ever kept. Reconnecting with your origin story can fuel your current practice.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">8. Limit Your Time</h2>
|
||||
<p className="mb-6">
|
||||
Tell yourself, "I will only work for 20 minutes." Often, the hardest part is just starting.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">9. Embrace functionality</h2>
|
||||
<p className="mb-6">
|
||||
Make something you <em>need</em>. A spoon rest. A soap dish. Solving a simple, functional problem is a great way to handle <strong>Creative Block for Potters</strong>.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">10. Rest</h2>
|
||||
<p className="mb-6">
|
||||
Sometimes, the block isn't mental; it's physical. Take a week off. The clay will be there when you get back.
|
||||
</p>
|
||||
</BlogPostLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MotivationInClay;
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import BlogPostLayout from '../../components/BlogPostLayout';
|
||||
|
||||
const PackagingGuide: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
document.title = "How to Package Pottery for Shipping: A Safe Guide | Hotchpotsh";
|
||||
let meta = document.querySelector('meta[name="description"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'description');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', 'Learn how to package pottery for shipping safely. Use our double-box method and sustainable tips to ensure your handmade ceramics arrive intact. Read now.');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BlogPostLayout
|
||||
title="How to Package Pottery for Shipping"
|
||||
category="Guide"
|
||||
date="Jul 15"
|
||||
image="https://lh3.googleusercontent.com/aida-public/AB6AXuAaWGnX_NYT3S_lOflL2NJZGbWge4AAkvra4ymvF8ag-c1UKsOAIB-rsLVQXW5xIlPZipDiK8-ysPyv22xdgsvzs4EOXSSCcrT4Lb2YCe0u5orxRaZEA5TgxeoKq15zaWKSlmnHyPGjPd_7yglpfO13eZmbU5KaxFJ1KGO0UAxoO9BpsyCYgbgINMoSz3epGe5ZdwBWRH-5KCzjoLuXimFTLcd5bqg9T1YofTxgy2hWBMJzKkafyEniq8dP6hMmfNCLVcCHHHx0hRU"
|
||||
imageAlt="How to Package Pottery for Shipping Safely guide"
|
||||
>
|
||||
<p className="lead text-xl text-stone-600 dark:text-stone-300 italic mb-8">
|
||||
<strong>How to Package Pottery for Shipping</strong> safely is the most important skill for a small business owner. There is nothing more heartbreaking than a shattered creation, so mastering this art form ensures your hard work survives the journey.
|
||||
</p>
|
||||
|
||||
<p className="mb-6">
|
||||
Here is your comprehensive guide on shipping handmade art so it arrives safely every single time.
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">1. The Double-Box Method (The Golden Rule)</h2>
|
||||
<p className="mb-6">
|
||||
When considering safe delivery—especially for large items—the <strong>double-box method</strong> is the industry standard.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Inner Box</strong>: Wrap your <Link to="/collections">Collections</Link> piece and place it in a small box. It should fit snugly.</li>
|
||||
<li><strong>Outer Box</strong>: Place the small box inside a larger shipping box, with at least 2 inches of padding on all sides.</li>
|
||||
<li><em>Why?</em> The outer box absorbs the shock, keeping your art safe.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">2. Wrapping Materials: Layers Matter</h2>
|
||||
<p className="mb-6">
|
||||
Don't rely on just one material when you plan your packing strategy.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Layer 1: Tissue Paper</strong>: Protects the glaze.</li>
|
||||
<li><strong>Layer 2: Bubble Wrap</strong>: The workhorse. Wrap the piece <em>tightly</em> in small-bubble wrap.</li>
|
||||
<li><strong>The Shake Test</strong>: Shake the box hard. If you hear movement, add tougher filler.</li>
|
||||
</ul>
|
||||
|
||||
<div className="my-16">
|
||||
<img
|
||||
src="/assets/images/packaging_guide.png"
|
||||
alt="Sustainable pottery packaging materials including honeycomb paper and packing peanuts"
|
||||
className="w-full shadow-lg rounded-sm"
|
||||
/>
|
||||
<p className="text-sm text-center text-stone-500 mt-4 italic">Eco-friendly packaging materials ready for use.</p>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">3. Sustainable Packaging Alternatives</h2>
|
||||
<p className="mb-6">
|
||||
Many customers value sustainability in our <Link to="/atelier">Atelier</Link>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Honeycomb Paper</strong>: A biodegradable alternative.</li>
|
||||
<li><strong>Corn Starch Peanuts</strong>: Dissolve in water.</li>
|
||||
<li><strong>Cardboard Scraps</strong>: Excellent dense filler.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">4. Branding Your Unboxing Experience</h2>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>The "Thank You" Note</strong>: Builds a connection.</li>
|
||||
<li><strong>Care Instructions</strong>: Explain microwave/dishwasher safety.</li>
|
||||
<li><strong>Stickers</strong>: Build anticipation.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">5. Insurance and labeling</h2>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Fragile Stickers</strong>: Helpful, but not a guarantee.</li>
|
||||
<li><strong>Shipping Insurance</strong>: Always pay the extra few dollars for peace of mind.</li>
|
||||
</ul>
|
||||
</BlogPostLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackagingGuide;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import BlogPostLayout from '../../components/BlogPostLayout';
|
||||
// Wait, I don't know if react-helmet is installed. Checking package.json... it was not.
|
||||
// I will adhere to the "no new dependencies" rule unless necessary. I'll just render the meta tags usually, but without Helmet they won't lift to head.
|
||||
// The user asked for "Meta Title" and "Meta Description" implementation. I will add a helper to update document.title.
|
||||
|
||||
const ProductPhotography: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
document.title = "Product Photography for Pottery: Tips for Sales | Hotchpotsh";
|
||||
// Simple meta description update for basic SPA
|
||||
let meta = document.querySelector('meta[name="description"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'description');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', 'Master Product Photography for Pottery with our DIY guide. Learn lighting and styling tips to boost your handmade ceramic sales online. Read more now.');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BlogPostLayout
|
||||
title="Product Photography for Pottery"
|
||||
category="Studio"
|
||||
date="Oct 03"
|
||||
image="https://lh3.googleusercontent.com/aida-public/AB6AXuAipMlYLTcRT_hdc3VePfFIlrA56VzZ5G2y3gcRfmIZMERwGFKq2N19Gqo6mw7uZowXmjl2eJ89TI3Mcud2OyOfadO3mPVF_v0sI0OHupqM49WEFcWzH-Wbu3DL6bQ46F2Y8SIAk-NUQy8psjcIdBKRrM8fqdn4eOPANYTXpVxkLMAm4R0Axy4aEKNdmj917ZKKTxvXB-J8nGlITJkJ-ua7XcZOwGnfK5ttzyWW35A0oOSffCf972gmpV27wrVQgYJNLS7UyDdyQIQ"
|
||||
imageAlt="DIY Product Photography for Pottery setup with natural light"
|
||||
>
|
||||
<p className="lead text-xl text-stone-600 dark:text-stone-300 italic mb-8">
|
||||
Mastering <strong>Product Photography for Pottery</strong> is essential because in the world of handmade business, your work is only as good as the photo that represents it. Since customers can't touch your mugs online, your photos must bridge the gap between browsing and buying.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Here is how to elevate your <strong>Product Photography for Pottery</strong> without expensive gear.
|
||||
</p>
|
||||
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1516483638261-f4dbaf036963?q=80&w=2574&auto=format&fit=crop"
|
||||
alt="Product Photography for Pottery setup"
|
||||
title="DIY Setup"
|
||||
className="w-full my-12 shadow-lg"
|
||||
/>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">1. Treasure the Natural Light</h2>
|
||||
<p className="mb-6">
|
||||
Lighting is the single most critical element of successful <strong>Product Photography for Pottery</strong>. Avoid the harsh, yellow glow of indoor lamps. Instead, set up your "studio" next to a large North or South-facing window, similar to the natural light in our <Link to="/atelier">Atelier</Link>.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Diffused Light is Best</strong>: If the sun is beaming directly in, tape a sheet of white parchment paper over the window. This creates soft shadows that highlight the curves of your <strong>ceramic vessels</strong> without blinding glare.</li>
|
||||
<li><strong>The Golden Hour</strong>: For lifestyle shots, try shooting during the hour after sunrise or before sunset for a warm, magical glow.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">2. Master the "Hero Shot"</h2>
|
||||
<p className="mb-6">
|
||||
Every listing needs a clear shot. When mastering <strong>Product Photography for Pottery</strong>, the "Hero Shot" usually requires a clean background for your <Link to="/collections">Collections</Link>.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>The Infinite Curve</strong>: Use a large sheet of white poster board. Tape one end to the wall and let it curve gently down onto the table. This seamless background eliminates distracting horizon lines.</li>
|
||||
<li><strong>Tripod Stability</strong>: Blurry photos are a dealbreaker. If you don't have a tripod, prop your phone up against a stack of books.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">3. Tell a Story with Props</h2>
|
||||
<p className="mb-6">
|
||||
While a clean background shows the details, lifestyle **Product Photography for Pottery** sells the <em>dream</em>.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Context matters</strong>: Don't just show a mug; show it steaming with coffee next to a half-read book.</li>
|
||||
<li><strong>Keep it subtle</strong>: Your props should never compete with your work. Neutral linens complement the vibrant <strong>glaze colors</strong> of your <Link to="/collections">Collections</Link>.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">4. Angles & Details</h2>
|
||||
<p className="mb-6">
|
||||
Don't stop at one angle. Online buyers need to see everything.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>The Eye-Level Shot</strong>: Perfect for showing the profile of a vase.</li>
|
||||
<li><strong>The Top-Down Shot</strong>: Ideal for plates and bowls.</li>
|
||||
<li><strong>The Detail Macro</strong>: Get close. Show the texture of the raw clay body.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-8 text-3xl">5. Editing: Less is More</h2>
|
||||
<p className="mb-6">
|
||||
You don't need Photoshop. Free apps like <strong>Snapseed</strong> or <strong>Lightroom Mobile</strong> are powerful tools for editing <strong>Product Photography for Pottery</strong>.
|
||||
</p>
|
||||
<ul className="mb-12 space-y-4">
|
||||
<li><strong>Correction, not Alteration</strong>: Adjust brightness, contrast, and white balance.</li>
|
||||
<li><strong>True-to-Life Color</strong>: Be very careful not to over-saturate.</li>
|
||||
</ul>
|
||||
</BlogPostLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductPhotography;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const MockPayment: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { placeOrder, clearCart } = useStore();
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
|
||||
const orderData = location.state?.orderData;
|
||||
|
||||
const handleSimulatePayment = async () => {
|
||||
if (!orderData) return;
|
||||
|
||||
setIsSimulating(true);
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
await placeOrder(orderData);
|
||||
clearCart();
|
||||
navigate('/success');
|
||||
} catch (err) {
|
||||
alert('Payment simulation failed');
|
||||
setIsSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!orderData) {
|
||||
return (
|
||||
<div className="min-h-screen pt-48 flex flex-col items-center justify-center">
|
||||
<p>No order data found. Please go back to checkout.</p>
|
||||
<button onClick={() => navigate('/checkout')} className="mt-4 underline">Back to Checkout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-48 px-6">
|
||||
<div className="max-w-md mx-auto bg-white dark:bg-stone-900 p-12 rounded-sm shadow-xl border border-stone-100 dark:border-stone-800 text-center">
|
||||
<span className="material-symbols-outlined text-6xl text-stone-300 mb-8">account_balance_wallet</span>
|
||||
<h1 className="font-display text-3xl text-text-main dark:text-white mb-4">Payment Simulation</h1>
|
||||
<p className="text-stone-500 text-sm mb-12 leading-relaxed">
|
||||
This is a secure test environment. Click the button below to simulate a successful transaction of
|
||||
<span className="font-bold text-text-main dark:text-white ml-1">${orderData.total_amount.toFixed(2)}</span>.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleSimulatePayment}
|
||||
disabled={isSimulating}
|
||||
className="w-full bg-black dark:bg-white text-white dark:text-black py-5 uppercase tracking-[0.3em] text-xs font-bold hover:opacity-90 transition-opacity flex items-center justify-center gap-4"
|
||||
>
|
||||
{isSimulating ? (
|
||||
<>
|
||||
<span className="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Simulate Success'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/checkout')}
|
||||
disabled={isSimulating}
|
||||
className="mt-6 text-[10px] text-stone-400 uppercase tracking-widest hover:text-stone-600 transition-colors"
|
||||
>
|
||||
Cancel and go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MockPayment;
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import React from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useStore } from '../src/context/StoreContext';
|
||||
|
||||
const ProductDetail: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { products, addToCart } = useStore();
|
||||
const product = products.find(item => item.slug === slug);
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="min-h-screen pt-32 flex flex-col items-center justify-center text-text-main dark:text-white">
|
||||
<h2 className="text-4xl font-display mb-4">Product Not Found</h2>
|
||||
<Link to="/collections" className="underline hover:text-stone-500">Return to Shop</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-stone-950 min-h-screen pt-32 pb-24">
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-12 text-sm uppercase tracking-widest text-stone-500">
|
||||
<Link to="/collections" className="hover:text-text-main dark:hover:text-white transition-colors">Shop</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-text-main dark:text-white">{product.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 xl:gap-24">
|
||||
{/* Images Column */}
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="aspect-[4/5] w-full overflow-hidden bg-stone-100 rounded-sm"
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Additional images grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{product.images?.slice(1).map((img, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 * (idx + 1) }}
|
||||
className="aspect-square w-full overflow-hidden bg-stone-100 rounded-sm"
|
||||
>
|
||||
<img src={img} alt={`${product.title} detail ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Column */}
|
||||
<div className="lg:sticky lg:top-32 h-fit">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-5xl md:text-7xl font-light text-text-main dark:text-white mb-6"
|
||||
>
|
||||
{product.title}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-2xl font-light text-stone-600 dark:text-stone-300 mb-8"
|
||||
>
|
||||
${product.price}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="prose prose-stone dark:prose-invert max-w-none mb-12 font-light leading-relaxed text-lg"
|
||||
>
|
||||
<p>{product.description}</p>
|
||||
<ul>
|
||||
{product.details && product.details.map((detail, i) => (
|
||||
<li key={i}>{detail}</li>
|
||||
))}
|
||||
<li>Made in Corpus Christi, TX</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
onClick={() => addToCart(product)}
|
||||
type="button"
|
||||
className="w-full bg-text-main dark:bg-white text-white dark:text-text-main py-4 uppercase tracking-[0.2em] hover:bg-stone-800 dark:hover:bg-stone-200 transition-colors"
|
||||
>
|
||||
Add to Cart
|
||||
</motion.button>
|
||||
|
||||
<div className="mt-12 pt-12 border-t border-stone-200 dark:border-stone-800 text-sm text-stone-500 font-light space-y-2">
|
||||
<p>Free shipping on orders over $150</p>
|
||||
<p>Ships within 3-5 business days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Success: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen pt-48 pb-24 px-6 flex flex-col items-center text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-24 h-24 bg-stone-100 dark:bg-stone-900 rounded-full flex items-center justify-center mb-12"
|
||||
>
|
||||
<span className="material-symbols-outlined text-4xl text-text-main dark:text-white">check_circle</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="font-display text-5xl md:text-7xl font-light text-text-main dark:text-white mb-8"
|
||||
>
|
||||
Thank You
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="font-body text-lg font-light text-stone-500 max-w-md mb-16 leading-relaxed"
|
||||
>
|
||||
Your order has been placed successfully. We've sent a confirmation email with all the details of your purchase.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-black dark:bg-white text-white dark:text-black px-12 py-5 uppercase tracking-[0.3em] text-xs font-bold hover:opacity-90 transition-opacity inline-block shadow-xl"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Success;
|
||||
|
After Width: | Height: | Size: 761 KiB |
|
|
@ -0,0 +1,138 @@
|
|||
const { Client } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
async function setup() {
|
||||
// Connection config for the default 'postgres' database
|
||||
const config = {
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: 'postgres'
|
||||
};
|
||||
|
||||
const client = new Client(config);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('Connected to PostgreSQL default database.');
|
||||
|
||||
// Create database
|
||||
try {
|
||||
await client.query(`CREATE DATABASE ${process.env.DB_NAME || 'pottery_db'}`);
|
||||
console.log(`Database ${process.env.DB_NAME || 'pottery_db'} created.`);
|
||||
} catch (err) {
|
||||
if (err.code === '42P04') {
|
||||
console.log(`Database ${process.env.DB_NAME || 'pottery_db'} already exists.`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await client.end();
|
||||
|
||||
// Connect to the new database to create tables
|
||||
const dbClient = new Client({
|
||||
...config,
|
||||
database: process.env.DB_NAME || 'pottery_db'
|
||||
});
|
||||
|
||||
await dbClient.connect();
|
||||
console.log(`Connected to ${process.env.DB_NAME || 'pottery_db'}.`);
|
||||
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||
|
||||
await dbClient.query(schema);
|
||||
console.log('Tables created successfully.');
|
||||
|
||||
// --- SEED DATA ---
|
||||
console.log('Seeding initial data...');
|
||||
|
||||
const products = [
|
||||
{
|
||||
title: 'Tableware',
|
||||
price: 185,
|
||||
image: '/collection-tableware.png',
|
||||
description: 'A complete hand-thrown tableware set for four. Finished in our signature matte white glaze with raw clay rims.',
|
||||
gallery: ['/collection-tableware.png', '/pottery-plates.png', '/ceramic-cups.png'],
|
||||
slug: 'tableware-set',
|
||||
number: '01',
|
||||
aspect_ratio: 'aspect-[3/4]'
|
||||
},
|
||||
{
|
||||
title: 'Lighting',
|
||||
price: 240,
|
||||
image: '/collection-lighting.png',
|
||||
description: 'Sculptural ceramic pendant lights that bring warmth and texture to any space. Each piece is unique.',
|
||||
gallery: ['/collection-lighting.png', '/pottery-studio.png', '/collection-vases.png'],
|
||||
slug: 'ceramic-lighting',
|
||||
number: '04',
|
||||
aspect_ratio: 'aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
title: 'Vases',
|
||||
price: 95,
|
||||
image: '/collection-vases.png',
|
||||
description: 'Organic forms inspired by the dunes of Padre Island. Perfect for dried stems or fresh bouquets.',
|
||||
gallery: ['/collection-vases.png', '/pottery-vase.png', '/collection-lighting.png'],
|
||||
slug: 'organic-vases',
|
||||
number: '02',
|
||||
aspect_ratio: 'aspect-square'
|
||||
}
|
||||
];
|
||||
|
||||
for (const p of products) {
|
||||
await dbClient.query(
|
||||
'INSERT INTO products (title, price, image, description, gallery, slug, number, aspect_ratio) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING',
|
||||
[p.title, p.price, p.image, p.description, JSON.stringify(p.gallery), p.slug, p.number, p.aspect_ratio]
|
||||
);
|
||||
}
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: 'Product Photography for Small Businesses',
|
||||
date: 'Oct 03',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAipMlYLTcRT_hdc3VePfFIlrA56VzZ5G2y3gcRfmIZMERwGFKq2N19Gqo6mw7uZowXmjl2eJ89TI3Mcud2OyOfadO3mPVF_v0sI0OHupqM49WEFcWzH-Wbu3DL6bQ46F2Y8SIAk-NUQy8psjcIdBKRrM8fqdn4eOPANYTXpVxkLMAm4R0Axy4aEKNdmj917ZKKTxvXB-J8nGlITJkJ-ua7XcZOwGnfK5ttzyWW35A0oOSffCf972gmpV27wrVQgYJNLS7UyDdyQIQ',
|
||||
slug: 'product-photography-for-small-businesses',
|
||||
category: 'Studio',
|
||||
description: "Learning that beautiful products aren't enough on their own — you also need beautiful photos to tell the story.",
|
||||
sections: [
|
||||
{ id: '1', type: 'text', content: 'Mastering Product Photography for Pottery is essential because in the world of handmade business, your work is only as good as the photo that represents it.' },
|
||||
{ id: '2', type: 'image', content: 'https://images.unsplash.com/photo-1516483638261-f4dbaf036963?q=80&w=2574&auto=format&fit=crop' },
|
||||
{ id: '3', type: 'text', content: 'Lighting is the single most critical element of successful Product Photography for Pottery. Avoid the harsh, yellow glow of indoor lamps.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'The Art of Packaging',
|
||||
date: 'Jul 15',
|
||||
image: 'https://lh3.googleusercontent.com/aida-public/AB6AXuAaWGnX_NYT3S_lOflL2NJZGbWge4AAkvra4ymvF8ag-c1UKsOAIB-rsLVQXW5xIlPZipDiK8-rsLVQXW5xIlPZipDiK8-ysPyv22xdgsvzs4EOXSSCcrT4Lb2YCe0u5orxRaZEA5TgxeoKq15zaWKSlmnHyPGjPd_7yglpfO13eZmbU5KaxFJ1KGO0UAxoO9BpsyCYgbgINMoSz3epGe5ZdwBWRH-5KCzjoLuXimFTLcd5bqg9T1YofTxgy2hWBMJzKkafyEniq8dP6hMmfNCLVcCHHHx0hRU',
|
||||
slug: 'the-art-of-packaging',
|
||||
category: 'Guide',
|
||||
description: "A practical guide for potters who want to package and send their handmade ceramics with care and confidence.",
|
||||
sections: [
|
||||
{ id: '1', type: 'text', content: 'When considering safe delivery—especially for large items—the double-box method is the industry standard.' },
|
||||
{ id: '2', type: 'text', content: 'The Outer Box: Place the small box inside a larger shipping box, with at least 2 inches of padding on all sides.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
for (const a of articles) {
|
||||
await dbClient.query(
|
||||
'INSERT INTO articles (title, date, image, sections, slug, category, description) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING',
|
||||
[a.title, a.date, a.image, JSON.stringify(a.sections), a.slug, a.category, a.description]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Seeding complete.');
|
||||
await dbClient.end();
|
||||
console.log('Setup complete!');
|
||||
} catch (err) {
|
||||
console.error('Setup failed:', err);
|
||||
console.log('\nTIP: Make sure PostgreSQL is running and your password in .env is correct.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
const express = require('express');
|
||||
const { Pool } = require('pg');
|
||||
const cors = require('cors');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// Database Connection
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
// Routes
|
||||
|
||||
// --- PRODUCTS ---
|
||||
app.get('/api/products', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, title, price, image, description, gallery as images, slug, number, aspect_ratio as "aspectRatio", details FROM products ORDER BY id ASC');
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/products', async (req, res) => {
|
||||
const { title, price, image, description, images, slug, number, aspectRatio, details } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO products (title, price, image, description, gallery, slug, number, aspect_ratio, details) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, title, price, image, description, gallery as images, slug, number, aspect_ratio as "aspectRatio", details',
|
||||
[title, price, image, description, JSON.stringify(images), slug, number, aspectRatio, JSON.stringify(details || [])]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/products/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { title, price, image, description, images, slug, number, aspectRatio, details } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'UPDATE products SET title = $1, price = $2, image = $3, description = $4, gallery = $5, slug = $6, number = $7, aspect_ratio = $8, details = $9 WHERE id = $10 RETURNING id, title, price, image, description, gallery as images, slug, number, aspect_ratio as "aspectRatio", details',
|
||||
[title, price, image, description, JSON.stringify(images), slug, number, aspectRatio, JSON.stringify(details || []), id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/products/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
await pool.query('DELETE FROM products WHERE id = $1', [id]);
|
||||
res.json({ message: 'Product deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- ARTICLES ---
|
||||
app.get('/api/articles', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, title, date, image, sections, slug, category, description, is_featured as "isFeatured" FROM articles ORDER BY id ASC');
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/articles', async (req, res) => {
|
||||
const { title, date, image, sections, slug, category, description, isFeatured } = req.body;
|
||||
try {
|
||||
if (isFeatured) {
|
||||
await pool.query('UPDATE articles SET is_featured = FALSE');
|
||||
}
|
||||
const result = await pool.query(
|
||||
'INSERT INTO articles (title, date, image, sections, slug, category, description, is_featured) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, title, date, image, sections, slug, category, description, is_featured as "isFeatured"',
|
||||
[title, date, image, JSON.stringify(sections), slug, category, description, !!isFeatured]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/articles/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { title, date, image, sections, slug, category, description, isFeatured } = req.body;
|
||||
try {
|
||||
if (isFeatured) {
|
||||
await pool.query('UPDATE articles SET is_featured = FALSE');
|
||||
}
|
||||
const result = await pool.query(
|
||||
'UPDATE articles SET title = $1, date = $2, image = $3, sections = $4, slug = $5, category = $6, description = $7, is_featured = $8 WHERE id = $9 RETURNING id, title, date, image, sections, slug, category, description, is_featured as "isFeatured"',
|
||||
[title, date, image, JSON.stringify(sections), slug, category, description, !!isFeatured, id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/articles/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
await pool.query('DELETE FROM articles WHERE id = $1', [id]);
|
||||
res.json({ message: 'Article deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Orders API
|
||||
app.get('/api/orders', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM orders ORDER BY created_at DESC');
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orders', async (req, res) => {
|
||||
const { customer_email, customer_name, shipping_address, items, total_amount } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'INSERT INTO orders (customer_email, customer_name, shipping_address, items, total_amount, payment_status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
||||
[customer_email, customer_name, JSON.stringify(shipping_address), JSON.stringify(items), total_amount, 'paid']
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/orders/:id/status', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { shipping_status } = req.body;
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'UPDATE orders SET shipping_status = $1 WHERE id = $2 RETURNING *',
|
||||
[shipping_status, id]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
const sql = `
|
||||
-- Orders Table
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_name TEXT NOT NULL,
|
||||
shipping_address JSONB NOT NULL,
|
||||
items JSONB NOT NULL,
|
||||
total_amount DECIMAL(10, 2) NOT NULL,
|
||||
payment_status TEXT DEFAULT 'pending',
|
||||
shipping_status TEXT DEFAULT 'pending',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('Starting migration...');
|
||||
const client = await pool.connect();
|
||||
await client.query(sql);
|
||||
console.log('Migration successful: Orders table created or already exists.');
|
||||
client.release();
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- Create Database
|
||||
-- CREATE DATABASE pottery_db;
|
||||
|
||||
-- Products Table
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
price DECIMAL(10, 2) NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
description TEXT,
|
||||
gallery JSONB DEFAULT '[]',
|
||||
slug TEXT,
|
||||
number TEXT,
|
||||
aspect_ratio TEXT
|
||||
);
|
||||
|
||||
-- Articles Table
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date VARCHAR(50) NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
sections JSONB DEFAULT '[]',
|
||||
slug TEXT,
|
||||
category TEXT,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
-- Orders Table
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_name TEXT NOT NULL,
|
||||
shipping_address JSONB NOT NULL,
|
||||
items JSONB NOT NULL,
|
||||
total_amount DECIMAL(10, 2) NOT NULL,
|
||||
payment_status TEXT DEFAULT 'pending',
|
||||
shipping_status TEXT DEFAULT 'pending',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
|
||||
async function test() {
|
||||
try {
|
||||
const res = await pool.query('SELECT NOW()');
|
||||
console.log('Connection successful:', res.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Connection failed:', err);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { CollectionItem, JournalEntry } from '../../types';
|
||||
import { COLLECTIONS, JOURNAL_ENTRIES } from '../../constants';
|
||||
|
||||
export interface CartItem extends CollectionItem {
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
customer_email: string;
|
||||
customer_name: string;
|
||||
shipping_address: any;
|
||||
items: any[];
|
||||
total_amount: number;
|
||||
payment_status: string;
|
||||
shipping_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface StoreContextType {
|
||||
products: CollectionItem[];
|
||||
articles: JournalEntry[];
|
||||
cart: CartItem[];
|
||||
orders: Order[];
|
||||
isCartOpen: boolean;
|
||||
setCartOpen: (open: boolean) => void;
|
||||
addToCart: (product: CollectionItem) => void;
|
||||
removeFromCart: (productId: number) => void;
|
||||
updateQuantity: (productId: number, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
placeOrder: (orderData: Partial<Order>) => Promise<Order>;
|
||||
fetchOrders: () => Promise<void>;
|
||||
updateOrderStatus: (id: number, status: string) => Promise<void>;
|
||||
addProduct: (product: CollectionItem) => void;
|
||||
updateProduct: (product: CollectionItem) => void;
|
||||
deleteProduct: (id: number) => void;
|
||||
addArticle: (article: JournalEntry) => void;
|
||||
updateArticle: (article: JournalEntry) => void;
|
||||
deleteArticle: (id: number) => void;
|
||||
}
|
||||
|
||||
const StoreContext = createContext<StoreContextType | undefined>(undefined);
|
||||
|
||||
const API_URL = 'http://localhost:5000/api';
|
||||
|
||||
export const StoreProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [products, setProducts] = useState<CollectionItem[]>([]);
|
||||
const [articles, setArticles] = useState<JournalEntry[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [isCartOpen, setCartOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Persist cart (minimal data only)
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const minimalCart = cart.map(item => ({ id: item.id, quantity: item.quantity }));
|
||||
localStorage.setItem('cart', JSON.stringify(minimalCart));
|
||||
} catch (err) {
|
||||
console.error('Failed to save cart to localStorage:', err);
|
||||
}
|
||||
}, [cart]);
|
||||
|
||||
// Hydrate cart when products are loaded
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && products.length > 0) {
|
||||
const saved = localStorage.getItem('cart');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
// Handle both old format (full objects) and new format (minimal)
|
||||
const hydrated = parsed.map((m: any) => {
|
||||
const productId = typeof m === 'object' && m !== null ? (m.id || m.productId) : m;
|
||||
const product = products.find(p => p.id === productId);
|
||||
if (product) {
|
||||
return { ...product, quantity: m.quantity || 1 };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean) as CartItem[];
|
||||
setCart(hydrated);
|
||||
} catch (err) {
|
||||
console.error('Failed to hydrate cart:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isLoading, products]);
|
||||
|
||||
// Initial Fetch
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [prodRes, artRes] = await Promise.all([
|
||||
fetch(`${API_URL}/products`),
|
||||
fetch(`${API_URL}/articles`)
|
||||
]);
|
||||
const prods = await prodRes.json();
|
||||
const arts = await artRes.json();
|
||||
setProducts(prods);
|
||||
setArticles(arts);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data from backend, falling back to static data', err);
|
||||
setProducts(COLLECTIONS);
|
||||
setArticles(JOURNAL_ENTRIES);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Cart Actions
|
||||
const addToCart = (product: CollectionItem) => {
|
||||
setCart(prev => {
|
||||
const existing = prev.find(item => item.id === product.id);
|
||||
if (existing) {
|
||||
return prev.map(item =>
|
||||
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
|
||||
);
|
||||
}
|
||||
return [...prev, { ...product, quantity: 1 }];
|
||||
});
|
||||
setCartOpen(true);
|
||||
};
|
||||
|
||||
const removeFromCart = (productId: number) => {
|
||||
setCart(prev => prev.filter(item => item.id !== productId));
|
||||
};
|
||||
|
||||
const updateQuantity = (productId: number, quantity: number) => {
|
||||
if (quantity < 1) {
|
||||
removeFromCart(productId);
|
||||
return;
|
||||
}
|
||||
setCart(prev => prev.map(item =>
|
||||
item.id === productId ? { ...item, quantity } : item
|
||||
));
|
||||
};
|
||||
|
||||
const clearCart = () => setCart([]);
|
||||
|
||||
// Order Actions
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/orders`);
|
||||
const data = await res.json();
|
||||
setOrders(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch orders:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const placeOrder = async (orderData: Partial<Order>) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
const newOrder = await res.json();
|
||||
setOrders(prev => [newOrder, ...prev]);
|
||||
return newOrder;
|
||||
} catch (err) {
|
||||
console.error('Failed to place order:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrderStatus = async (id: number, status: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/orders/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shipping_status: status })
|
||||
});
|
||||
const updatedOrder = await res.json();
|
||||
setOrders(prev => prev.map(o => o.id === id ? updatedOrder : o));
|
||||
} catch (err) {
|
||||
console.error('Failed to update order status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const addProduct = async (product: CollectionItem) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/products`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(product)
|
||||
});
|
||||
const newProduct = await res.json();
|
||||
setProducts(prev => [...prev, newProduct]);
|
||||
} catch (err) {
|
||||
console.error('Failed to add product:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProduct = async (updatedProduct: CollectionItem) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/products/${updatedProduct.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedProduct)
|
||||
});
|
||||
const data = await res.json();
|
||||
setProducts(prev => prev.map(p => p.id === data.id ? data : p));
|
||||
} catch (err) {
|
||||
console.error('Failed to update product:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProduct = async (id: number) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/products/${id}`, { method: 'DELETE' });
|
||||
setProducts(prev => prev.filter(p => p.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete product:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const addArticle = async (article: JournalEntry) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/articles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(article)
|
||||
});
|
||||
const newArticle = await res.json();
|
||||
setArticles(prev => [...prev, newArticle]);
|
||||
} catch (err) {
|
||||
console.error('Failed to add article:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateArticle = async (updatedArticle: JournalEntry) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/articles/${updatedArticle.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedArticle)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// If the updated article is featured, we must ensure only this one is featured in our local state
|
||||
if (data.isFeatured) {
|
||||
setArticles(prev => prev.map(a => ({
|
||||
...a,
|
||||
isFeatured: a.id === data.id
|
||||
})));
|
||||
} else {
|
||||
setArticles(prev => prev.map(a => a.id === data.id ? data : a));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update article:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteArticle = async (id: number) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/articles/${id}`, { method: 'DELETE' });
|
||||
setArticles(prev => prev.filter(a => a.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete article:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={{
|
||||
products,
|
||||
articles,
|
||||
cart,
|
||||
orders,
|
||||
isCartOpen,
|
||||
setCartOpen,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
clearCart,
|
||||
placeOrder,
|
||||
fetchOrders,
|
||||
updateOrderStatus,
|
||||
addProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
addArticle,
|
||||
updateArticle,
|
||||
deleteArticle
|
||||
}}>
|
||||
{isLoading ? <div className="fixed inset-0 bg-white z-50 flex items-center justify-center font-display text-xl animate-pulse">Loading Store...</div> : children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStore = () => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useStore must be used within a StoreProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -6,20 +6,30 @@ export interface NavItem {
|
|||
export interface CollectionItem {
|
||||
id: number;
|
||||
title: string;
|
||||
number: string;
|
||||
price: number;
|
||||
image: string;
|
||||
aspectRatio: string; // Tailwind class like aspect-[3/4]
|
||||
gridClasses?: string; // Optional layout adjustments
|
||||
images: string[];
|
||||
description?: string;
|
||||
slug: string;
|
||||
number: string;
|
||||
aspectRatio: string;
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
id: number;
|
||||
category: string;
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
image: string;
|
||||
marginTop?: boolean;
|
||||
sections: {
|
||||
id: string;
|
||||
type: 'text' | 'image';
|
||||
content: string;
|
||||
}[];
|
||||
slug: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
|
||||
export interface FooterSection {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
|
||||
import base64
|
||||
import os
|
||||
|
||||
def get_base64_src(path, mime_type):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
return f"data:{mime_type};base64,{base64.b64encode(data).decode('utf-8')}"
|
||||
|
||||
try:
|
||||
# 1. Read Assets
|
||||
workshop_b64 = get_base64_src("workshop.jpg", "image/jpeg")
|
||||
depth_b64 = get_base64_src("workshop_depth.png", "image/png")
|
||||
vase_b64 = get_base64_src("pottery-vase.png", "image/png")
|
||||
try:
|
||||
vase_depth_b64 = get_base64_src("pottery-vase_depth.png", "image/png")
|
||||
except FileNotFoundError:
|
||||
print("Warning: pottery-vase_depth.png not found. Using placeholder or skipping.")
|
||||
vase_depth_b64 = depth_b64 # Fallback to something to avoid crash
|
||||
|
||||
# 2. Read HTML Template
|
||||
with open("index.html", "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
# Remove the protocol check block for the embedded version
|
||||
# because embedded base64 images work fine on file:// protocol
|
||||
import re
|
||||
html = re.sub(r'// CHECK PROTOCOL[\s\S]*?return;\s+?}', '', html)
|
||||
|
||||
# 3. Replace with Base64
|
||||
html = html.replace("workshop.jpg", workshop_b64)
|
||||
html = html.replace("workshop_depth.png", depth_b64)
|
||||
html = html.replace("pottery-vase.png", vase_b64)
|
||||
html = html.replace("pottery-vase_depth.png", vase_depth_b64)
|
||||
|
||||
# 4. Write new file
|
||||
with open("index_embedded.html", "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
|
||||
print("Successfully created index_embedded.html")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
def create_cylindrical_depth_map(image_path, output_path):
|
||||
# Load image
|
||||
img = Image.open(image_path).convert("RGBA")
|
||||
width, height = img.size
|
||||
|
||||
# Create a numpy array for the depth map
|
||||
# We want a gradient that is white in the center horizontal axis and black at the edges (cylindrical)
|
||||
# 0 = far (black), 255 = near (white)
|
||||
|
||||
# Generate X coordinates (0 to width)
|
||||
x = np.linspace(-1, 1, width)
|
||||
# Compute cylindrical depth: sqrt(1 - x^2) for a perfect cylinder, or just a cosine/parabolic falloff
|
||||
# Let's use cosine for smooth roundness: cos(x * pi / 2)
|
||||
depth_profile = np.cos(x * np.pi / 2) # Center (0) is 1, Edges (-1, 1) are 0
|
||||
|
||||
# Normalize to 0-255
|
||||
depth_profile = (depth_profile * 255).astype(np.uint8)
|
||||
|
||||
# Tile vertically to create the full map
|
||||
depth_map = np.tile(depth_profile, (height, 1))
|
||||
|
||||
# Create Image from array
|
||||
depth_img = Image.fromarray(depth_map, mode='L')
|
||||
|
||||
# MASKING: We only want the vase to have depth, the background (transparent) should be flat/far.
|
||||
# Use the alpha channel of the original image as a mask
|
||||
alpha = np.array(img.split()[-1])
|
||||
|
||||
# Where alpha is 0 (background), set depth to 0 (flat/far)
|
||||
depth_array = np.array(depth_img)
|
||||
depth_array[alpha < 10] = 0 # Threshold for transparency
|
||||
|
||||
# Save
|
||||
final_depth = Image.fromarray(depth_array)
|
||||
final_depth.save(output_path)
|
||||
print(f"Generated depth map: {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_cylindrical_depth_map("pottery-vase.png", "pottery-vase_depth.png")
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>3D Parallax Workshop Demo</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; overflow-x: hidden; background-color: #0f0f0f; font-family: sans-serif; color: white; }
|
||||
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 { font-size: 3rem; margin-bottom: 2rem; }
|
||||
p { max-width: 600px; line-height: 1.6; color: #aaa; }
|
||||
|
||||
.parallax-section {
|
||||
position: relative;
|
||||
height: 400vh; /* Scroll distance */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sticky-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#parallax-canvas {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.product-reveal {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
width: 60%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.product-reveal img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
#loader {
|
||||
position: fixed; inset: 0; background: #0f0f0f; z-index: 999;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="spacer">
|
||||
<h1>Scroll Down</h1>
|
||||
<p>Experience the journey from the workshop to the finished form.</p>
|
||||
</section>
|
||||
|
||||
<section class="parallax-section">
|
||||
<div class="sticky-wrapper">
|
||||
<canvas id="parallax-canvas"></canvas>
|
||||
<div class="product-reveal">
|
||||
<img src="pottery-vase.png" id="final-img">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="spacer">
|
||||
<h1>Collection 014</h1>
|
||||
<p>Every piece tells a story.</p>
|
||||
</section>
|
||||
|
||||
<div id="loader">Loading Assets...</div>
|
||||
|
||||
<script>
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
async function init() {
|
||||
const canvas = document.querySelector('#parallax-canvas');
|
||||
|
||||
// THREE SETUP
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// CHECK PROTOCOL
|
||||
if (window.location.protocol === 'file:') {
|
||||
const loader = document.getElementById('loader');
|
||||
loader.innerHTML = `
|
||||
<div style="text-align:center; padding: 2rem;">
|
||||
<h2 style="color: #ff6b6b">File Protocol Error</h2>
|
||||
<p>Browsers cannot load textures directly from local files due to security restrictions.</p>
|
||||
<p style="margin-top: 1rem; font-weight: bold; color: white;">Please open <a href="index_embedded.html" style="color: #4cd137">index_embedded.html</a> instead.</p>
|
||||
<p style="font-size: 0.8em; color: #888">Or use a local server (e.g. python -m http.server).</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// LOAD TEXTURES
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const loadTexture = (url) => new Promise((resolve, reject) => {
|
||||
textureLoader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
|
||||
try {
|
||||
// Load assets for both Workshop and Vase
|
||||
const [workshopTex, workshopDepth, vaseTex, vaseDepth] = await Promise.all([
|
||||
loadTexture('workshop.jpg'),
|
||||
loadTexture('workshop_depth.png'),
|
||||
loadTexture('pottery-vase.png'),
|
||||
loadTexture('pottery-vase_depth.png')
|
||||
]);
|
||||
|
||||
document.getElementById('loader').style.display = 'none';
|
||||
|
||||
// --- IMAGE CONSTANTS ---
|
||||
const WORKSHOP_ASPECT = workshopTex.image.width / workshopTex.image.height;
|
||||
const VASE_ASPECT = vaseTex.image.width / vaseTex.image.height;
|
||||
|
||||
// --- SHADER SETUP ---
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uDepth;
|
||||
uniform float uDepthScale;
|
||||
uniform vec2 uMouse;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
float depth = texture2D(uDepth, uv).r;
|
||||
vec3 pos = position;
|
||||
|
||||
// Z Displacement
|
||||
pos.z += depth * uDepthScale;
|
||||
|
||||
// Mouse Parallax (Low intensity)
|
||||
pos.x += (uMouse.x * depth * 0.02);
|
||||
pos.y += (uMouse.y * depth * 0.02);
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uImage;
|
||||
uniform float uOpacity;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(uImage, vUv);
|
||||
gl_FragColor = vec4(color.rgb, color.a * uOpacity);
|
||||
}
|
||||
`;
|
||||
|
||||
// --- WORKSHOP MESH ---
|
||||
// Geometry matches image aspect ratio (Width, 1.0)
|
||||
const workshopGeo = new THREE.PlaneGeometry(WORKSHOP_ASPECT, 1, 128, 128);
|
||||
const workshopMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uImage: { value: workshopTex },
|
||||
uDepth: { value: workshopDepth },
|
||||
uMouse: { value: new THREE.Vector2(0, 0) },
|
||||
uDepthScale: { value: 0.15 },
|
||||
uOpacity: { value: 1.0 }
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true
|
||||
});
|
||||
const workshopMesh = new THREE.Mesh(workshopGeo, workshopMat);
|
||||
scene.add(workshopMesh);
|
||||
|
||||
// --- VASE MESH ---
|
||||
const vaseGeo = new THREE.PlaneGeometry(VASE_ASPECT, 1, 128, 128);
|
||||
const vaseMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uImage: { value: vaseTex },
|
||||
uDepth: { value: vaseDepth },
|
||||
uMouse: { value: new THREE.Vector2(0, 0) },
|
||||
uDepthScale: { value: 0.15 },
|
||||
uOpacity: { value: 0.0 }
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true
|
||||
});
|
||||
const vaseMesh = new THREE.Mesh(vaseGeo, vaseMat);
|
||||
vaseMesh.position.z = 0.1; // Just in front to avoid z-fighting
|
||||
scene.add(vaseMesh);
|
||||
|
||||
|
||||
// Camera Start
|
||||
const DISTANCE = 4.0;
|
||||
camera.position.z = DISTANCE;
|
||||
|
||||
// Handle Resize & COVER/CONTAIN Logic
|
||||
const handleResize = () => {
|
||||
const screenAspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
camera.aspect = screenAspect;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
|
||||
// Calculate Visible Height at the mesh distance
|
||||
// fov is vertical fov in degrees
|
||||
const vFOV = camera.fov * Math.PI / 180;
|
||||
const visibleHeight = 2 * Math.tan(vFOV / 2) * (DISTANCE - workshopMesh.position.z);
|
||||
const visibleWidth = visibleHeight * screenAspect;
|
||||
|
||||
// 1. WORKSHOP: COVER
|
||||
// We want the mesh (which is Aspect x 1) to cover VisibleWidth x VisibleHeight
|
||||
// Scale X and Y by the same factor to maintain aspect
|
||||
const scaleFactorCover = Math.max(visibleWidth / WORKSHOP_ASPECT, visibleHeight / 1);
|
||||
workshopMesh.scale.set(scaleFactorCover, scaleFactorCover, 1);
|
||||
|
||||
// 2. VASE: CONTAIN / SAFE COVER
|
||||
// We want it visible. Let's make it cover 80% of min dimension, or standard scale.
|
||||
// Let's just fit it to height generally, or cover if desired.
|
||||
// User said "rotate... explore", let's make it fairly large but contained.
|
||||
const scaleFactorContain = Math.min(visibleWidth / VASE_ASPECT, visibleHeight / 1) * 0.8;
|
||||
vaseMesh.scale.set(scaleFactorContain, scaleFactorContain, 1);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
|
||||
// ANIMATION LOOP
|
||||
const mouse = new THREE.Vector2(0, 0);
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
workshopMat.uniforms.uMouse.value.lerp(mouse, 0.05);
|
||||
vaseMat.uniforms.uMouse.value.lerp(mouse, 0.05);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// INTERACTIONS
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
});
|
||||
|
||||
// Scroll Animation
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ".parallax-section",
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: true
|
||||
}
|
||||
});
|
||||
|
||||
// Transition
|
||||
// Workshop Fades Out
|
||||
tl.to(workshopMat.uniforms.uOpacity, { value: 0, ease: "power1.out" }, 0.2);
|
||||
|
||||
// Vase Fades In and Zooms slightly
|
||||
tl.to(vaseMat.uniforms.uOpacity, { value: 1, ease: "power1.in" }, 0.2);
|
||||
|
||||
// Camera move? Maybe subtle
|
||||
tl.to(camera.position, { z: 3.5, ease: "none" }, 0);
|
||||
|
||||
// Vase rotation/movement
|
||||
tl.fromTo(vaseMesh.rotation, { z: -0.05 }, { z: 0.05, ease: "none"}, 0.2);
|
||||
|
||||
// Hide loader/overlay if any
|
||||
tl.to(".product-reveal", { opacity: 0, duration: 0 }, 0);
|
||||
tl.to(canvas, { opacity: 1 }, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading assets:", err);
|
||||
document.getElementById('loader').innerText = "Error loading assets: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
varying vec2 vUv;
|
||||
varying float vDisplacement;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tImage, vUv);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
|
|
@ -89,8 +89,21 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Parallax Section -->
|
||||
<section class="parallax-section">
|
||||
<div class="parallax-sticky">
|
||||
<canvas id="parallax-canvas"></canvas>
|
||||
<div class="product-reveal">
|
||||
<img src="pottery-vase.png" alt="Finished Product" id="final-product-img">
|
||||
</div>
|
||||
</div>
|
||||
<div class="parallax-trigger"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Three.js from CDN -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 701 KiB |
|
|
@ -27,7 +27,7 @@ video.addEventListener("loadedmetadata", () => {
|
|||
|
||||
async function startBuffering() {
|
||||
video.currentTime = 0;
|
||||
video.playbackRate = 1; // Standard speed for better capture quality
|
||||
video.playbackRate = 4.0; // Increased speed for faster loading
|
||||
await video.play();
|
||||
|
||||
function capture() {
|
||||
|
|
@ -135,3 +135,151 @@ function initScrollAnimation() {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- 3D Parallax Implementation ---
|
||||
|
||||
async function initParallax() {
|
||||
// Wait for generic window load to ensure Three.js is ready
|
||||
if (typeof THREE === 'undefined') {
|
||||
console.warn("Three.js not loaded yet. Retrying...");
|
||||
requestAnimationFrame(initParallax);
|
||||
return;
|
||||
}
|
||||
|
||||
const parallaxCanvas = document.querySelector("#parallax-canvas");
|
||||
if (!parallaxCanvas) return;
|
||||
|
||||
// SCENE SETUP
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: parallaxCanvas, alpha: true, antialias: true });
|
||||
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// TEXTURE LOADER
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
|
||||
// Load textures
|
||||
// Note: Assuming these files exist in the same directory/public folder
|
||||
const [originalTexture, depthTexture] = await Promise.all([
|
||||
new Promise(resolve => textureLoader.load('workshop.jpg', resolve)),
|
||||
new Promise(resolve => textureLoader.load('workshop_depth.png', resolve))
|
||||
]);
|
||||
|
||||
// GEOMETRY & MATERIAL
|
||||
const geometry = new THREE.PlaneGeometry(16, 9, 128, 128); // Increased segments for smoother displacement
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tImage: { value: originalTexture },
|
||||
tDepth: { value: depthTexture },
|
||||
uDepthScale: { value: 3.0 }, // Exaggerated depth
|
||||
uMouse: { value: new THREE.Vector2(0, 0) },
|
||||
uScroll: { value: 0 }
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
varying float vDisplacement;
|
||||
|
||||
uniform sampler2D tDepth;
|
||||
uniform float uDepthScale;
|
||||
uniform vec2 uMouse;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
|
||||
float depth = texture2D(tDepth, uv).r;
|
||||
vDisplacement = depth;
|
||||
|
||||
vec3 newPosition = position;
|
||||
|
||||
// Displace along Z
|
||||
newPosition.z += depth * uDepthScale;
|
||||
|
||||
// Mouse Parallax (Simulate perspective shift)
|
||||
// Closer objects (light depth) move more than far objects
|
||||
newPosition.x += (uMouse.x * depth * 0.5);
|
||||
newPosition.y += (uMouse.y * depth * 0.5);
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tImage;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(tImage, vUv);
|
||||
}
|
||||
`,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
camera.position.z = 5;
|
||||
|
||||
// MOUSE INTERACTION
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
const x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
const y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
// Smooth lerp could be better, but direct set for responsiveness
|
||||
gsap.to(material.uniforms.uMouse.value, {
|
||||
x: x * 0.5, // Sensitivity
|
||||
y: y * 0.5,
|
||||
duration: 1,
|
||||
ease: "power2.out"
|
||||
});
|
||||
});
|
||||
|
||||
// RESIZE HANDLER
|
||||
function handleResize() {
|
||||
const videoAspect = 16 / 9;
|
||||
const windowAspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
camera.aspect = windowAspect;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
|
||||
// Cover logic
|
||||
if (windowAspect < videoAspect) {
|
||||
mesh.scale.set(videoAspect / windowAspect, 1, 1);
|
||||
} else {
|
||||
mesh.scale.set(1, windowAspect / videoAspect, 1);
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize(); // Initial call
|
||||
|
||||
// SCROLL ANIMATION (GSAP)
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: ".parallax-section",
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: true
|
||||
}
|
||||
});
|
||||
|
||||
tl.to(camera.position, {
|
||||
z: 3.5,
|
||||
ease: "none"
|
||||
}, 0);
|
||||
|
||||
// Fade out to reveal product
|
||||
tl.to(".product-reveal", { opacity: 1, duration: 0.2 }, 0.9);
|
||||
tl.to(parallaxCanvas, { opacity: 0, duration: 0.2 }, 0.95);
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
}
|
||||
|
||||
// Start
|
||||
initParallax();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
varying vec2 vUv;
|
||||
varying float vDisplacement;
|
||||
|
||||
uniform sampler2D tDepth;
|
||||
uniform float uDepthScale;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
|
||||
// Read depth from texture
|
||||
float depth = texture2D(tDepth, uv).r;
|
||||
vDisplacement = depth;
|
||||
|
||||
// Displace z position based on depth
|
||||
vec3 newPosition = position;
|
||||
newPosition.z += depth * uDepthScale;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
from rembg import remove
|
||||
from PIL import Image
|
||||
|
||||
def process_video(input_path, output_path):
|
||||
# Check if input exists
|
||||
if not os.path.exists(input_path):
|
||||
print(f"Error: Input file '{input_path}' not found.")
|
||||
return
|
||||
|
||||
print(f"Processing video: {input_path}")
|
||||
|
||||
cap = cv2.VideoCapture(input_path)
|
||||
|
||||
# Get video properties
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
print(f"Resolution: {width}x{height}, FPS: {fps}, Total Frames: {total_frames}")
|
||||
|
||||
# Initialize video writer
|
||||
# Using 'mp4v' codec for MP4. Note that standard MP4 does not support alpha channel easily.
|
||||
# For web transparency, we usually need WebM with VP9 or a specific MOV codec (ProRes 4444).
|
||||
# Here we will try to create a WebM file (VP9) which supports alpha.
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*'VP90')
|
||||
output_ext = os.path.splitext(output_path)[1].lower()
|
||||
|
||||
if output_ext == '.mp4':
|
||||
print("Warning: MP4 container often doesn't support alpha transparency widely. Switching codec might be needed.")
|
||||
# Try mp4v just in case, but alpha might be lost or black
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
elif output_ext == '.webm':
|
||||
fourcc = cv2.VideoWriter_fourcc(*'VP90')
|
||||
|
||||
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
||||
|
||||
frame_count = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Convert BGR (OpenCV) to RGB (PIL/rembg)
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_im = Image.fromarray(frame_rgb)
|
||||
|
||||
# Remove background using rembg
|
||||
output_pil = remove(pil_im)
|
||||
|
||||
# Convert back to numpy
|
||||
output_np = np.array(output_pil)
|
||||
|
||||
# Convert RGB to BGR for OpenCV handling (if we were saving a normal video)
|
||||
# But wait, OpenCV VideoWriter expects BGR.
|
||||
# If we want transparency, we need a 4-channel write.
|
||||
# Standard cv2.VideoWriter might struggle with 4 channels depending on backend.
|
||||
|
||||
# Let's check if the output has alpha
|
||||
if output_np.shape[2] == 4:
|
||||
# If we are writing to a format that supports alpha (like VP9 WebM), we should pass the alpha.
|
||||
# However, basic cv2 VideoWriter might not support 4 channels.
|
||||
# A safer bet for a simple script is to save as a sequence of PNGs or find a writer that supports it.
|
||||
# For this PoC, let's try writing the frame.
|
||||
|
||||
# If VideoWriter fails with 4 channels, we fallback to black background.
|
||||
frame_bgr_alpha = cv2.cvtColor(output_np, cv2.COLOR_RGBA2BGRA)
|
||||
out.write(frame_bgr_alpha)
|
||||
else:
|
||||
frame_bgr = cv2.cvtColor(output_np, cv2.COLOR_RGBA2BGR)
|
||||
out.write(frame_bgr)
|
||||
|
||||
frame_count += 1
|
||||
if frame_count % 10 == 0:
|
||||
print(f"Processed {frame_count}/{total_frames} frames...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing frames: {e}")
|
||||
|
||||
finally:
|
||||
cap.release()
|
||||
out.release()
|
||||
print("Done.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ensure dependencies: pip install rembg opencv-python pillow numpy
|
||||
input_file = "cup_spin.mp4"
|
||||
output_file = "cup_spin_no_bg.webm" # using webm for transparency support
|
||||
|
||||
process_video(input_file, output_file)
|
||||
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
|
@ -0,0 +1,61 @@
|
|||
# SEO Rules & Guidelines
|
||||
|
||||
These rules must be followed for all future development to ensure high SEO scores and avoid regression.
|
||||
|
||||
## 1. Content & Structure
|
||||
|
||||
### Heading Hierarchy (H1-H6)
|
||||
* **One H1 Per Page:** Every accessible page MUST have exactly one `<h1>` tag.
|
||||
* **Server-Side Rendering:** The `<h1>` tag MUST be present in the initial HTML payload (Server Component). if the visual design uses a Client Component for the Hero, place a visually hidden `<h1>` (e.g., `<h1 className="sr-only">Title</h1>`) in the Server Component.
|
||||
* **Logical Order:** Do not skip heading levels (e.g., H1 -> H3).
|
||||
|
||||
### Content Visibility
|
||||
* **SSR First:** Search engines prefer content rendered on the server. Avoid relying solely on `useEffect` or client-side data fetching for critical content (Titles, Descriptions, H1s, introductory text).
|
||||
* **Minimum Word Count:** Aim for at least 300-500 words of indexable content on key landing pages. If the visual design is minimal, include semantic content in `sr-only` or visible sections.
|
||||
|
||||
### Metadata
|
||||
* **Titles:**
|
||||
* Unique for every page.
|
||||
* Length: 30-60 characters.
|
||||
* Format: `Primary Keyword - Secondary Info | Brand Name`.
|
||||
* **Meta Descriptions:**
|
||||
* Unique for every page.
|
||||
* Length: 110-160 characters.
|
||||
* Must contain the primary keyword.
|
||||
* Must contain a call-to-action (e.g., "Create now", "Learn more").
|
||||
* **Canonical Tags:** Ensure every page has a self-referencing canonical tag to prevent duplicate content issues.
|
||||
|
||||
## 2. Technical Implementation
|
||||
|
||||
### Redirects
|
||||
* **Permanent vs. Temporary:** Use `301` (Permanent) redirects for content that has moved forever (e.g., legacy blog posts). Use `307` only for temporary situations (e.g., maintenance, auth redirects).
|
||||
* **Avoid Chains:** Redirect directly to the final destination (A -> B, not A -> B -> C).
|
||||
|
||||
### Links
|
||||
* **Descriptive Anchor Text:** Never use "click here" or raw URLs. Use descriptive text (e.g., "Read our Privacy Policy").
|
||||
* **Broken Links:** Regularly check for 404s.
|
||||
|
||||
### Indexing
|
||||
* **Public vs. Private:**
|
||||
* Public pages (Marketing, Blog, Pricing) MUST allow indexing (`index: true`).
|
||||
* Private pages (Dashboard, Settings) MUST be `noindex`.
|
||||
* **Robots.txt:** Ensure critical JS/CSS assets are not blocked.
|
||||
|
||||
## 3. Performance & Media
|
||||
|
||||
### Images
|
||||
* **Format:** Use Next.js `<Image>` component which automatically serves WebP/AVIF.
|
||||
* **Size:**
|
||||
* Hero images: < 200KB.
|
||||
* Blog images: < 100KB (unless full width).
|
||||
* Absolute Max: 500KB.
|
||||
* **Alt Text:** Every image MUST have descriptive `alt` text. Decorative images should have `alt=""` or `aria-hidden="true"`.
|
||||
|
||||
### Core Web Vitals
|
||||
* **LCP (Largest Contentful Paint):** Ensure the main image/text loads in < 2.5s.
|
||||
* **CLS (Cumulative Layout Shift):** Always specify `width` and `height` for images/videos to prevent layout jumps.
|
||||
|
||||
## 4. Internationalization (i18n)
|
||||
|
||||
* **Hreflang:** Ensure every translated page has correct `hreflang` tags pointing to its counterparts.
|
||||
* **Language Attribute:** The `<html>` tag must match the page language (e.g., `lang="de"` for German pages).
|
||||