Final
This commit is contained in:
@@ -1,112 +1,110 @@
|
||||
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;
|
||||
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;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
const BlogPostLayout: React.FC<BlogPostLayoutProps> = ({
|
||||
title,
|
||||
category,
|
||||
date,
|
||||
image,
|
||||
imageAlt,
|
||||
children,
|
||||
author = 'Claudia Knuth',
|
||||
}) => {
|
||||
const { articles } = useStore();
|
||||
const getArticleHref = (slug: string) => (
|
||||
slug.startsWith('/editorial/') ? slug : `/editorial/${slug}`
|
||||
);
|
||||
|
||||
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 className="max-w-4xl mx-auto px-6 md:px-12">
|
||||
<div className="flex items-center space-x-4 mb-8 justify-center flex-wrap gap-y-2">
|
||||
<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>
|
||||
<span className="text-xs text-text-muted">by <span className="font-medium" itemProp="author">{author}</span></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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{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={getArticleHref(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>
|
||||
)}
|
||||
|
||||
<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;
|
||||
|
||||
@@ -1,118 +1,106 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { COLLECTIONS } from '../constants';
|
||||
import { CollectionItem } from '../types';
|
||||
|
||||
const cardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 80,
|
||||
rotateX: 15,
|
||||
},
|
||||
visible: (index: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
transition: {
|
||||
delay: index * 0.15,
|
||||
duration: 0.8,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const col1 = [COLLECTIONS[0], COLLECTIONS[1]];
|
||||
const col2 = [COLLECTIONS[2], COLLECTIONS[3]];
|
||||
const col3 = [COLLECTIONS[4], COLLECTIONS[5]];
|
||||
|
||||
const renderCard = (item: CollectionItem, index: number) => (
|
||||
<motion.a
|
||||
key={item.id}
|
||||
className="group block cursor-pointer"
|
||||
href="#"
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
custom={index}
|
||||
>
|
||||
<div className={`relative overflow-hidden ${item.aspectRatio} mb-6`}>
|
||||
{/* Image with clean hover effect */}
|
||||
<motion.img
|
||||
alt={`${item.title} collection`}
|
||||
className="w-full h-full object-cover"
|
||||
src={item.image}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
/>
|
||||
{/* Subtle overlay that fades out on hover */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/5"
|
||||
initial={{ opacity: 1 }}
|
||||
whileHover={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
{/* Clean reveal line effect on hover */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 bg-white/80"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
style={{ transformOrigin: "left" }}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex justify-between items-center border-t border-gray-400/50 dark:border-gray-800 pt-4"
|
||||
initial={{ opacity: 0.8 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
>
|
||||
<h3 className="font-display text-3xl font-light text-text-main dark:text-white group-hover:italic transition-all duration-300">
|
||||
{item.title}
|
||||
</h3>
|
||||
<motion.span
|
||||
className="text-xs uppercase tracking-widest text-text-muted"
|
||||
whileHover={{ x: 5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{item.number}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-warm-grey dark:bg-[#141210] transition-colors duration-500">
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
<motion.div
|
||||
className="flex flex-col md:flex-row justify-between items-end mb-20 md:mb-32 px-4"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="font-display text-5xl md:text-7xl font-thin text-text-main dark:text-white">
|
||||
Curated <span className="italic text-text-muted">Editions</span>
|
||||
</h2>
|
||||
<p className="hidden md:block font-body text-sm text-text-muted max-w-xs leading-relaxed text-right">
|
||||
Explore our seasonal collections, fired in small batches.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-16">
|
||||
<div className="flex flex-col space-y-16 md:space-y-32">
|
||||
{col1.map((item, idx) => renderCard(item, idx))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-16 md:space-y-32 md:pt-32">
|
||||
{col2.map((item, idx) => renderCard(item, idx + 2))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-16 md:space-y-32 md:pt-16 lg:pt-0">
|
||||
{col3.map((item, idx) => renderCard(item, idx + 4))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { COLLECTIONS } from '../constants';
|
||||
import { CollectionItem } from '../types';
|
||||
|
||||
const cardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 80,
|
||||
rotateX: 15,
|
||||
},
|
||||
visible: (index: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
transition: {
|
||||
delay: index * 0.15,
|
||||
duration: 0.8,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const col1 = COLLECTIONS.filter((_, i) => i % 4 === 0);
|
||||
const col2 = COLLECTIONS.filter((_, i) => i % 4 === 1);
|
||||
const col3 = COLLECTIONS.filter((_, i) => i % 4 === 2);
|
||||
const col4 = COLLECTIONS.filter((_, i) => i % 4 === 3);
|
||||
|
||||
const renderCard = (item: CollectionItem, index: number) => (
|
||||
<motion.a
|
||||
key={item.id}
|
||||
className="group block cursor-pointer"
|
||||
href="#"
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
custom={index}
|
||||
>
|
||||
<div className={`relative overflow-hidden ${item.aspectRatio} mb-6`}>
|
||||
{/* Image with clean hover effect */}
|
||||
<motion.img
|
||||
alt={`${item.title} collection`}
|
||||
className="w-full h-full object-cover"
|
||||
src={item.image}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
/>
|
||||
{/* Subtle overlay that fades out on hover */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/5"
|
||||
initial={{ opacity: 1 }}
|
||||
whileHover={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
{/* Clean reveal line effect on hover */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 bg-white/80"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
style={{ transformOrigin: "left" }}
|
||||
/>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-warm-grey dark:bg-[#141210] transition-colors duration-500">
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
<motion.div
|
||||
className="flex flex-col md:flex-row justify-between items-end mb-20 md:mb-32 px-4"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="font-display text-5xl md:text-7xl font-thin text-text-main dark:text-white">
|
||||
Curated <span className="italic text-text-muted">Editions</span>
|
||||
</h2>
|
||||
<p className="hidden md:block font-body text-sm text-text-muted max-w-xs leading-relaxed text-right">
|
||||
Explore our seasonal collections, fired in small batches.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 lg:gap-12">
|
||||
<div className="flex flex-col space-y-8 md:space-y-16">
|
||||
{col1.map((item, idx) => renderCard(item, idx))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-8 md:space-y-16 pt-16 md:pt-24">
|
||||
{col2.map((item, idx) => renderCard(item, idx + 2))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-8 md:space-y-16 pt-8 md:pt-12 lg:pt-0">
|
||||
{col3.map((item, idx) => renderCard(item, idx + 4))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-8 md:space-y-16 pt-24 md:pt-32 lg:pt-24">
|
||||
{col4.map((item, idx) => renderCard(item, idx + 6))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
@@ -41,28 +41,24 @@ const FAQ: React.FC = () => {
|
||||
|
||||
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: "Is the online shop currently open?",
|
||||
answer: "Our online shop is temporarily closed while we focus on new collections and studio work. Follow us on Instagram or sign up for our newsletter to be the first to know when it reopens. For commissions, reach out directly at knuth.claudia@gmail.com."
|
||||
},
|
||||
{
|
||||
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."
|
||||
answer: "Yes! Our functional stoneware, including mugs, plates, and bowls, is high-fire kiln fired, 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!"
|
||||
answer: "Our work is rooted in Corpus Christi, Texas, inspired by the colors and textures of the Gulf Coast."
|
||||
},
|
||||
{
|
||||
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."
|
||||
answer: "Pottery classes and wheel-throwing workshops are available through the Art Center of Corpus Christi. Visit the Art Center for current schedules and registration."
|
||||
},
|
||||
{
|
||||
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."
|
||||
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, reach out directly at knuth.claudia@gmail.com."
|
||||
},
|
||||
{
|
||||
question: "What clay bodies and glazes do you use?",
|
||||
|
||||
@@ -1,89 +1,93 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const FeatureSection: React.FC = () => {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
const image = imageRef.current;
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!section || !image || !content) return;
|
||||
|
||||
// Image reveal animation
|
||||
gsap.fromTo(
|
||||
image,
|
||||
{ clipPath: 'inset(100% 0 0 0)', opacity: 0 },
|
||||
{
|
||||
clipPath: 'inset(0% 0 0 0)',
|
||||
opacity: 1,
|
||||
duration: 1.2,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 60%',
|
||||
toggleActions: 'play none none reverse',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Content fade in animation
|
||||
gsap.fromTo(
|
||||
content,
|
||||
{ x: -60, opacity: 0 },
|
||||
{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 50%',
|
||||
toggleActions: 'play none none reverse',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="py-32 md:py-48 bg-sage dark:bg-stone-900 overflow-hidden relative transition-colors duration-500">
|
||||
<div className="max-w-[1800px] mx-auto px-6">
|
||||
<div className="relative flex flex-col md:block">
|
||||
<div className="hidden md:block absolute -top-24 left-10 z-0 select-none opacity-[0.03] dark:opacity-[0.05] pointer-events-none">
|
||||
<span className="font-display text-[20rem] leading-none text-black dark:text-white">CRAFT</span>
|
||||
</div>
|
||||
<div
|
||||
ref={imageRef}
|
||||
className="w-full md:w-3/5 h-[600px] md:h-[800px] ml-auto relative z-10 shadow-2xl bg-center bg-cover bg-no-repeat"
|
||||
style={{ backgroundImage: "url('/ceramic-cups.png')" }}
|
||||
>
|
||||
</div>
|
||||
<div ref={contentRef} className="relative z-20 mt-[-100px] md:mt-0 md:absolute md:top-1/2 md:left-20 md:-translate-y-1/2 bg-white dark:bg-stone-900 p-12 md:p-20 shadow-xl max-w-xl mx-auto md:mx-0">
|
||||
<span className="block w-12 h-[1px] bg-text-main mb-8"></span>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-light mb-8 text-text-main dark:text-white leading-tight">
|
||||
The Art of <br /><i className="font-thin">Slow Living</i>
|
||||
</h2>
|
||||
<p className="font-body font-light text-text-muted dark:text-gray-400 mb-10 leading-loose text-sm">
|
||||
We believe in the beauty of handmade objects. Our collection features a curated selection of ceramics designed to elevate the everyday. From sturdy mugs for your morning coffee to elegant vases that breathe life into a room, each piece is crafted with patience and intention.
|
||||
</p>
|
||||
<a className="group inline-flex items-center text-xs uppercase tracking-[0.2em] text-text-main dark:text-white font-medium" href="#">
|
||||
Read Our Story <span className="ml-2 group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const FeatureSection: React.FC = () => {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
const image = imageRef.current;
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!section || !image || !content) return;
|
||||
|
||||
// Image reveal animation
|
||||
gsap.fromTo(
|
||||
image,
|
||||
{ clipPath: 'inset(100% 0 0 0)', opacity: 0 },
|
||||
{
|
||||
clipPath: 'inset(0% 0 0 0)',
|
||||
opacity: 1,
|
||||
duration: 1.2,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 60%',
|
||||
toggleActions: 'play none none reverse',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Content fade in animation
|
||||
gsap.fromTo(
|
||||
content,
|
||||
{ x: -60, opacity: 0 },
|
||||
{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 50%',
|
||||
toggleActions: 'play none none reverse',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="py-32 md:py-48 bg-sage dark:bg-stone-900 overflow-hidden relative transition-colors duration-500">
|
||||
<div className="max-w-[1800px] mx-auto px-6">
|
||||
<div className="relative flex flex-col md:block">
|
||||
<div className="hidden md:block absolute -top-24 left-10 z-0 select-none opacity-10 dark:opacity-20 pointer-events-none">
|
||||
<span className="font-display text-[20rem] leading-none text-black dark:text-white">CRAFT</span>
|
||||
</div>
|
||||
<div
|
||||
ref={imageRef}
|
||||
className="w-full md:w-3/5 h-[400px] md:h-[600px] ml-auto relative z-10 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src="/landingpage/2.png"
|
||||
alt="Handcrafted stoneware ceramics by KNUTH Ceramics — slow-made pottery from Corpus Christi, Texas"
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<div ref={contentRef} className="relative z-20 mt-[-100px] md:mt-0 md:absolute md:top-1/2 md:left-20 md:-translate-y-1/2 bg-white dark:bg-stone-900 p-12 md:p-20 shadow-xl max-w-xl mx-auto md:mx-0">
|
||||
<span className="block w-12 h-[1px] bg-text-main mb-8"></span>
|
||||
<h2 className="font-display text-4xl md:text-5xl font-light mb-8 text-text-main dark:text-white leading-tight">
|
||||
The Art of <br /><i className="font-thin">Slow Living</i>
|
||||
</h2>
|
||||
<p className="font-body font-light text-text-muted dark:text-gray-400 mb-10 leading-loose text-sm">
|
||||
We believe in the beauty of handmade objects. Our collection features a curated selection of ceramics designed to elevate the everyday. From sturdy mugs for your morning coffee to elegant vases that breathe life into a room, each piece is crafted with patience and intention.
|
||||
</p>
|
||||
<a className="group inline-flex items-center text-xs uppercase tracking-[0.2em] text-text-main dark:text-white font-medium" href="#">
|
||||
Read Our Story <span className="ml-2 group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureSection;
|
||||
@@ -1,115 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FOOTER_LINKS } from '../constants';
|
||||
|
||||
const DISABLED_FOOTER_LINKS = new Set([
|
||||
'Best Sellers',
|
||||
'Gift Cards',
|
||||
'Sustainability',
|
||||
'Careers',
|
||||
'Press',
|
||||
]);
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const renderFooterLink = (link: { label: string; href: string }) => {
|
||||
const baseClassName = 'text-lg font-light transition-all duration-300 block';
|
||||
|
||||
if (DISABLED_FOOTER_LINKS.has(link.label)) {
|
||||
return (
|
||||
<span className={`${baseClassName} text-stone-500 cursor-not-allowed`} aria-disabled="true">
|
||||
{link.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={`${baseClassName} hover:text-stone-400 hover:pl-2`} to={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-primary dark:bg-black text-white pt-32 pb-12 px-6 md:px-12 border-t border-stone-800">
|
||||
<div className="max-w-[1920px] mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 mb-32">
|
||||
{/* Brand & Mission */}
|
||||
<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">
|
||||
KNUTH Ceramics
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 lg:mt-0">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest mb-6">Join our newsletter</h4>
|
||||
<form className="flex flex-col sm:flex-row gap-4 max-w-md" onSubmit={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="bg-white/5 border border-white/10 text-white placeholder-stone-500 focus:outline-none focus:border-white/30 text-sm px-6 py-4 w-full transition-colors"
|
||||
placeholder="Enter your email"
|
||||
type="email"
|
||||
/>
|
||||
<button
|
||||
className="bg-white text-black px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-stone-200 transition-colors"
|
||||
type="submit"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-7 grid grid-cols-2 md:grid-cols-3 gap-12 pt-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[0].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[0].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[1].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[1].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[2].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[2].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>© 2025 KNUTH Ceramics. All rights reserved.</p>
|
||||
<div className="flex space-x-8 mt-6 md:mt-0">
|
||||
<Link className="hover:text-white transition-colors" to="/privacy">Privacy</Link>
|
||||
<Link className="hover:text-white transition-colors" to="/returns">Terms</Link>
|
||||
<Link className="hover:text-white transition-colors" to="/cookies">Cookies</Link>
|
||||
<Link className="hover:text-white transition-colors" to="/admin">Admin</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FOOTER_LINKS } from '../constants';
|
||||
|
||||
const DISABLED_FOOTER_LINKS = new Set([
|
||||
'Best Sellers',
|
||||
'Gift Cards',
|
||||
'Sustainability',
|
||||
'Careers',
|
||||
'Press',
|
||||
'Shipping',
|
||||
'Returns',
|
||||
]);
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const renderFooterLink = (link: { label: string; href: string }) => {
|
||||
const baseClassName = 'text-lg font-light transition-all duration-300 block';
|
||||
|
||||
if (DISABLED_FOOTER_LINKS.has(link.label)) {
|
||||
return (
|
||||
<span className={`${baseClassName} text-stone-500 cursor-not-allowed`} aria-disabled="true">
|
||||
{link.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={`${baseClassName} hover:text-stone-400 hover:pl-2`} to={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-primary dark:bg-black text-white pt-32 pb-12 px-6 md:px-12 border-t border-stone-800">
|
||||
<div className="max-w-[1920px] mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24 mb-32">
|
||||
{/* Brand & Mission */}
|
||||
<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-6 bg-gradient-to-br from-white to-stone-400 bg-clip-text text-transparent">
|
||||
KNUTH Ceramics
|
||||
</h2>
|
||||
<div className="mb-8">
|
||||
<span className="font-body text-[0.7rem] uppercase tracking-[0.2em] border border-stone-800 px-3 py-1.5 inline-block text-stone-300 bg-stone-900/40">
|
||||
Online Shop Opening — Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 lg:mt-0">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest mb-4">Get in touch</h4>
|
||||
<a
|
||||
href="mailto:knuth.claudia@gmail.com"
|
||||
className="font-body text-stone-400 hover:text-white transition-colors text-base block mb-2"
|
||||
>
|
||||
knuth.claudia@gmail.com
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/knuth.ceramics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-body text-stone-400 hover:text-white transition-colors text-base block"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links Columns */}
|
||||
<div className="lg:col-span-7 grid grid-cols-2 md:grid-cols-3 gap-12 pt-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[0].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[0].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[1].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[1].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest mb-8 text-stone-500">{FOOTER_LINKS[2].title}</h4>
|
||||
<ul className="space-y-6">
|
||||
{FOOTER_LINKS[2].links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{renderFooterLink(link)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>© 2026 KNUTH Ceramics. All rights reserved.</p>
|
||||
<div className="flex space-x-8 mt-6 md:mt-0">
|
||||
<Link className="hover:text-white transition-colors" to="/privacy">Privacy</Link>
|
||||
<Link className="hover:text-white transition-colors" to="/returns">Terms</Link>
|
||||
<Link className="hover:text-white transition-colors" to="/cookies">Cookies</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,179 +1,179 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { GALLERY_IMAGES } from '../constants';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
likes: number;
|
||||
comments: number;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const GallerySection: React.FC = () => {
|
||||
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
|
||||
|
||||
// Double the images for seamless infinite scroll
|
||||
const duplicatedImages = [...GALLERY_IMAGES, ...GALLERY_IMAGES] as GalleryImage[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="py-20 bg-white dark:bg-background-dark overflow-hidden">
|
||||
<div className="max-w-[1920px] mx-auto px-4">
|
||||
<motion.div
|
||||
className="flex justify-between items-center mb-8 px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 via-pink-500 to-orange-400 p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-white dark:bg-background-dark flex items-center justify-center">
|
||||
<span className="font-display text-lg">K</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display text-xl text-text-main dark:text-white">@knuth_ceramics</h4>
|
||||
<p className="text-xs text-text-muted">24.8k followers</p>
|
||||
</div>
|
||||
</div>
|
||||
<a className="px-6 py-2 border border-text-main dark:border-white text-xs uppercase tracking-widest text-text-main dark:text-white hover:bg-text-main hover:text-white dark:hover:bg-white dark:hover:text-black transition-all duration-300 rounded-full" href="#">
|
||||
Follow
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Infinite Carousel */}
|
||||
<div className="relative group overflow-hidden">
|
||||
<style>{`
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-${GALLERY_IMAGES.length * 304}px); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex gap-4 animate-marquee w-max py-4">
|
||||
{duplicatedImages.map((img, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
className="relative flex-shrink-0 w-72 h-72 overflow-hidden cursor-pointer rounded-lg"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
>
|
||||
<img
|
||||
alt={img.caption}
|
||||
className="w-full h-full object-cover"
|
||||
src={img.src}
|
||||
/>
|
||||
{/* Instagram-style hover overlay */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 flex items-center justify-center gap-8 text-white"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined" style={{ fontVariationSettings: "'FILL' 1" }}>favorite</span>
|
||||
<span className="font-bold">{img.likes.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined">chat_bubble</span>
|
||||
<span className="font-bold">{img.comments}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
<AnimatePresence>
|
||||
{
|
||||
selectedImage && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<motion.div
|
||||
className="relative max-w-4xl w-full bg-white dark:bg-stone-900 rounded-xl overflow-hidden flex flex-col md:flex-row"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="md:w-2/3 aspect-square md:aspect-auto">
|
||||
<img
|
||||
src={selectedImage.src}
|
||||
alt={selectedImage.caption}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="md:w-1/3 p-6 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-stone-200 dark:border-stone-700">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 via-pink-500 to-orange-400 p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-white dark:bg-stone-900 flex items-center justify-center">
|
||||
<span className="font-display text-sm">K</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm text-text-main dark:text-white">knuth_ceramics</span>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div className="flex-1 py-4">
|
||||
<p className="text-sm text-text-main dark:text-white">
|
||||
<span className="font-bold">knuth_ceramics</span> {selectedImage.caption}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-stone-200 dark:border-stone-700 pt-4">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white" style={{ fontVariationSettings: "'FILL' 1" }}>favorite</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">chat_bubble</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">send</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform ml-auto">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">bookmark</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-main dark:text-white">{selectedImage.likes.toLocaleString()} likes</p>
|
||||
<p className="text-xs text-text-muted mt-1">{selectedImage.comments} comments</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white bg-black/50 rounded-full p-2 hover:bg-black/70 transition-colors"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
</AnimatePresence >
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { GALLERY_IMAGES } from '../constants';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
likes: number;
|
||||
comments: number;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const GallerySection: React.FC = () => {
|
||||
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
|
||||
|
||||
// Double the images for seamless infinite scroll
|
||||
const duplicatedImages = [...GALLERY_IMAGES, ...GALLERY_IMAGES] as GalleryImage[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="py-20 bg-white dark:bg-background-dark overflow-hidden">
|
||||
<div className="max-w-[1920px] mx-auto px-4">
|
||||
<motion.div
|
||||
className="flex justify-between items-center mb-8 px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<a href="https://www.instagram.com/knuth.ceramics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-4 group cursor-pointer">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 via-pink-500 to-orange-400 p-[2px] transition-transform group-hover:scale-105 duration-300">
|
||||
<div className="w-full h-full rounded-full bg-white dark:bg-background-dark flex items-center justify-center">
|
||||
<span className="font-display text-lg">K</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display text-xl text-text-main dark:text-white group-hover:underline">@knuth_ceramics</h4>
|
||||
<p className="text-xs text-text-muted">24.8k followers</p>
|
||||
</div>
|
||||
</a>
|
||||
<a className="px-6 py-2 border border-text-main dark:border-white text-xs uppercase tracking-widest text-text-main dark:text-white hover:bg-text-main hover:text-white dark:hover:bg-white dark:hover:text-black transition-all duration-300 rounded-full" href="https://www.instagram.com/knuth.ceramics" target="_blank" rel="noopener noreferrer">
|
||||
Follow
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
{/* Infinite Carousel */}
|
||||
<div className="relative group overflow-hidden">
|
||||
<style>{`
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-${GALLERY_IMAGES.length * 304}px); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex gap-4 animate-marquee w-max py-4">
|
||||
{duplicatedImages.map((img, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
className="relative flex-shrink-0 w-72 h-72 overflow-hidden cursor-pointer rounded-lg"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
>
|
||||
<img
|
||||
alt={img.caption}
|
||||
className="w-full h-full object-cover"
|
||||
src={img.src}
|
||||
/>
|
||||
{/* Instagram-style hover overlay */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/50 flex items-center justify-center gap-8 text-white"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined" style={{ fontVariationSettings: "'FILL' 1" }}>favorite</span>
|
||||
<span className="font-bold">{img.likes.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined">chat_bubble</span>
|
||||
<span className="font-bold">{img.comments}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
<AnimatePresence>
|
||||
{
|
||||
selectedImage && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<motion.div
|
||||
className="relative max-w-4xl w-full bg-white dark:bg-stone-900 rounded-xl overflow-hidden flex flex-col md:flex-row"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="md:w-2/3 aspect-square md:aspect-auto">
|
||||
<img
|
||||
src={selectedImage.src}
|
||||
alt={selectedImage.caption}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="md:w-1/3 p-6 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-stone-200 dark:border-stone-700">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 via-pink-500 to-orange-400 p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-white dark:bg-stone-900 flex items-center justify-center">
|
||||
<span className="font-display text-sm">K</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm text-text-main dark:text-white">knuth_ceramics</span>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div className="flex-1 py-4">
|
||||
<p className="text-sm text-text-main dark:text-white">
|
||||
<span className="font-bold">knuth_ceramics</span> {selectedImage.caption}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-stone-200 dark:border-stone-700 pt-4">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white" style={{ fontVariationSettings: "'FILL' 1" }}>favorite</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">chat_bubble</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">send</span>
|
||||
</button>
|
||||
<button className="hover:scale-110 transition-transform ml-auto">
|
||||
<span className="material-symbols-outlined text-2xl text-text-main dark:text-white">bookmark</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-main dark:text-white">{selectedImage.likes.toLocaleString()} likes</p>
|
||||
<p className="text-xs text-text-muted mt-1">{selectedImage.comments} comments</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white bg-black/50 rounded-full p-2 hover:bg-black/70 transition-colors"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
</AnimatePresence >
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
|
||||
@@ -1,119 +1,118 @@
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 w-full z-50 transition-all duration-500 ${scrolled
|
||||
? 'bg-white/80 dark:bg-black/80 backdrop-blur-xl py-2 border-b border-stone-200/50 dark:border-stone-800/50'
|
||||
: 'bg-transparent py-6'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
<div className="flex justify-between items-center h-20">
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="flex items-center md:hidden">
|
||||
<button
|
||||
className="text-text-main dark:text-white p-2 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 relative group cursor-pointer">
|
||||
<Link className="relative z-10 font-display text-4xl md:text-5xl font-light tracking-widest uppercase text-text-main dark:text-white" to="/">
|
||||
KNUTH Ceramics
|
||||
</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 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex space-x-12">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
className="group relative text-xs uppercase tracking-[0.2em] text-text-main dark:text-white hover:text-black dark:hover:text-white transition-colors duration-300 py-2"
|
||||
to={item.label === 'Collections' ? '/collections' : item.label === 'Atelier' ? '/atelier' : '/editorial'}
|
||||
>
|
||||
{item.label}
|
||||
{/* Underline Reveal Animation */}
|
||||
<span className="absolute bottom-0 left-0 w-0 h-[1px] bg-black dark:bg-white transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Icons */}
|
||||
<div className="flex items-center space-x-6 text-text-main dark:text-white">
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="md:hidden absolute top-full left-0 w-full bg-white/95 dark:bg-black/95 backdrop-blur-xl border-b border-stone-200 dark:border-stone-800 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col p-8 space-y-6">
|
||||
{NAV_ITEMS.map((item, idx) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.label === 'Collections' ? '/collections' : item.label === 'Atelier' ? '/atelier' : '/editorial'}
|
||||
className="text-lg uppercase tracking-[0.2em] text-text-main dark:text-white hover:pl-4 transition-all duration-300 border-l-2 border-transparent hover:border-black dark:hover:border-white"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 w-full z-50 transition-all duration-500 ${scrolled
|
||||
? 'bg-white/80 dark:bg-black/80 backdrop-blur-xl py-2 border-b border-stone-200/50 dark:border-stone-800/50'
|
||||
: 'bg-transparent py-6'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-[1920px] mx-auto px-6 md:px-12">
|
||||
<div className="flex justify-between items-center h-20">
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="flex items-center md:hidden">
|
||||
<button
|
||||
className="text-text-main dark:text-white p-2 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 relative group cursor-pointer">
|
||||
<Link className="relative z-10 font-display text-4xl md:text-5xl font-light tracking-widest uppercase text-text-main dark:text-white" to="/">
|
||||
KNUTH Ceramics
|
||||
</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 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex space-x-12">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
className="group relative text-xs uppercase tracking-[0.2em] text-text-main dark:text-white hover:text-black dark:hover:text-white transition-colors duration-300 py-2"
|
||||
to={item.label === 'Collections' ? '/collections' : item.label === 'Atelier' ? '/atelier' : '/editorial'}
|
||||
>
|
||||
{item.label}
|
||||
{/* Underline Reveal Animation */}
|
||||
<span className="absolute bottom-0 left-0 w-0 h-[1px] bg-black dark:bg-white transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Icons */}
|
||||
<div className="flex items-center space-x-6 text-text-main dark:text-white">
|
||||
{/*
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Mobile Menu Overlay */}
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="md:hidden absolute top-full left-0 w-full bg-white/95 dark:bg-black/95 backdrop-blur-xl border-b border-stone-200 dark:border-stone-800 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col p-8 space-y-6">
|
||||
{NAV_ITEMS.map((item, idx) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.label === 'Collections' ? '/collections' : item.label === 'Atelier' ? '/atelier' : '/editorial'}
|
||||
className="text-lg uppercase tracking-[0.2em] text-text-main dark:text-white hover:pl-4 transition-all duration-300 border-l-2 border-transparent hover:border-black dark:hover:border-white"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<motion.span
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
const Hero: React.FC = () => {
|
||||
return (
|
||||
<section className="relative min-h-screen pt-24 w-full flex flex-col md:flex-row items-center overflow-hidden bg-background-light dark:bg-background-dark">
|
||||
<div className="w-full md:w-5/12 h-full flex flex-col justify-center px-6 md:pl-20 md:pr-12 py-20 z-10">
|
||||
<span className="font-body text-xs uppercase tracking-[0.3em] text-text-muted mb-8 ml-1 block">
|
||||
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">
|
||||
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">
|
||||
Handcrafted ceramics from the Texas Coast. Functional art inspired by the raw textures and colors of Corpus Christi. Small batch, slow-made.
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Hero: React.FC = () => {
|
||||
return (
|
||||
<section className="relative min-h-[85vh] pt-24 pb-12 w-full flex flex-col md:flex-row items-center overflow-hidden bg-background-light dark:bg-background-dark">
|
||||
<div className="w-full md:w-1/2 h-full flex flex-col justify-center px-6 md:pl-20 md:pr-12 py-12 z-10">
|
||||
<span className="font-body text-xs uppercase tracking-[0.3em] text-text-muted mb-12 ml-1 block">
|
||||
New Collection 2026
|
||||
</span>
|
||||
<span className="font-body text-[0.7rem] uppercase tracking-[0.2em] border border-stone-300 dark:border-stone-700 px-3 py-1.5 inline-block text-stone-700 dark:text-stone-300 mt-4">
|
||||
Online Shop Opening — Coming Soon
|
||||
</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl lg:text-7xl xl:text-8xl text-text-main dark:text-white font-thin leading-[0.9] mb-8">
|
||||
<span className="whitespace-nowrap">German Craft.</span><br /><span className="italic pl-8 md:pl-16 text-text-muted whitespace-nowrap">Coastal Soul.</span>
|
||||
</h1>
|
||||
<p className="font-body text-text-muted dark:text-gray-400 text-sm font-light mb-8 max-w-sm leading-relaxed ml-1">
|
||||
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="#">
|
||||
<div className="ml-1 mt-8">
|
||||
<Link 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" to="/collections">
|
||||
View The Collection
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-7/12 h-[60vh] md:h-screen relative">
|
||||
<div className="absolute inset-0 bg-stone-200 dark:bg-stone-800">
|
||||
<img
|
||||
alt="Minimalist ceramic vase with single branch"
|
||||
className="w-full h-full object-cover object-center brightness-95"
|
||||
src="/pottery-studio.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-10 left-10 md:left-auto md:right-20 bg-background-light/90 dark:bg-background-dark/90 backdrop-blur p-6 max-w-xs hidden md:block shadow-sm">
|
||||
<p className="font-display italic text-xl text-text-main dark:text-gray-200">
|
||||
"In emptiness, there is fullness."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
<div className="w-full md:w-1/2 h-[50vh] md:h-[75vh] relative mt-12 md:mt-0">
|
||||
<div className="absolute inset-0 bg-stone-200 dark:bg-stone-800">
|
||||
<img
|
||||
alt="Minimalist ceramic vase with single branch"
|
||||
className="w-full h-full object-cover object-center brightness-95"
|
||||
src="/landingpage/1.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-16 left-10 md:left-auto md:right-20 bg-background-light/90 dark:bg-background-dark/90 backdrop-blur p-6 max-w-xs hidden md:block shadow-sm">
|
||||
<p className="font-display italic text-lg text-text-main dark:text-gray-200">
|
||||
"In emptiness, there is fullness."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
|
||||
@@ -1,84 +1,91 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const horizontalImages = [
|
||||
{ src: '/pottery-vase.png', title: 'Handcrafted Vases', description: 'Each vase tells a story of patience and craft' },
|
||||
{ src: '/pottery-bowls.png', title: 'Artisan Bowls', description: 'Organic forms inspired by nature' },
|
||||
{ src: '/pottery-plates.png', title: 'Dinner Collection', description: 'Elevate your everyday dining experience' },
|
||||
{ src: '/pottery-studio.png', title: 'Our Studio', description: 'Where creativity meets tradition' },
|
||||
{ src: '/ceramic-cups.png', title: 'Ceramic Cups', description: 'Handmade with love and intention' },
|
||||
];
|
||||
|
||||
const HorizontalScrollSection: React.FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const scrollContainer = scrollRef.current;
|
||||
|
||||
if (!container || !scrollContainer) return;
|
||||
|
||||
const scrollWidth = scrollContainer.scrollWidth - window.innerWidth;
|
||||
|
||||
const tween = gsap.to(scrollContainer, {
|
||||
x: -scrollWidth,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: 'top top',
|
||||
end: () => `+=${scrollWidth * 0.5}`,
|
||||
scrub: 1,
|
||||
pin: true,
|
||||
anticipatePin: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
tween.scrollTrigger?.kill();
|
||||
tween.kill();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="relative overflow-hidden bg-clay-dark">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-screen items-center"
|
||||
>
|
||||
{horizontalImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex-shrink-0 w-[90vw] md:w-[75vw] h-screen flex items-center justify-center p-4 md:p-8"
|
||||
>
|
||||
<div className="relative w-full h-full max-w-5xl max-h-[80vh] overflow-hidden rounded-lg shadow-2xl group">
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-12 text-white">
|
||||
<h3 className="font-display text-5xl md:text-6xl font-light mb-4">{image.title}</h3>
|
||||
<p className="font-body text-lg font-light opacity-80 max-w-md">{image.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 right-8 -translate-y-1/2 text-white/20 font-display text-[15rem] leading-none select-none pointer-events-none">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-4 text-white/60">
|
||||
<span className="material-symbols-outlined text-sm">arrow_back</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] font-light">Scroll to explore</span>
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalScrollSection;
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const horizontalImages = [
|
||||
{ src: '/product_images/kitchenware.png', title: 'Coffee Cups', description: 'Wheel-thrown cups for your morning ritual — no two alike' },
|
||||
{ src: '/product_images/lass_das_so_202603231510.png', title: 'Bowls', description: 'Handcrafted stoneware bowls for the table and the kitchen' },
|
||||
{ src: '/product_images/Produkt_foto_studio_202603231654 (1).png', title: 'Tableware', description: 'Small-batch dinnerware made to be used every day' },
|
||||
{ src: '/product_images/Produkt_foto_studio_202603231744.png', title: 'Kitchenware', description: 'Functional ceramics built for the rhythms of daily life' },
|
||||
{ src: '/product_images/Produkt_foto_studio_202603231654 (2).png', title: 'Decoration', description: 'Sculptural pieces inspired by the textures of the Gulf Coast' },
|
||||
];
|
||||
|
||||
const HorizontalScrollSection: React.FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const scrollContainer = scrollRef.current;
|
||||
|
||||
if (!container || !scrollContainer) return;
|
||||
|
||||
const cards = Array.from(scrollContainer.children) as HTMLDivElement[];
|
||||
const lastCard = cards[cards.length - 1];
|
||||
|
||||
if (!lastCard) return;
|
||||
|
||||
const lastCardRightEdge = lastCard.offsetLeft + lastCard.offsetWidth;
|
||||
const mobileEndInset = window.innerWidth < 768 ? 24 : 64;
|
||||
const maxScroll = Math.max(lastCardRightEdge - window.innerWidth + mobileEndInset, 0);
|
||||
|
||||
const tween = gsap.to(scrollContainer, {
|
||||
x: -maxScroll,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: 'top top',
|
||||
end: () => `+=${maxScroll * 0.5}`,
|
||||
scrub: 1,
|
||||
pin: true,
|
||||
anticipatePin: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
tween.scrollTrigger?.kill();
|
||||
tween.kill();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="relative overflow-hidden bg-clay-dark h-screen w-full">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex h-screen items-center"
|
||||
>
|
||||
{horizontalImages.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative flex-shrink-0 w-[86vw] md:w-[75vw] h-screen flex items-center justify-center px-4 pr-16 md:p-8"
|
||||
>
|
||||
<div className="relative w-full h-full max-w-4xl max-h-[60vh] overflow-hidden rounded-lg shadow-2xl group">
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 p-8 md:p-12 text-white">
|
||||
<h3 className="font-display text-4xl md:text-5xl font-light mb-4">{image.title}</h3>
|
||||
<p className="font-body text-base md:text-lg font-light opacity-80 max-w-md">{image.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 right-3 translate-x-[24%] -translate-y-1/2 text-white/20 font-display text-[4.5rem] sm:text-[6rem] md:right-0 md:translate-x-[42%] md:text-[12rem] xl:text-[15rem] leading-none select-none pointer-events-none">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-4 text-white/60">
|
||||
<span className="material-symbols-outlined text-sm">arrow_back</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] font-light">Scroll to explore</span>
|
||||
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalScrollSection;
|
||||
|
||||
176
Pottery-website/components/InstagramFeed.tsx
Normal file
176
Pottery-website/components/InstagramFeed.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
instgrm?: {
|
||||
Embeds: {
|
||||
process: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const posts = [
|
||||
'https://www.instagram.com/p/DSOFijljukL/',
|
||||
'https://www.instagram.com/p/DSGh7rMjVes/',
|
||||
'https://www.instagram.com/p/DRIIUECj4f6/',
|
||||
'https://www.instagram.com/p/DJOvmvcIoo7/',
|
||||
'https://www.instagram.com/p/DJOu5b7IL4t/',
|
||||
'https://www.instagram.com/p/DIQnhO0oJgw/',
|
||||
'https://www.instagram.com/p/DIJUVqbI4EH/',
|
||||
'https://www.instagram.com/p/DHlvDNyIDRa/',
|
||||
'https://www.instagram.com/p/DHlub_iojwv/',
|
||||
'https://www.instagram.com/p/DJOvdVLIZpM/',
|
||||
];
|
||||
|
||||
const InstagramFeed: React.FC = () => {
|
||||
const [selectedPost, setSelectedPost] = useState<string | null>(null);
|
||||
|
||||
// Double the list for seamless infinite marquee scroll
|
||||
const duplicatedPosts = [...posts, ...posts];
|
||||
|
||||
useEffect(() => {
|
||||
// Process Instagram embeds whenever the component mounts or the lightbox opens
|
||||
if (window.instgrm) {
|
||||
window.instgrm.Embeds.process();
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.src = '//www.instagram.com/embed.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}, [selectedPost]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="py-24 px-6 md:px-12 bg-stone-50 dark:bg-stone-900 overflow-hidden">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<span className="block font-body text-xs uppercase tracking-[0.3em] text-stone-400 mb-6">
|
||||
Follow Along
|
||||
</span>
|
||||
<div className="flex items-end justify-between mb-16 px-2">
|
||||
<h2 className="font-display text-4xl md:text-5xl text-text-main dark:text-white leading-none">
|
||||
From the Studio
|
||||
</h2>
|
||||
<a
|
||||
href="https://www.instagram.com/knuth.ceramics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-bold uppercase tracking-widest text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
@knuth.ceramics →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Infinite Carousel */}
|
||||
<div className="relative group overflow-hidden">
|
||||
<style>{`
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-${posts.length * 342}px); /* 326px width + 16px gap */ }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee 50s linear infinite;
|
||||
}
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex gap-4 animate-marquee w-max py-4">
|
||||
{duplicatedPosts.map((permalink, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative flex-shrink-0 w-[326px] overflow-hidden rounded-[8px] group/item cursor-pointer bg-white"
|
||||
>
|
||||
{/* Invisible Overlay to capture clicks.
|
||||
Because iframes block events, we put a div above it.
|
||||
On hover it reveals a subtle mask to indicate interactivity. */}
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-black/0 group-hover/item:bg-black/50 transition-colors duration-300 flex flex-col items-center justify-center opacity-0 group-hover/item:opacity-100"
|
||||
onClick={() => setSelectedPost(permalink)}
|
||||
>
|
||||
<p className="text-white font-display text-lg px-4 text-center font-bold drop-shadow-md">
|
||||
View Post
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The Instagram Embed itself.
|
||||
By omitting data-instgrm-captioned we hide the caption/hashtags directly. */}
|
||||
<div className="pointer-events-none">
|
||||
<blockquote
|
||||
className="instagram-media"
|
||||
data-instgrm-permalink={`${permalink}?utm_source=ig_embed&utm_campaign=loading`}
|
||||
data-instgrm-version="14"
|
||||
style={{
|
||||
background: '#FFF',
|
||||
border: 0,
|
||||
margin: 0,
|
||||
maxWidth: '540px',
|
||||
minWidth: '326px',
|
||||
padding: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedPost && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4 overflow-y-auto pt-[100px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedPost(null)}
|
||||
>
|
||||
<motion.div
|
||||
className="relative max-w-lg w-full bg-white dark:bg-stone-900 rounded-xl overflow-hidden my-auto"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button inside modal container */}
|
||||
<button
|
||||
className="absolute top-2 right-2 text-stone-500 bg-white border border-stone-200 shadow-md rounded-full w-8 h-8 flex items-center justify-center hover:bg-stone-100 transition-colors z-[60]"
|
||||
onClick={() => setSelectedPost(null)}
|
||||
>
|
||||
<span className="font-bold">×</span>
|
||||
</button>
|
||||
|
||||
{/* Instagram Embed WITH caption shown in the Lightbox */}
|
||||
<div className="w-full bg-white mt-12 pb-4 px-2">
|
||||
<blockquote
|
||||
className="instagram-media"
|
||||
data-instgrm-captioned
|
||||
data-instgrm-permalink={`${selectedPost}?utm_source=ig_embed&utm_campaign=loading`}
|
||||
data-instgrm-version="14"
|
||||
style={{
|
||||
background: '#FFF',
|
||||
border: 0,
|
||||
boxShadow: 'none',
|
||||
margin: '0',
|
||||
maxWidth: '540px',
|
||||
minWidth: '326px',
|
||||
padding: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstagramFeed;
|
||||
@@ -1,53 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { JOURNAL_ENTRIES } from '../constants';
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { JOURNAL_ENTRIES } from '../constants';
|
||||
|
||||
const JournalSection: React.FC = () => {
|
||||
return (
|
||||
<section className="relative py-32 bg-terracotta-soft dark:bg-black overflow-hidden transition-colors duration-500">
|
||||
<div className="absolute inset-0 z-0 mix-blend-multiply opacity-30">
|
||||
<img
|
||||
alt="Atmospheric studio background"
|
||||
className="w-full h-full object-cover blur-3xl scale-110 grayscale"
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAipMlYLTcRT_hdc3VePfFIlrA56VzZ5G2y3gcRfmIZMERwGFKq2N19Gqo6mw7uZowXmjl2eJ89TI3Mcud2OyOfadO3mPVF_v0sI0OHupqM49WEFcWzH-Wbu3DL6bQ46F2Y8SIAk-NUQy8psjcIdBKRrM8fqdn4eOPANYTXpVxkLMAm4R0Axy4aEKNdmj917ZKKTxvXB-J8nGlITJkJ-ua7XcZOwGnfK5ttzyWW35A0oOSffCf972gmpV27wrVQgYJNLS7UyDdyQIQ"
|
||||
/>
|
||||
</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">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) => (
|
||||
<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}
|
||||
className="w-full h-full object-cover transition-transform duration-[1.5s] group-hover:scale-105"
|
||||
src={entry.image}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors duration-500"></div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-text-muted border border-text-muted/30 px-2 py-1 rounded-full">{entry.category}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-text-muted">{entry.date}</span>
|
||||
</div>
|
||||
<h3 className="font-display text-3xl text-text-main dark:text-white mb-4 leading-tight group-hover:underline decoration-1 underline-offset-4">
|
||||
{entry.title}
|
||||
</h3>
|
||||
<p className="text-sm font-light text-text-muted dark:text-gray-400 leading-loose max-w-sm">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
const getArticleHref = (slug: string) => (
|
||||
slug.startsWith('/editorial/') ? slug : `/editorial/${slug}`
|
||||
);
|
||||
};
|
||||
|
||||
export default JournalSection;
|
||||
return (
|
||||
<section className="relative py-32 bg-terracotta-soft dark:bg-black overflow-hidden transition-colors duration-500">
|
||||
<div className="absolute inset-0 z-0 mix-blend-multiply opacity-30">
|
||||
<img
|
||||
alt="Atmospheric studio background"
|
||||
className="w-full h-full object-cover blur-3xl scale-110 grayscale"
|
||||
src="https://lh3.googleusercontent.com/aida-public/AB6AXuAipMlYLTcRT_hdc3VePfFIlrA56VzZ5G2y3gcRfmIZMERwGFKq2N19Gqo6mw7uZowXmjl2eJ89TI3Mcud2OyOfadO3mPVF_v0sI0OHupqM49WEFcWzH-Wbu3DL6bQ46F2Y8SIAk-NUQy8psjcIdBKRrM8fqdn4eOPANYTXpVxkLMAm4R0Axy4aEKNdmj917ZKKTxvXB-J8nGlITJkJ-ua7XcZOwGnfK5ttzyWW35A0oOSffCf972gmpV27wrVQgYJNLS7UyDdyQIQ"
|
||||
/>
|
||||
</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">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) => (
|
||||
<Link key={entry.id} to={getArticleHref(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}
|
||||
className="w-full h-full object-cover transition-transform duration-[1.5s] group-hover:scale-105"
|
||||
src={entry.image}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors duration-500"></div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-text-muted border border-text-muted/30 px-2 py-1 rounded-full">{entry.category}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-text-muted">{entry.date}</span>
|
||||
</div>
|
||||
<h3 className="font-display text-3xl text-text-main dark:text-white mb-4 leading-tight group-hover:underline decoration-1 underline-offset-4">
|
||||
{entry.title}
|
||||
</h3>
|
||||
<p className="text-sm font-light text-text-muted dark:text-gray-400 leading-loose max-w-sm">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default JournalSection;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const QuoteSection: React.FC = () => {
|
||||
return (
|
||||
<section className="py-32 bg-clay-dark dark:bg-black border-y border-stone-800/50 transition-colors duration-500">
|
||||
<div className="max-w-5xl mx-auto px-6 text-center">
|
||||
<span className="material-symbols-outlined text-4xl mb-8 text-stone-500 font-thin">format_quote</span>
|
||||
<h3 className="font-display text-3xl md:text-5xl font-thin leading-snug text-stone-100 dark:text-stone-200 mb-10 italic">
|
||||
"My pottery is designed to be both beautiful and practical. From minimalist vases to durable dinner plates, each piece has its own character."
|
||||
</h3>
|
||||
<p className="font-body text-xs uppercase tracking-[0.2em] text-stone-400">
|
||||
Anonymous — Verified Collector
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const QuoteSection: React.FC = () => {
|
||||
return (
|
||||
<section className="py-32 bg-clay-dark dark:bg-black border-y border-stone-800/50 transition-colors duration-500">
|
||||
<div className="max-w-5xl mx-auto px-6 text-center">
|
||||
<span className="material-symbols-outlined text-4xl mb-8 text-stone-500 font-thin">format_quote</span>
|
||||
<h3 className="font-display text-3xl md:text-5xl font-thin leading-snug text-stone-100 dark:text-stone-200 mb-10 italic">
|
||||
"My pottery is designed to be both beautiful and practical. From minimalist vases to durable dinner plates, each piece has its own character."
|
||||
</h3>
|
||||
<p className="font-body text-xs uppercase tracking-[0.2em] text-stone-400">
|
||||
Anonymous — Verified Collector
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuoteSection;
|
||||
84
Pottery-website/components/SEO.tsx
Normal file
84
Pottery-website/components/SEO.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// TODO: Update SITE_URL to your actual domain before deploying
|
||||
export const SITE_URL = 'https://knuthceramics.com';
|
||||
export const SITE_NAME = 'KNUTH Ceramics';
|
||||
const DEFAULT_OG_IMAGE = `${SITE_URL}/landingpage/artelier.png`;
|
||||
|
||||
type Schema = Record<string, unknown>;
|
||||
|
||||
interface SEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical?: string;
|
||||
schema?: Schema | Schema[];
|
||||
ogImage?: string;
|
||||
ogType?: 'website' | 'article' | 'product';
|
||||
}
|
||||
|
||||
function setMeta(selector: string, attr: string, value: string) {
|
||||
let el = document.querySelector<HTMLMetaElement>(selector);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
const [attrName, attrVal] = selector.replace('meta[', '').replace(']', '').split('="');
|
||||
el.setAttribute(attrName, attrVal.replace('"', ''));
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute(attr, value);
|
||||
}
|
||||
|
||||
const SEO: React.FC<SEOProps> = ({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
schema,
|
||||
ogImage = DEFAULT_OG_IMAGE,
|
||||
ogType = 'website',
|
||||
}) => {
|
||||
const schemaStr = schema ? JSON.stringify(schema) : null;
|
||||
|
||||
useEffect(() => {
|
||||
const fullTitle = title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}`;
|
||||
document.title = fullTitle;
|
||||
|
||||
setMeta('meta[name="description"]', 'content', description);
|
||||
setMeta('meta[property="og:title"]', 'content', fullTitle);
|
||||
setMeta('meta[property="og:description"]', 'content', description);
|
||||
setMeta('meta[property="og:type"]', 'content', ogType);
|
||||
setMeta('meta[property="og:image"]', 'content', ogImage);
|
||||
setMeta('meta[property="og:site_name"]', 'content', SITE_NAME);
|
||||
setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
|
||||
setMeta('meta[name="twitter:title"]', 'content', fullTitle);
|
||||
setMeta('meta[name="twitter:description"]', 'content', description);
|
||||
setMeta('meta[name="twitter:image"]', 'content', ogImage);
|
||||
|
||||
let canonicalEl = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!canonicalEl) {
|
||||
canonicalEl = document.createElement('link');
|
||||
canonicalEl.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(canonicalEl);
|
||||
}
|
||||
canonicalEl.setAttribute('href', canonical ?? `${SITE_URL}${window.location.pathname}`);
|
||||
|
||||
// Remove previous page-level schemas, inject new ones
|
||||
document.querySelectorAll('script[data-seo="page"]').forEach(el => el.remove());
|
||||
if (schemaStr) {
|
||||
const schemas = Array.isArray(JSON.parse(schemaStr)) ? JSON.parse(schemaStr) : [JSON.parse(schemaStr)];
|
||||
schemas.forEach((s: Schema) => {
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
script.setAttribute('data-seo', 'page');
|
||||
script.textContent = JSON.stringify(s);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.querySelectorAll('script[data-seo="page"]').forEach(el => el.remove());
|
||||
};
|
||||
}, [title, description, canonical, ogImage, ogType, schemaStr]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SEO;
|
||||
Reference in New Issue
Block a user