Merge pull request 'Fix : added new pages' (#4) from feature/coming_soon into main
continuous-integration/drone Build is failing
Details
continuous-integration/drone Build is failing
Details
Reviewed-on: #4
This commit is contained in:
commit
32cd082de3
24
src/App.tsx
24
src/App.tsx
|
|
@ -1,26 +1,22 @@
|
||||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
// import VintageComingSoonPage from "./components/comingsoon/comingsoon";
|
import VintageComingSoonPage from "./components/comingsoon/comingsoon";
|
||||||
// import DarkProductShowcase from "./components/product/product";
|
import DarkProductShowcase from "./components/product/product";
|
||||||
// import BlogPage from "./components/blogs/BlogPage";
|
import BlogPage from "./components/blogs/BlogPage";
|
||||||
|
import WebsiteHeader from "./components/header/WebsiteHeader";
|
||||||
// import BlogCard from "./components/blogs/BlogCard";
|
// import BlogCard from "./components/blogs/BlogCard";
|
||||||
import WeddingGallery from "./WeddingGallery/WeddingGallery";
|
|
||||||
// import OtherPage from "./pages/OtherPage"; // example if you add more pages
|
// import OtherPage from "./pages/OtherPage"; // example if you add more pages
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
|
<WebsiteHeader />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Default route */}
|
<Route path="/" element={<DarkProductShowcase />} />
|
||||||
{/* <Route path="/" element={<VintageComingSoonPage />} /> */}
|
<Route path="/home" element={<DarkProductShowcase />} />
|
||||||
<Route path="/" element={<WeddingGallery />} />
|
<Route path="/coming-soon" element={<VintageComingSoonPage />} />
|
||||||
|
<Route path="/blog" element={<BlogPage />} />
|
||||||
{/* Example extra routes */}
|
<Route path="/journal" element={<BlogPage />} />
|
||||||
{/* <Route path="/home" element={<DarkProductShowcase />} />
|
|
||||||
<Route path="/blog" element={<BlogPage />} /> */}
|
|
||||||
<Route path="/weddingGallery" element={<WeddingGallery />} />
|
|
||||||
|
|
||||||
{/* <Route path="/blog" element={<BlogCard post={undefined} index={0} />} /> */}
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// Gallery.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import SafeGrid from './SafeGrid';
|
|
||||||
import { WeddingPhoto } from './types';
|
|
||||||
import { Box, Card, CardContent, CardMedia, Chip, IconButton, Typography } from '@mui/material';
|
|
||||||
import { Download } from '@mui/icons-material';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
photos: WeddingPhoto[];
|
|
||||||
onDownload?: (url: string, title?: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Gallery: React.FC<Props> = ({ photos, onDownload }) => {
|
|
||||||
return (
|
|
||||||
<SafeGrid container spacing={3}>
|
|
||||||
{photos.map((p) => (
|
|
||||||
<SafeGrid item xs={12} sm={6} md={4} key={p.id}>
|
|
||||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<CardMedia component="img" height="250" image={p.url} alt={p.title} sx={{ objectFit: 'cover' }} />
|
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8 }}>
|
|
||||||
<IconButton size="small" onClick={() => onDownload?.(p.url, p.title)} sx={{ color: 'white', bgcolor: 'rgba(0,0,0,0.6)' }}>
|
|
||||||
<Download fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<CardContent sx={{ bgcolor: '#fffaf0', flexGrow: 1 }}>
|
|
||||||
<Typography variant="h6">{p.title || 'Untitled'}</Typography>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Chip label={p.title ?? '—'} size="small" color="primary" variant="outlined" />
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{p.created_at ? new Date(p.created_at).toLocaleDateString() : p.date ? new Date(p.date).toLocaleDateString() : ''}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</SafeGrid>
|
|
||||||
))}
|
|
||||||
</SafeGrid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Gallery;
|
|
||||||
|
|
@ -1,592 +0,0 @@
|
||||||
// LightboxModal.tsx
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
IconButton,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Tooltip,
|
|
||||||
CircularProgress,
|
|
||||||
} from '@mui/material';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
// import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import RemoveIcon from '@mui/icons-material/Remove';
|
|
||||||
import FitScreenIcon from '@mui/icons-material/FitScreen';
|
|
||||||
|
|
||||||
export type LightboxPhoto = {
|
|
||||||
id: number | string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
filename?: string;
|
|
||||||
created_at?: string;
|
|
||||||
naturalWidth?: number;
|
|
||||||
naturalHeight?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
photos: LightboxPhoto[];
|
|
||||||
startIndex?: number;
|
|
||||||
onClose: () => void;
|
|
||||||
startIndexCallback?: (idx: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 40;
|
|
||||||
const MIN_SCALE = 0.1;
|
|
||||||
const MAX_SCALE = 5;
|
|
||||||
const SCALE_STEP = 0.25;
|
|
||||||
|
|
||||||
export default function LightboxModal({
|
|
||||||
open,
|
|
||||||
photos,
|
|
||||||
startIndex = 0,
|
|
||||||
onClose,
|
|
||||||
startIndexCallback,
|
|
||||||
}: Props) {
|
|
||||||
const [index, setIndex] = useState<number>(startIndex);
|
|
||||||
const [scale, setScale] = useState<number>(1);
|
|
||||||
const [translate, setTranslate] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
||||||
const panStart = useRef<{ x: number; y: number } | null>(null);
|
|
||||||
const lastTranslate = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
const touchStartX = useRef<number | null>(null);
|
|
||||||
const touchDeltaX = useRef(0);
|
|
||||||
|
|
||||||
// For pinch
|
|
||||||
const pinchStartDist = useRef<number | null>(null);
|
|
||||||
const pinchStartScale = useRef<number>(1);
|
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => setIndex(startIndex), [startIndex, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, index, scale]);
|
|
||||||
|
|
||||||
useEffect(() => startIndexCallback?.(index), [index]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// reset scale/translate when changing image or closing/opening
|
|
||||||
setScale(1);
|
|
||||||
setTranslate({ x: 0, y: 0 });
|
|
||||||
lastTranslate.current = { x: 0, y: 0 };
|
|
||||||
setIsLoading(true);
|
|
||||||
setImageDimensions(null);
|
|
||||||
}, [index, open]);
|
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (!open) return;
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
if (e.key === 'ArrowLeft') prev();
|
|
||||||
if (e.key === 'ArrowRight') next();
|
|
||||||
if (e.key === '+' || (e.ctrlKey && e.key === '=')) zoomIn();
|
|
||||||
if (e.key === '-') zoomOut();
|
|
||||||
if (e.key === '0') fitToScreen();
|
|
||||||
};
|
|
||||||
|
|
||||||
const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length);
|
|
||||||
const next = () => setIndex((i) => (i + 1) % photos.length);
|
|
||||||
|
|
||||||
// Preload neighbors
|
|
||||||
useEffect(() => {
|
|
||||||
if (!photos?.length) return;
|
|
||||||
const a = new Image();
|
|
||||||
a.src = photos[(index - 1 + photos.length) % photos.length]?.url;
|
|
||||||
const b = new Image();
|
|
||||||
b.src = photos[(index + 1) % photos.length]?.url;
|
|
||||||
}, [index, photos]);
|
|
||||||
|
|
||||||
const photo = photos && photos.length ? photos[index] : null;
|
|
||||||
|
|
||||||
// Handle image load
|
|
||||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
const img = e.currentTarget;
|
|
||||||
setImageDimensions({
|
|
||||||
width: img.naturalWidth,
|
|
||||||
height: img.naturalHeight
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setImageDimensions(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!photo) return null;
|
|
||||||
|
|
||||||
/* ============================
|
|
||||||
Zoom / Pan Controls
|
|
||||||
============================ */
|
|
||||||
const setScaleClamped = (s: number) => {
|
|
||||||
const clamped = Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
|
|
||||||
setScale(clamped);
|
|
||||||
if (clamped === 1) {
|
|
||||||
// reset translate when fully fit
|
|
||||||
setTranslate({ x: 0, y: 0 });
|
|
||||||
lastTranslate.current = { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomIn = () => setScaleClamped(scale + SCALE_STEP);
|
|
||||||
const zoomOut = () => setScaleClamped(scale - SCALE_STEP);
|
|
||||||
const fitToScreen = () => setScaleClamped(1);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const handleDoubleClick = (_e: React.MouseEvent) => {
|
|
||||||
if (scale === 1) setScaleClamped(2);
|
|
||||||
else setScaleClamped(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ============================
|
|
||||||
Panning (pointer / touch)
|
|
||||||
============================ */
|
|
||||||
const onPointerDown = (e: React.PointerEvent) => {
|
|
||||||
if (scale <= 1) return;
|
|
||||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
|
||||||
setIsPanning(true);
|
|
||||||
panStart.current = { x: e.clientX, y: e.clientY };
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (e: React.PointerEvent) => {
|
|
||||||
if (!isPanning || !panStart.current) return;
|
|
||||||
const dx = e.clientX - panStart.current.x;
|
|
||||||
const dy = e.clientY - panStart.current.y;
|
|
||||||
const TX = lastTranslate.current.x + dx;
|
|
||||||
const TY = lastTranslate.current.y + dy;
|
|
||||||
setTranslate({ x: TX, y: TY });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = (e: React.PointerEvent) => {
|
|
||||||
if (!isPanning) return;
|
|
||||||
setIsPanning(false);
|
|
||||||
if (panStart.current) {
|
|
||||||
const dx = e.clientX - panStart.current.x;
|
|
||||||
const dy = e.clientY - panStart.current.y;
|
|
||||||
lastTranslate.current = { x: lastTranslate.current.x + dx, y: lastTranslate.current.y + dy };
|
|
||||||
panStart.current = null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
(e.target as Element).releasePointerCapture?.(e.pointerId);
|
|
||||||
} catch {
|
|
||||||
//ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onWheel = (e: React.WheelEvent) => {
|
|
||||||
if (!open) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = -e.deltaY;
|
|
||||||
const step = delta > 0 ? SCALE_STEP : -SCALE_STEP;
|
|
||||||
setScaleClamped(scale + step);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ============================
|
|
||||||
Touch handlers for swipe + pinch
|
|
||||||
Note: use React.Touch types here to satisfy TS
|
|
||||||
============================ */
|
|
||||||
const onTouchStart = (e: React.TouchEvent) => {
|
|
||||||
touchDeltaX.current = 0;
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
touchStartX.current = e.touches[0].clientX;
|
|
||||||
if (scale > 1) {
|
|
||||||
panStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
||||||
setIsPanning(true);
|
|
||||||
}
|
|
||||||
} else if (e.touches.length === 2) {
|
|
||||||
pinchStartDist.current = getDistance(e.touches[0], e.touches[1]);
|
|
||||||
pinchStartScale.current = scale;
|
|
||||||
setIsPanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTouchMove = (e: React.TouchEvent) => {
|
|
||||||
if (e.touches.length === 1 && scale > 1 && isPanning && panStart.current) {
|
|
||||||
const dx = e.touches[0].clientX - panStart.current.x;
|
|
||||||
const dy = e.touches[0].clientY - panStart.current.y;
|
|
||||||
setTranslate({ x: lastTranslate.current.x + dx, y: lastTranslate.current.y + dy });
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (e.touches.length === 2 && pinchStartDist.current != null) {
|
|
||||||
const dist = getDistance(e.touches[0], e.touches[1]);
|
|
||||||
const ratio = dist / (pinchStartDist.current || dist);
|
|
||||||
setScaleClamped(pinchStartScale.current * ratio);
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (e.touches.length === 1) {
|
|
||||||
const x = e.touches[0].clientX;
|
|
||||||
touchDeltaX.current = x - (touchStartX.current ?? x);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const onTouchEnd = (_e: React.TouchEvent) => {
|
|
||||||
if (isPanning) {
|
|
||||||
setIsPanning(false);
|
|
||||||
lastTranslate.current = { ...translate };
|
|
||||||
panStart.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pinchStartDist.current != null) {
|
|
||||||
pinchStartDist.current = null;
|
|
||||||
pinchStartScale.current = scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(touchDeltaX.current) > SWIPE_THRESHOLD && Math.abs(translate.x) < 10 && !isPanning) {
|
|
||||||
if (touchDeltaX.current < 0) next();
|
|
||||||
else prev();
|
|
||||||
}
|
|
||||||
|
|
||||||
touchDeltaX.current = 0;
|
|
||||||
touchStartX.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper accepting React.Touch (TS-friendly)
|
|
||||||
function getDistance(a: React.Touch, b: React.Touch) {
|
|
||||||
const dx = a.clientX - b.clientX;
|
|
||||||
const dy = a.clientY - b.clientY;
|
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate image display size based on original dimensions
|
|
||||||
const getImageDisplayStyle = () => {
|
|
||||||
if (!imageDimensions) return {};
|
|
||||||
|
|
||||||
const windowWidth = window.innerWidth;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const { width: imgWidth, height: imgHeight } = imageDimensions;
|
|
||||||
|
|
||||||
// Calculate the maximum dimensions to fit in the viewport
|
|
||||||
const maxWidth = windowWidth * 0.9; // 90% of window width
|
|
||||||
const maxHeight = windowHeight * 0.8; // 80% of window height
|
|
||||||
|
|
||||||
let displayWidth = imgWidth;
|
|
||||||
let displayHeight = imgHeight;
|
|
||||||
|
|
||||||
// If image is larger than max dimensions, scale it down
|
|
||||||
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
|
||||||
const widthRatio = maxWidth / imgWidth;
|
|
||||||
const heightRatio = maxHeight / imgHeight;
|
|
||||||
const ratio = Math.min(widthRatio, heightRatio);
|
|
||||||
|
|
||||||
displayWidth = imgWidth * ratio;
|
|
||||||
displayHeight = imgHeight * ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: `${displayWidth}px`,
|
|
||||||
height: `${displayHeight}px`,
|
|
||||||
maxWidth: '90vw',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ============================
|
|
||||||
Render
|
|
||||||
============================ */
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
fullScreen
|
|
||||||
PaperProps={{
|
|
||||||
sx: {
|
|
||||||
m: 0,
|
|
||||||
p: 0,
|
|
||||||
height: '100vh',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.95)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
'& .MuiDialog-container': {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
userSelect: isPanning ? 'none' : 'auto',
|
|
||||||
touchAction: 'none',
|
|
||||||
}}
|
|
||||||
onWheel={onWheel}
|
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchMove={onTouchMove}
|
|
||||||
onTouchEnd={onTouchEnd}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Loading indicator */}
|
|
||||||
{isLoading && (
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
zIndex: 10
|
|
||||||
}}>
|
|
||||||
<CircularProgress sx={{ color: 'rgba(255,255,255,0.7)' }} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top right controls */}
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 60,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 1,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 0.5,
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
}}>
|
|
||||||
<Tooltip title="Zoom Out">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
zoomOut();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RemoveIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Fit to screen">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
fitToScreen();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FitScreenIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Zoom In">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
zoomIn();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Download">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
try {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = photo.url;
|
|
||||||
a.download = `${photo.filename ?? photo.title ?? 'image'}`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('download', err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip title="Close (Esc)">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255,255,255,0.1)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Prev / Next arrows */}
|
|
||||||
<IconButton
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
prev();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 16,
|
|
||||||
zIndex: 50,
|
|
||||||
color: 'white',
|
|
||||||
bgcolor: 'rgba(0,0,0,0.5)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowBackIosNewIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
next();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
zIndex: 50,
|
|
||||||
color: 'white',
|
|
||||||
bgcolor: 'rgba(0,0,0,0.5)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowForwardIosIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
{/* Image container */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
px: 2,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
touchAction: 'none',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
ref={imageRef}
|
|
||||||
src={photo.url}
|
|
||||||
alt={photo.title ?? photo.filename}
|
|
||||||
onLoad={handleImageLoad}
|
|
||||||
onError={handleImageError}
|
|
||||||
style={{
|
|
||||||
display: isLoading ? 'none' : 'block',
|
|
||||||
width: 'auto',
|
|
||||||
height: 'auto',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
objectFit: 'contain',
|
|
||||||
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
|
||||||
transition: isPanning ? 'none' : 'transform 120ms ease',
|
|
||||||
cursor: scale > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
|
|
||||||
borderRadius: 4,
|
|
||||||
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
|
||||||
touchAction: 'none',
|
|
||||||
...getImageDisplayStyle(),
|
|
||||||
}}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Caption */}
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 50,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 1.5,
|
|
||||||
maxWidth: '600px',
|
|
||||||
mx: 'auto',
|
|
||||||
}}>
|
|
||||||
<Typography variant="subtitle1" color="white" noWrap sx={{ fontWeight: 500 }}>
|
|
||||||
{photo.title ?? photo.filename}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="rgba(255,255,255,0.85)" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
{index + 1} of {photos.length}
|
|
||||||
{photo.created_at && ` • ${new Date(photo.created_at).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})}`}
|
|
||||||
{imageDimensions && ` • ${imageDimensions.width}×${imageDimensions.height}`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Zoom level indicator */}
|
|
||||||
{scale !== 1 && (
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 16,
|
|
||||||
left: 16,
|
|
||||||
zIndex: 50,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
borderRadius: 2,
|
|
||||||
p: 1,
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{Math.round(scale * 100)}%
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
// PreviewTester.tsx
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Box, Button, Card, CardMedia, CardContent, Typography } from '@mui/material';
|
|
||||||
import { useDropzone } from 'react-dropzone';
|
|
||||||
|
|
||||||
type Item = { id: string; file: File; previewUrl: string };
|
|
||||||
|
|
||||||
export default function PreviewTester() {
|
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
||||||
console.log('onDrop called, acceptedFiles:', acceptedFiles);
|
|
||||||
const now = Date.now();
|
|
||||||
const newItems = acceptedFiles.map((f, i) => ({
|
|
||||||
id: `${now}-${i}-${f.name}`,
|
|
||||||
file: f,
|
|
||||||
previewUrl: URL.createObjectURL(f),
|
|
||||||
}));
|
|
||||||
setItems((prev) => [...newItems, ...prev]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, multiple: true, accept: { 'image/*': [] } });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
items.forEach((it) => {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(it.previewUrl);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 3 }}>
|
|
||||||
<Box {...getRootProps()} sx={{ border: '2px dashed', p: 2, textAlign: 'center', mb: 2 }}>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<Typography>{isDragActive ? 'Drop images here' : 'Drag & drop images (PreviewTester)'}</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
{items.map((it) => (
|
|
||||||
<Card key={it.id} sx={{ width: 160 }}>
|
|
||||||
<CardMedia component="img" height="100" image={it.previewUrl} alt={it.file.name} />
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="caption" noWrap>{it.file.name}</Typography>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Button onClick={() => { console.log('items state:', items); alert(`Items: ${items.length}`); }}>Console.log items</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// SafeGrid.tsx
|
|
||||||
import Grid from '@mui/material/Grid';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const SafeGrid = Grid as any;
|
|
||||||
export default SafeGrid;
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
// UploadQueue.tsx
|
|
||||||
import React, { useCallback } from 'react';
|
|
||||||
import { Box, Button, Card, CardContent, CardMedia, IconButton, LinearProgress, Typography } from '@mui/material';
|
|
||||||
import { Delete, Replay } from '@mui/icons-material';
|
|
||||||
import { FileQueueItem, WeddingPhoto } from './types';
|
|
||||||
import { uploadFileXHR, toAbsoluteUrl } from './apis';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
queue: FileQueueItem[];
|
|
||||||
setQueue: React.Dispatch<React.SetStateAction<FileQueueItem[]>>;
|
|
||||||
newPhotoTitle?: string;
|
|
||||||
onUploaded?: (photo: WeddingPhoto) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UploadQueue: React.FC<Props> = ({ queue, setQueue, newPhotoTitle, onUploaded }) => {
|
|
||||||
const uploadSingle = useCallback(
|
|
||||||
async (item: FileQueueItem) => {
|
|
||||||
setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: 'uploading', progress: 0, error: undefined } : q)));
|
|
||||||
const res = await uploadFileXHR(
|
|
||||||
item.file,
|
|
||||||
(pct) => setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, progress: pct } : q))),
|
|
||||||
newPhotoTitle
|
|
||||||
);
|
|
||||||
if (res.success) {
|
|
||||||
const it = res.data;
|
|
||||||
const uploadedPhoto: WeddingPhoto = {
|
|
||||||
id: it.id ?? it._id,
|
|
||||||
url: toAbsoluteUrl(it.path ?? it.url),
|
|
||||||
title: it.title ?? it.filename ?? item.file.name,
|
|
||||||
date: it.created_at ?? it.createdAt,
|
|
||||||
filename: it.filename,
|
|
||||||
path: it.path,
|
|
||||||
created_at: it.created_at ?? it.createdAt,
|
|
||||||
};
|
|
||||||
setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: 'done', progress: 100, uploadedPhoto } : q)));
|
|
||||||
onUploaded?.(uploadedPhoto);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: 'error', error: res.error } : q)));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[newPhotoTitle, setQueue, onUploaded]
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadAll = useCallback(async () => {
|
|
||||||
for (const item of queue.filter((q) => q.status === 'pending' || q.status === 'error')) {
|
|
||||||
|
|
||||||
await uploadSingle(item);
|
|
||||||
}
|
|
||||||
}, [queue, uploadSingle]);
|
|
||||||
|
|
||||||
const retryOne = (id: string) => {
|
|
||||||
const item = queue.find((q) => q.id === id);
|
|
||||||
if (item) uploadSingle(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeOne = (id: string) => {
|
|
||||||
const item = queue.find((q) => q.id === id);
|
|
||||||
if (item) URL.revokeObjectURL(item.previewUrl);
|
|
||||||
setQueue((prev) => prev.filter((q) => q.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
|
||||||
<Button onClick={uploadAll} disabled={!queue.some((q) => q.status === 'pending' || q.status === 'error')} variant="contained">
|
|
||||||
Upload All
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
|
||||||
{queue.map((q) => (
|
|
||||||
<Card key={q.id} sx={{ width: 180 }}>
|
|
||||||
<CardMedia component="img" height={110} image={q.previewUrl} />
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="body2" noWrap>
|
|
||||||
{q.file.name}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{q.status === 'uploading' && <Typography variant="caption">Uploading — {q.progress}%</Typography>}
|
|
||||||
{q.status === 'pending' && <Typography variant="caption">Pending</Typography>}
|
|
||||||
{q.status === 'done' && <Typography variant="caption" color="success.main">Uploaded</Typography>}
|
|
||||||
{q.status === 'error' && <Typography variant="caption" color="error.main">{q.error ?? 'Upload failed'}</Typography>}
|
|
||||||
<LinearProgress variant="determinate" value={q.progress} sx={{ mt: 1 }} />
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
|
||||||
{q.status === 'error' && <IconButton size="small" onClick={() => retryOne(q.id)}><Replay fontSize="small" /></IconButton>}
|
|
||||||
<IconButton size="small" onClick={() => removeOne(q.id)}><Delete fontSize="small" /></IconButton>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UploadQueue;
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,118 +0,0 @@
|
||||||
// apis.ts
|
|
||||||
const MFS_API_BASE = 'https://mfs-api.midastix.com';
|
|
||||||
|
|
||||||
export const toAbsoluteUrl = (path?: string) => {
|
|
||||||
if (!path) return '';
|
|
||||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
|
||||||
return `${MFS_API_BASE}${path}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* uploadFileXHR(file, onProgress, title?)
|
|
||||||
* returns { success, data?, error? }
|
|
||||||
*/
|
|
||||||
export const uploadFileXHR = (
|
|
||||||
file: File,
|
|
||||||
onProgress: (pct: number) => void,
|
|
||||||
title?: string
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
): Promise<{ success: boolean; data?: any; error?: string }> =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
const url = `${MFS_API_BASE}/image-upload/single`;
|
|
||||||
xhr.open('POST', url, true);
|
|
||||||
xhr.setRequestHeader('Accept', 'application/json');
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (ev) => {
|
|
||||||
if (!ev.lengthComputable) return;
|
|
||||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
|
||||||
onProgress(pct);
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
resolve({ success: true, data: json });
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
} catch (e) {
|
|
||||||
resolve({ success: true, data: xhr.responseText });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const txt = xhr.responseText || `HTTP ${xhr.status}`;
|
|
||||||
resolve({ success: false, error: txt });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => resolve({ success: false, error: 'Network error' });
|
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file, file.name);
|
|
||||||
if (title) fd.append('title', title);
|
|
||||||
xhr.send(fd);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
resolve({ success: false, error: err?.message ?? String(err) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* tolerant fetchImages mapping */
|
|
||||||
export const fetchImagesApi = async (title = '') => {
|
|
||||||
const url =
|
|
||||||
title && title.trim() !== '' ? `${MFS_API_BASE}/image-upload?title=${encodeURIComponent(title)}` : `${MFS_API_BASE}/image-upload`;
|
|
||||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(txt || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const body = await res.json();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let imagesArray: any[] = [];
|
|
||||||
if (Array.isArray(body)) imagesArray = body;
|
|
||||||
else if (Array.isArray(body.images)) imagesArray = body.images;
|
|
||||||
else if (body.data && Array.isArray(body.data.images)) imagesArray = body.data.images;
|
|
||||||
else {
|
|
||||||
const firstArray = Object.values(body).find((v) => Array.isArray(v));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
if (Array.isArray(firstArray)) imagesArray = firstArray as any[];
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return imagesArray.map((it: any) => ({
|
|
||||||
id: it.id ?? it._id ?? `${it.filename ?? Math.random()}`,
|
|
||||||
url: toAbsoluteUrl(it.path ?? it.url),
|
|
||||||
title: it.title ?? it.filename ?? '',
|
|
||||||
date: it.created_at ?? it.createdAt ?? it.date ?? '',
|
|
||||||
filename: it.filename,
|
|
||||||
path: it.path,
|
|
||||||
created_at: it.created_at ?? it.createdAt,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Delete photo (by id) */
|
|
||||||
export const deletePhotoApi = async (filename: string) => {
|
|
||||||
const url = `${MFS_API_BASE}/image-upload/${filename}`;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { accept: "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return text || "deleted";
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/* Delete folder by title (assumed endpoint) */
|
|
||||||
export const deleteFolderApi = async (title: string) => {
|
|
||||||
const url = `${MFS_API_BASE}/image-upload/${encodeURIComponent(title)}`;
|
|
||||||
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
|
||||||
return text || 'folder deleted';
|
|
||||||
};
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
// FolderModal.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { Box, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Card, CardMedia, IconButton, Typography } from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import { WeddingPhoto } from './types';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
photos: WeddingPhoto[];
|
|
||||||
onDeletePhoto: (id: number | string) => Promise<void>;
|
|
||||||
deletingId?: number | string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FolderModal: React.FC<Props> = ({ open, onClose, photos, onDeletePhoto, deletingId }) => {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
|
||||||
<DialogTitle>Manage Photos ({photos.length})</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
{photos.length === 0 ? (
|
|
||||||
<Typography variant="body2">No photos in this event.</Typography>
|
|
||||||
) : (
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{photos.map((p) => (
|
|
||||||
<Grid key={p.id}>
|
|
||||||
<Card>
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<CardMedia component="img" height="140" image={p.url} />
|
|
||||||
<IconButton
|
|
||||||
sx={{ position: 'absolute', top: 6, right: 6, bgcolor: 'rgba(255,255,255,0.8)' }}
|
|
||||||
onClick={() => onDeletePhoto(p.id)}
|
|
||||||
disabled={deletingId === p.id}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ p: 1 }}>
|
|
||||||
<Typography variant="caption" noWrap>{p.filename ?? p.title}</Typography>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} variant="outlined">Close</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderModal;
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// FolderGrid.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import SafeGrid from './SafeGrid';
|
|
||||||
import { FolderInfo } from './types';
|
|
||||||
import { Box, Card, CardContent, CardMedia, IconButton, Paper, Typography } from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
folders: FolderInfo[];
|
|
||||||
onOpen: (title: string) => void;
|
|
||||||
onDeleteFolder: (title: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FolderGrid: React.FC<Props> = ({ folders, onOpen, onDeleteFolder }) => {
|
|
||||||
if (!folders.length) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 6, textAlign: 'center' }}>
|
|
||||||
<Typography variant="h6" color="text.secondary">No events found</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">Upload photos to create events (folders).</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeGrid container spacing={3}>
|
|
||||||
{folders.map((f) => (
|
|
||||||
<SafeGrid item xs={12} sm={6} md={4} key={f.title}>
|
|
||||||
<Card sx={{ cursor: 'pointer', transition: 'transform .15s', '&:hover': { transform: 'translateY(-6px)' } }}>
|
|
||||||
<Box onClick={() => onOpen(f.title)}>
|
|
||||||
<Box sx={{ height: 200, overflow: 'hidden' }}>
|
|
||||||
<CardMedia component="img" image={f.thumb ?? ''} alt={f.title} sx={{ width: '100%', height: 200, objectFit: 'cover', backgroundColor: '#f4f6f7' }} />
|
|
||||||
</Box>
|
|
||||||
<CardContent>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle1" noWrap>{f.title}</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">{f.count} photos</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<IconButton edge="end" size="small" aria-label="delete folder" onClick={(e) => { e.stopPropagation(); onDeleteFolder(f.title); }}>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
|
||||||
</SafeGrid>
|
|
||||||
))}
|
|
||||||
</SafeGrid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderGrid;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// types.ts
|
|
||||||
export type WeddingPhoto = {
|
|
||||||
id: number | string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
date?: string;
|
|
||||||
filename?: string;
|
|
||||||
path?: string;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FileQueueItem = {
|
|
||||||
id: string;
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
progress: number;
|
|
||||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
|
||||||
error?: string;
|
|
||||||
uploadedPhoto?: WeddingPhoto;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FolderInfo = {
|
|
||||||
title: string;
|
|
||||||
count: number;
|
|
||||||
thumb?: string;
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,488 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Box, Button, Chip, Container, Stack, Typography } from "@mui/material";
|
||||||
|
import { keyframes } from "@emotion/react";
|
||||||
|
import { styled } from "@mui/system";
|
||||||
|
import heroImage from "../../assets/solo1.jpg";
|
||||||
|
import accentImage from "../../assets/bag2.jpg";
|
||||||
|
import secondHeroImage from "../../assets/solo2.jpg";
|
||||||
|
import thirdHeroImage from "../../assets/bag1.jpg";
|
||||||
|
import logo from "../../assets/logobgremove.png";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
mist: "#f8f1ea",
|
||||||
|
blush: "#f4ddd4",
|
||||||
|
peach: "#efb7a2",
|
||||||
|
coral: "#d9826f",
|
||||||
|
berry: "#7f4b5d",
|
||||||
|
plum: "#4f2f42",
|
||||||
|
gold: "#c99652",
|
||||||
|
ivory: "#fffaf5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const drift = keyframes`
|
||||||
|
0% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
50% { transform: translate3d(0, -18px, 0) scale(1.04); }
|
||||||
|
100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rise = keyframes`
|
||||||
|
from { opacity: 0; transform: translateY(28px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scaleIn = keyframes`
|
||||||
|
from { transform: scale(0.8); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rotateIn = keyframes`
|
||||||
|
from { transform: rotate(5deg) scale(0.9); opacity: 0; }
|
||||||
|
to { transform: rotate(0deg) scale(1); opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const flipIn = keyframes`
|
||||||
|
0% { transform: perspective(400px) rotateY(90deg); opacity: 0; }
|
||||||
|
100% { transform: perspective(400px) rotateY(0deg); opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fadeInImage = keyframes`
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fadeOutImage = keyframes`
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const float = keyframes`
|
||||||
|
0% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
100% { transform: translateY(0px); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const glow = keyframes`
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(201, 150, 82, 0.35); }
|
||||||
|
70% { box-shadow: 0 0 0 18px rgba(201, 150, 82, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(201, 150, 82, 0); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const shimmer = keyframes`
|
||||||
|
0% { transform: translateX(-120%); }
|
||||||
|
100% { transform: translateX(120%); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroShell = styled(Box)({
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "36px",
|
||||||
|
background: `linear-gradient(135deg, ${colors.ivory} 0%, ${colors.blush} 42%, ${colors.mist} 100%)`,
|
||||||
|
border: `1px solid ${colors.peach}55`,
|
||||||
|
boxShadow: "0 30px 70px rgba(79, 47, 66, 0.12)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const GlassCard = styled(Box)({
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: "28px",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "rgba(255, 250, 245, 0.72)",
|
||||||
|
backdropFilter: "blur(14px)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.55)",
|
||||||
|
boxShadow: "0 24px 50px rgba(79, 47, 66, 0.12)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const VintageImageFrame = styled(Box)({
|
||||||
|
border: `12px solid ${colors.ivory}`,
|
||||||
|
boxShadow: `8px 8px 0 ${colors.berry}45, 0 4px 8px rgba(0,0,0,0.1)`,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "28px",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
top: "-15px",
|
||||||
|
left: "-15px",
|
||||||
|
right: "-15px",
|
||||||
|
bottom: "-15px",
|
||||||
|
border: `1px solid ${colors.peach}88`,
|
||||||
|
zIndex: -1,
|
||||||
|
opacity: 0.7,
|
||||||
|
borderRadius: "34px",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PrimaryButton = styled(Button)({
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "14px 28px",
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
background: `linear-gradient(135deg, ${colors.plum}, ${colors.berry})`,
|
||||||
|
color: colors.ivory,
|
||||||
|
boxShadow: "0 16px 34px rgba(79, 47, 66, 0.24)",
|
||||||
|
animation: `${glow} 3.2s infinite`,
|
||||||
|
"&:hover": {
|
||||||
|
background: `linear-gradient(135deg, ${colors.berry}, ${colors.coral})`,
|
||||||
|
transform: "translateY(-2px)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const SecondaryButton = styled(Button)({
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "14px 28px",
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: colors.plum,
|
||||||
|
border: `1px solid ${colors.peach}`,
|
||||||
|
background: "rgba(255,255,255,0.45)",
|
||||||
|
"&:hover": {
|
||||||
|
background: "rgba(255,255,255,0.8)",
|
||||||
|
borderColor: colors.gold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const RevealBox = styled(Box)({
|
||||||
|
opacity: 0,
|
||||||
|
animation: `${rise} 0.9s ease forwards`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FloatingOrb = styled(Box)<{
|
||||||
|
delay?: string;
|
||||||
|
size: number;
|
||||||
|
top?: string;
|
||||||
|
left?: string;
|
||||||
|
right?: string;
|
||||||
|
bottom?: string;
|
||||||
|
}>(({ delay = "0s", size, top, left, right, bottom }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at 30% 30%, rgba(255,255,255,0.8), rgba(217,130,111,0.18) 55%, rgba(127,75,93,0.12) 100%)",
|
||||||
|
filter: "blur(2px)",
|
||||||
|
animation: `${drift} 8s ease-in-out infinite`,
|
||||||
|
animationDelay: delay,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StoryHeader: React.FC = () => {
|
||||||
|
const heroImages = [heroImage, secondHeroImage, thirdHeroImage, accentImage];
|
||||||
|
const heroAnimations = [scaleIn, rotateIn, flipIn];
|
||||||
|
const [currentHeroImage, setCurrentHeroImage] = useState(0);
|
||||||
|
const [previousHeroImage, setPreviousHeroImage] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentHeroImage((prev) => {
|
||||||
|
setPreviousHeroImage(prev);
|
||||||
|
return (prev + 1) % heroImages.length;
|
||||||
|
});
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [heroImages.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousHeroImage === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPreviousHeroImage(null);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [previousHeroImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
maxWidth="xl"
|
||||||
|
sx={{ position: "relative", zIndex: 1, pt: { xs: 3, md: 4 }, pb: { xs: 4, md: 6 } }}
|
||||||
|
>
|
||||||
|
<HeroShell>
|
||||||
|
<FloatingOrb size={180} top="-40px" right="-30px" />
|
||||||
|
<FloatingOrb size={130} bottom="18%" left="-45px" delay="1.2s" />
|
||||||
|
<FloatingOrb size={96} top="20%" right="42%" delay="2.1s" />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(120deg, rgba(255,255,255,0) 20%, rgba(255,255,255,0.38) 50%, rgba(255,255,255,0) 80%)",
|
||||||
|
opacity: 0.6,
|
||||||
|
animation: `${shimmer} 5.8s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
px: { xs: 2.5, sm: 4, md: 6 },
|
||||||
|
py: { xs: 4, md: 5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RevealBox sx={{ animationDelay: "0.05s" }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
alignItems={{ xs: "flex-start", sm: "center" }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
spacing={2}
|
||||||
|
sx={{ mb: { xs: 4, md: 5 } }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={logo}
|
||||||
|
alt="The Kalawati"
|
||||||
|
sx={{
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
objectFit: "contain",
|
||||||
|
filter: "drop-shadow(0 8px 16px rgba(79,47,66,0.16))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ textAlign: "left" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
fontSize: "1.15rem",
|
||||||
|
color: colors.plum,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
THE KALAWATI
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.berry,
|
||||||
|
fontSize: "0.86rem",
|
||||||
|
letterSpacing: "0.14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Artisanal Storytelling Goods
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||||
|
{["Hand-dyed", "Slow-made", "Soulful color"].map((item) => (
|
||||||
|
<Chip
|
||||||
|
key={item}
|
||||||
|
label={item}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: "rgba(255,255,255,0.55)",
|
||||||
|
color: colors.plum,
|
||||||
|
border: `1px solid ${colors.peach}88`,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</RevealBox>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", lg: "1.05fr 0.95fr" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: { xs: 4, md: 5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RevealBox sx={{ animationDelay: "0.18s" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
color: colors.coral,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
letterSpacing: "0.22em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A poetic storefront for crafted living
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
color: colors.plum,
|
||||||
|
fontSize: { xs: "2.8rem", sm: "3.4rem", md: "4.9rem" },
|
||||||
|
lineHeight: { xs: 1.05, md: 0.98 },
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
maxWidth: "10ch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stories woven into every beautiful piece.
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 2.5,
|
||||||
|
maxWidth: "56ch",
|
||||||
|
color: colors.berry,
|
||||||
|
fontSize: { xs: "1rem", md: "1.08rem" },
|
||||||
|
lineHeight: 1.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Build desire through color, texture, and memory. This header is designed to feel cinematic,
|
||||||
|
feminine, and premium for a story-led product brand.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mt: 4 }}>
|
||||||
|
<PrimaryButton href="#collection">Explore the Collection</PrimaryButton>
|
||||||
|
<SecondaryButton href="#story">Read Our Story</SecondaryButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 2, md: 3 }} sx={{ mt: 4.5 }}>
|
||||||
|
{[
|
||||||
|
{ value: "24+", label: "curated handcrafted drops" },
|
||||||
|
{ value: "8", label: "signature color stories" },
|
||||||
|
{ value: "100%", label: "visual-first storytelling" },
|
||||||
|
].map((item) => (
|
||||||
|
<Box key={item.label}>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontFamily: '"Playfair Display", serif', color: colors.plum, fontSize: "1.8rem" }}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.berry, fontSize: "0.92rem" }}>{item.label}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</RevealBox>
|
||||||
|
|
||||||
|
<RevealBox sx={{ animationDelay: "0.32s" }}>
|
||||||
|
<Box sx={{ position: "relative", minHeight: { xs: 440, md: 560 } }}>
|
||||||
|
<GlassCard
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: { xs: "72px 0 0 0", md: "90px 16px 0 24px" },
|
||||||
|
transform: { md: "rotate(-4deg)" },
|
||||||
|
background: "transparent",
|
||||||
|
backdropFilter: "none",
|
||||||
|
border: "none",
|
||||||
|
boxShadow: "none",
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previousHeroImage !== null && (
|
||||||
|
<VintageImageFrame
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: { xs: 320, md: 420 },
|
||||||
|
backgroundImage: `url(${heroImages[previousHeroImage]})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
filter: "sepia(0.2) contrast(1.05)",
|
||||||
|
animation: `${fadeOutImage} 2s ease-in-out forwards`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "linear-gradient(to top, rgba(79,47,66,0.42), transparent 45%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VintageImageFrame>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VintageImageFrame
|
||||||
|
key={heroImages[currentHeroImage]}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: { xs: 320, md: 420 },
|
||||||
|
backgroundImage: `url(${heroImages[currentHeroImage]})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
filter: "sepia(0.2) contrast(1.05)",
|
||||||
|
animation: `${heroAnimations[currentHeroImage % heroAnimations.length]} 1.5s ease, ${fadeInImage} 2s ease-in-out, ${float} 6s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "linear-gradient(to top, rgba(79,47,66,0.42), transparent 45%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VintageImageFrame>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: { xs: 12, md: 0 },
|
||||||
|
width: { xs: "54%", md: "46%" },
|
||||||
|
p: 1.2,
|
||||||
|
transform: { xs: "rotate(4deg)", md: "rotate(6deg)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <Box
|
||||||
|
component="img"
|
||||||
|
src={accentImage}
|
||||||
|
alt="Aesthetic accessory detail"
|
||||||
|
sx={{ width: "100%", height: { xs: 168, md: 220 }, objectFit: "cover", borderRadius: "22px" }}
|
||||||
|
/> */}
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<GlassCard
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: { xs: 12, md: -12 },
|
||||||
|
bottom: { xs: 10, md: 26 },
|
||||||
|
width: { xs: "76%", md: "54%" },
|
||||||
|
p: 2.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.coral,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.16em",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Featured chapter
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
color: colors.plum,
|
||||||
|
fontSize: { xs: "1.35rem", md: "1.55rem" },
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dusty rose, gold accents, soft heirloom drama
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.berry, lineHeight: 1.7, fontSize: "0.95rem" }}>
|
||||||
|
Layered cards and floating light create the feeling of a living editorial spread instead of a
|
||||||
|
standard ecommerce banner.
|
||||||
|
</Typography>
|
||||||
|
</GlassCard>
|
||||||
|
</Box>
|
||||||
|
</RevealBox>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</HeroShell>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StoryHeader;
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
|
import InstagramIcon from "@mui/icons-material/Instagram";
|
||||||
|
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
|
||||||
|
import PinterestIcon from "@mui/icons-material/Pinterest";
|
||||||
|
import StorefrontIcon from "@mui/icons-material/Storefront";
|
||||||
|
import LinkedInIcon from "@mui/icons-material/LinkedIn";
|
||||||
|
import { keyframes } from "@emotion/react";
|
||||||
|
import { styled } from "@mui/system";
|
||||||
|
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||||
|
import logo from "../../assets/logobgremove.png";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
ivory: "#fffaf5",
|
||||||
|
blush: "#f4ddd4",
|
||||||
|
peach: "#efb7a2",
|
||||||
|
coral: "#d9826f",
|
||||||
|
berry: "#7f4b5d",
|
||||||
|
plum: "#4f2f42",
|
||||||
|
gold: "#c99652",
|
||||||
|
};
|
||||||
|
|
||||||
|
const shimmer = keyframes`
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GlassBar = styled(AppBar)({
|
||||||
|
background:
|
||||||
|
"linear-gradient(120deg, rgba(255,250,245,0.94), rgba(244,221,212,0.88), rgba(248,241,234,0.94))",
|
||||||
|
backdropFilter: "blur(18px)",
|
||||||
|
borderBottom: `1px solid ${colors.peach}66`,
|
||||||
|
boxShadow: "0 18px 40px rgba(79, 47, 66, 0.1)",
|
||||||
|
color: colors.plum,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bounce = keyframes`
|
||||||
|
0%, 20%, 50%, 80%, 100% { transform: translateY(0); }
|
||||||
|
40% { transform: translateY(-8px); }
|
||||||
|
60% { transform: translateY(-4px); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SocialDock = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: "rgba(255,255,255,0.35)",
|
||||||
|
border: `1px solid ${colors.peach}66`,
|
||||||
|
boxShadow: "0 12px 28px rgba(79, 47, 66, 0.08)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const DesktopNav = styled(Box)({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "26px",
|
||||||
|
padding: "0 10px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: "Home", to: "/" },
|
||||||
|
{ label: "Shop", to: "/home" },
|
||||||
|
{ label: "Journal", to: "/journal" },
|
||||||
|
{ label: "Coming Soon", to: "/coming-soon" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ label: "Instagram", href: "https://instagram.com/thekalawati", icon: InstagramIcon },
|
||||||
|
{ label: "WhatsApp", href: "https://wa.me/1234567890", icon: WhatsAppIcon },
|
||||||
|
{ label: "Pinterest", href: "https://pinterest.com/thekalawati", icon: PinterestIcon },
|
||||||
|
{ label: "Etsy", href: "https://etsy.com/shop/thekalawati", icon: StorefrontIcon },
|
||||||
|
{ label: "LinkedIn", href: "https://linkedin.com/company/thekalawati", icon: LinkedInIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActivePath = (pathname: string, href: string) => {
|
||||||
|
if (href === "/") {
|
||||||
|
return pathname === "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebsiteHeader: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const [menuAnchor, setMenuAnchor] = React.useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const closeMenu = () => setMenuAnchor(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlassBar position="sticky" elevation={0}>
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Toolbar disableGutters sx={{ minHeight: 84, gap: 2, justifyContent: "space-between" }}>
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to="/"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
color: colors.plum,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={logo}
|
||||||
|
alt="The Kalawati"
|
||||||
|
sx={{ width: 50, height: 50, objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
fontSize: { xs: "1rem", md: "1.12rem" },
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
THE KALAWATI
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.72rem",
|
||||||
|
letterSpacing: "0.16em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: colors.berry,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Storytelling Store
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DesktopNav sx={{ display: { xs: "none", md: "flex" } }}>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.to}
|
||||||
|
component={RouterLink}
|
||||||
|
to={item.to}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
minWidth: "auto",
|
||||||
|
padding: "8px 0",
|
||||||
|
textTransform: "none",
|
||||||
|
borderRadius: 0,
|
||||||
|
fontWeight: isActivePath(location.pathname, item.to) ? 700 : 500,
|
||||||
|
fontSize: "0.98rem",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: isActivePath(location.pathname, item.to) ? colors.plum : colors.berry,
|
||||||
|
"&:hover": {
|
||||||
|
background: "transparent",
|
||||||
|
color: colors.plum,
|
||||||
|
},
|
||||||
|
"&::after": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: isActivePath(location.pathname, item.to) ? "100%" : "0%",
|
||||||
|
height: "1.5px",
|
||||||
|
background: `linear-gradient(90deg, ${colors.coral}, ${colors.plum})`,
|
||||||
|
transition: "width 0.3s ease",
|
||||||
|
},
|
||||||
|
"&:hover::after": {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</DesktopNav>
|
||||||
|
|
||||||
|
<SocialDock sx={{ display: { xs: "none", md: "flex" } }}>
|
||||||
|
{socialLinks.map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={item.label}
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
color: colors.plum,
|
||||||
|
background: `linear-gradient(135deg, ${colors.blush}, ${colors.ivory})`,
|
||||||
|
border: `1px solid ${colors.peach}88`,
|
||||||
|
boxShadow: "0 8px 18px rgba(79, 47, 66, 0.08)",
|
||||||
|
animation: `${bounce} 2.4s infinite`,
|
||||||
|
animationDelay: `${index * 0.12}s`,
|
||||||
|
transition: "transform 0.25s ease, background 0.25s ease, color 0.25s ease",
|
||||||
|
"&:hover": {
|
||||||
|
background: `linear-gradient(135deg, ${colors.coral}, ${colors.berry})`,
|
||||||
|
color: colors.ivory,
|
||||||
|
transform: "translateY(-2px)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SocialDock>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={(event) => setMenuAnchor(event.currentTarget)}
|
||||||
|
sx={{
|
||||||
|
display: { xs: "inline-flex", md: "none" },
|
||||||
|
color: colors.plum,
|
||||||
|
border: `1px solid ${colors.peach}88`,
|
||||||
|
background: "rgba(255,255,255,0.55)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Toolbar>
|
||||||
|
</Container>
|
||||||
|
</GlassBar>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={closeMenu}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
mt: 1,
|
||||||
|
minWidth: 210,
|
||||||
|
borderRadius: "18px",
|
||||||
|
background: "rgba(255,250,245,0.96)",
|
||||||
|
backdropFilter: "blur(16px)",
|
||||||
|
border: `1px solid ${colors.peach}66`,
|
||||||
|
boxShadow: "0 18px 36px rgba(79, 47, 66, 0.12)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.to}
|
||||||
|
component={RouterLink}
|
||||||
|
to={item.to}
|
||||||
|
onClick={closeMenu}
|
||||||
|
selected={isActivePath(location.pathname, item.to)}
|
||||||
|
sx={{
|
||||||
|
color: colors.plum,
|
||||||
|
py: 1.2,
|
||||||
|
"&.Mui-selected": {
|
||||||
|
background: `${colors.blush}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebsiteHeader;
|
||||||
|
|
@ -10,11 +10,10 @@ import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import { keyframes } from '@emotion/react';
|
import { keyframes } from '@emotion/react';
|
||||||
import { styled } from '@mui/system';
|
import { styled } from '@mui/system';
|
||||||
// import image1 from '../assets/group1.jpg'//////
|
|
||||||
import image2 from '../../assets/solo1.jpg'
|
|
||||||
// import image3 from '../assets/solo2.jpg'//
|
|
||||||
import image4 from '../../assets/bag1.jpg'
|
import image4 from '../../assets/bag1.jpg'
|
||||||
import image5 from '../../assets/bag2.jpg'
|
import image5 from '../../assets/bag2.jpg'
|
||||||
|
import StoryHeader from '../header/StoryHeader';
|
||||||
|
import StoryTimeline from '../timeline/StoryTimeline';
|
||||||
// Lighter color palette for product showcase
|
// Lighter color palette for product showcase
|
||||||
const colors = {
|
const colors = {
|
||||||
lightBase: '#F8F7F4', // Very light cream
|
lightBase: '#F8F7F4', // Very light cream
|
||||||
|
|
@ -33,22 +32,12 @@ const fadeIn = keyframes`
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const scaleIn = keyframes`
|
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const float = keyframes`
|
const float = keyframes`
|
||||||
0% { transform: translateY(0px); }
|
0% { transform: translateY(0px); }
|
||||||
50% { transform: translateY(-8px); }
|
50% { transform: translateY(-8px); }
|
||||||
100% { transform: translateY(0px); }
|
100% { transform: translateY(0px); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const drawUnderline = keyframes`
|
|
||||||
0% { width: 0; }
|
|
||||||
100% { width: 100%; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const smoothPulse = keyframes`
|
const smoothPulse = keyframes`
|
||||||
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(193, 154, 107, 0.3); }
|
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(193, 154, 107, 0.3); }
|
||||||
70% { transform: scale(1.01); box-shadow: 0 0 0 8px rgba(193, 154, 107, 0); }
|
70% { transform: scale(1.01); box-shadow: 0 0 0 8px rgba(193, 154, 107, 0); }
|
||||||
|
|
@ -117,22 +106,6 @@ const LightVintagePulseButton = styled(LightVintageButton)({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const LightVintageUnderline = styled(Box)({
|
|
||||||
position: 'relative',
|
|
||||||
display: 'inline-block',
|
|
||||||
'&::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-4px',
|
|
||||||
left: 0,
|
|
||||||
width: '0',
|
|
||||||
height: '1.5px',
|
|
||||||
backgroundColor: colors.accent,
|
|
||||||
animation: `${drawUnderline} 1.2s forwards`,
|
|
||||||
animationDelay: '0.3s',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const AnimatedSection = styled(Box)({
|
const AnimatedSection = styled(Box)({
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transform: 'translateY(20px)',
|
transform: 'translateY(20px)',
|
||||||
|
|
@ -210,7 +183,6 @@ export const LightProductShowcase: React.FC = () => {
|
||||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||||
|
|
||||||
// Intersection Observer for scroll animations
|
// Intersection Observer for scroll animations
|
||||||
const [heroRef, heroInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
|
||||||
const [productsRef, productsInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
const [productsRef, productsInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
||||||
const [footerRef, footerInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
const [footerRef, footerInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
||||||
|
|
||||||
|
|
@ -266,156 +238,13 @@ export const LightProductShowcase: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 1, py: 5 }}>
|
<StoryHeader />
|
||||||
{/* Header */}
|
<StoryTimeline />
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${colors.border}`, py: 3, mb: 4 }}>
|
|
||||||
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.1s' : '0s' }}>
|
|
||||||
<Typography
|
|
||||||
variant="h3"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 500,
|
|
||||||
color: colors.lightInk,
|
|
||||||
letterSpacing: '2px',
|
|
||||||
fontFamily: '"Playfair Display", serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LightVintageUnderline>The Kalawati Collection</LightVintageUnderline>
|
|
||||||
</Typography>
|
|
||||||
</AnimatedSection>
|
|
||||||
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.2s' : '0s' }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<IconButton
|
|
||||||
href="https://instagram.com"
|
|
||||||
target="_blank"
|
|
||||||
sx={{
|
|
||||||
color: colors.secondary,
|
|
||||||
'&:hover': { color: colors.accent, transform: 'scale(1.1)' },
|
|
||||||
transition: 'all 0.3s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InstagramIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
href="https://facebook.com"
|
|
||||||
target="_blank"
|
|
||||||
sx={{
|
|
||||||
color: colors.secondary,
|
|
||||||
'&:hover': { color: colors.accent, transform: 'scale(1.1)' },
|
|
||||||
transition: 'all 0.3s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FacebookIcon />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
href="https://twitter.com"
|
|
||||||
target="_blank"
|
|
||||||
sx={{
|
|
||||||
color: colors.secondary,
|
|
||||||
'&:hover': { color: colors.accent, transform: 'scale(1.1)' },
|
|
||||||
transition: 'all 0.3s ease'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TwitterIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</AnimatedSection>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 1, pb: 5 }}>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: { xs: 'column', md: 'row' },
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
py: 6,
|
|
||||||
minHeight: '50vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AnimatedSection
|
|
||||||
ref={heroRef}
|
|
||||||
style={{
|
|
||||||
animationDelay: heroInView ? '0.3s' : '0s',
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: { xs: '350px', md: '450px' },
|
|
||||||
backgroundImage: `url(${image2})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
borderRadius: '4px',
|
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
|
||||||
animation: `${scaleIn} 1s ease`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</AnimatedSection>
|
|
||||||
|
|
||||||
<AnimatedSection
|
|
||||||
ref={heroRef}
|
|
||||||
style={{
|
|
||||||
animationDelay: heroInView ? '0.4s' : '0s',
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="h3"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 300,
|
|
||||||
mb: 3,
|
|
||||||
fontSize: { xs: '2.2rem', md: '2.8rem' },
|
|
||||||
lineHeight: 1.3,
|
|
||||||
color: colors.lightInk,
|
|
||||||
fontFamily: '"Playfair Display", serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 400, color: colors.dark }}>Timeless</span> Craftsmanship, Modern Elegance
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
sx={{
|
|
||||||
mb: 4,
|
|
||||||
color: colors.secondary,
|
|
||||||
fontWeight: 300,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
fontFamily: '"Cormorant Garamond", serif',
|
|
||||||
pl: 3,
|
|
||||||
position: 'relative',
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
height: '100%',
|
|
||||||
width: '2px',
|
|
||||||
background: colors.accent,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Each piece tells a story of tradition, skill, and the hands that crafted it with love and dedication.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<LightVintagePulseButton
|
|
||||||
variant="contained"
|
|
||||||
sx={{
|
|
||||||
px: 4,
|
|
||||||
py: 1.5,
|
|
||||||
fontWeight: 300,
|
|
||||||
fontFamily: '"Cormorant Garamond", serif',
|
|
||||||
letterSpacing: '1.5px',
|
|
||||||
color: colors.lightInk,
|
|
||||||
'&:hover': { background: colors.highlight },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Explore Collection
|
|
||||||
</LightVintagePulseButton>
|
|
||||||
</AnimatedSection>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category Filter */}
|
||||||
<Box sx={{ my: 6, textAlign: 'center' }}>
|
<Box id="collection" sx={{ my: 6, textAlign: 'center' }}>
|
||||||
<AnimatedSection style={{ animationDelay: '0.1s' }}>
|
<AnimatedSection style={{ animationDelay: '0.1s' }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h5"
|
variant="h5"
|
||||||
|
|
@ -585,7 +414,7 @@ export const LightProductShowcase: React.FC = () => {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Call to Action */}
|
{/* Call to Action */}
|
||||||
<Box sx={{ my: 8, py: 8, textAlign: 'center', background: colors.highlight, borderRadius: '4px' }}>
|
<Box id="story" sx={{ my: 8, py: 8, textAlign: 'center', background: colors.highlight, borderRadius: '4px' }}>
|
||||||
<Container maxWidth="md">
|
<Container maxWidth="md">
|
||||||
<AnimatedSection style={{ animationDelay: '0.1s' }}>
|
<AnimatedSection style={{ animationDelay: '0.1s' }}>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Box, Container, Grid, Typography } from "@mui/material";
|
||||||
|
import { keyframes } from "@emotion/react";
|
||||||
|
import { styled } from "@mui/system";
|
||||||
|
import image1 from "../../assets/group1.jpg";
|
||||||
|
import image2 from "../../assets/solo1.jpg";
|
||||||
|
import image3 from "../../assets/solo2.jpg";
|
||||||
|
import image4 from "../../assets/bag1.jpg";
|
||||||
|
import image5 from "../../assets/bag2.jpg";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
ivory: "#fffaf5",
|
||||||
|
mist: "#f8f1ea",
|
||||||
|
blush: "#f4ddd4",
|
||||||
|
peach: "#efb7a2",
|
||||||
|
coral: "#d9826f",
|
||||||
|
berry: "#7f4b5d",
|
||||||
|
plum: "#4f2f42",
|
||||||
|
};
|
||||||
|
|
||||||
|
const riseIn = keyframes`
|
||||||
|
from { opacity: 0; transform: translateY(36px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rotateIn = keyframes`
|
||||||
|
from { transform: rotate(5deg) scale(0.9); opacity: 0; }
|
||||||
|
to { transform: rotate(0deg) scale(1); opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TimelineShell = styled(Box)({
|
||||||
|
position: "relative",
|
||||||
|
paddingTop: "24px",
|
||||||
|
paddingBottom: "48px",
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: "1px",
|
||||||
|
background: "linear-gradient(to bottom, rgba(217,130,111,0.15), rgba(127,75,93,0.45), rgba(217,130,111,0.15))",
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
"@media (max-width: 899px)": {
|
||||||
|
"&::before": {
|
||||||
|
left: "22px",
|
||||||
|
transform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const FloatingBubble = styled(Box)<{
|
||||||
|
size: number;
|
||||||
|
top?: string;
|
||||||
|
left?: string;
|
||||||
|
right?: string;
|
||||||
|
bottom?: string;
|
||||||
|
delay?: string;
|
||||||
|
}>(({ size, top, left, right, bottom, delay = "0s" }) => ({
|
||||||
|
position: "absolute",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at 30% 30%, rgba(255,255,255,0.85), rgba(244,221,212,0.55) 45%, rgba(217,130,111,0.16) 75%, rgba(127,75,93,0.08) 100%)",
|
||||||
|
filter: "blur(3px)",
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: "none",
|
||||||
|
animation: `${riseIn} 1.2s ease forwards`,
|
||||||
|
animationDelay: delay,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TimelineCard = styled(Box)({
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: "30px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, rgba(255,250,245,0.9) 0%, rgba(244,221,212,0.76) 55%, rgba(248,241,234,0.9) 100%)",
|
||||||
|
backdropFilter: "blur(14px)",
|
||||||
|
border: `1px solid ${colors.peach}66`,
|
||||||
|
boxShadow: "0 18px 38px rgba(79, 47, 66, 0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
opacity: 0,
|
||||||
|
animation: `${riseIn} 0.9s ease forwards`,
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"linear-gradient(120deg, rgba(255,255,255,0.18), rgba(255,255,255,0), rgba(255,255,255,0.15))",
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TimelineImage = styled(Box)({
|
||||||
|
borderRadius: "24px",
|
||||||
|
border: `10px solid ${colors.ivory}`,
|
||||||
|
boxShadow: `10px 10px 0 rgba(127,75,93,0.2), 0 10px 20px rgba(79,47,66,0.08)`,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
animation: `${rotateIn} 1.1s ease`,
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
top: "-14px",
|
||||||
|
left: "-14px",
|
||||||
|
right: "-14px",
|
||||||
|
bottom: "-14px",
|
||||||
|
border: `1px solid ${colors.peach}88`,
|
||||||
|
borderRadius: "28px",
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const storyChapters = [
|
||||||
|
{
|
||||||
|
year: "2019",
|
||||||
|
title: "Where Curiosity Met Craft",
|
||||||
|
content:
|
||||||
|
"Fresh out of fashion school, I found myself drawn to looms, dye pits, and village homes. I organized workshops to listen, learn, and share space with artisans, forging a bond between design, community, and culture.",
|
||||||
|
image: image1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2020",
|
||||||
|
title: "A Name Was Born",
|
||||||
|
content:
|
||||||
|
"During the pandemic, I launched a campaign to highlight artisans hit by lockdowns. The Kalawati was born, named after women who hold culture in their palms and creativity in their hearts.",
|
||||||
|
image: image2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2021",
|
||||||
|
title: "Strengthening the Circle",
|
||||||
|
content:
|
||||||
|
"I spent the year mapping artisan strengths, connecting traditional skills to modern demand, and building trust for a long-term vision of community and craft.",
|
||||||
|
image: image3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2022",
|
||||||
|
title: "Systems That Serve People",
|
||||||
|
content:
|
||||||
|
"Through fellowship work, systems took shape that blended business and empathy, building stronger brand stories, value chains, and sustainable income pathways.",
|
||||||
|
image: image4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2023",
|
||||||
|
title: "Quiet Creation",
|
||||||
|
content:
|
||||||
|
"Working with artisan clusters, collections began to take form through handloom, embroidery, and natural dyes, turning process into pieces people could feel.",
|
||||||
|
image: image1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: "2024",
|
||||||
|
title: "Kalawati Arrives",
|
||||||
|
content:
|
||||||
|
"The Kalawati opens its doors as a brand and movement, offering ethically made handcrafted apparel and decor with story, memory, and care woven through each release.",
|
||||||
|
image: image5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const StoryTimeline: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
py: { xs: 4, md: 7 },
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FloatingBubble size={170} top="6%" right="3%" delay="0.05s" />
|
||||||
|
<FloatingBubble size={120} top="30%" left="-30px" delay="0.15s" />
|
||||||
|
<FloatingBubble size={90} bottom="22%" right="12%" delay="0.25s" />
|
||||||
|
<FloatingBubble size={150} bottom="6%" left="4%" delay="0.35s" />
|
||||||
|
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
textAlign: "center",
|
||||||
|
mb: { xs: 5, md: 7 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.coral,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.22em",
|
||||||
|
fontSize: "0.82rem",
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Story Timeline
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
color: colors.plum,
|
||||||
|
fontSize: { xs: "2.1rem", md: "3.3rem" },
|
||||||
|
lineHeight: 1.05,
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Our story woven in time
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
maxWidth: "62ch",
|
||||||
|
mx: "auto",
|
||||||
|
color: colors.berry,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
fontSize: { xs: "1rem", md: "1.05rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A pink-toned retelling of the same timeline idea from the coming soon page, now designed to sit beneath the main story header as part of the storefront flow.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TimelineShell>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{storyChapters.map((chapter, index) => (
|
||||||
|
<Grid key={chapter.year} size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ maxWidth: 1120, mx: "auto" }}>
|
||||||
|
<TimelineCard sx={{ animationDelay: `${index * 0.12}s` }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: { xs: "column", md: index % 2 === 0 ? "row" : "row-reverse" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: { xs: 3, md: 5 },
|
||||||
|
p: { xs: 2.2, md: 3.6 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: { xs: "left", md: index % 2 === 0 ? "right" : "left" },
|
||||||
|
px: { xs: 0.5, md: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.coral,
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
mb: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapter.year}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: '"Playfair Display", serif',
|
||||||
|
color: colors.plum,
|
||||||
|
fontSize: { xs: "1.55rem", md: "2.2rem" },
|
||||||
|
lineHeight: 1.1,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapter.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.berry,
|
||||||
|
lineHeight: 1.9,
|
||||||
|
fontSize: { xs: "0.98rem", md: "1.05rem" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapter.content}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 250,
|
||||||
|
pl: { xs: 5, md: 0 },
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: {
|
||||||
|
xs: "22px",
|
||||||
|
md: index % 2 === 0 ? "calc(100% + 24px)" : "auto",
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
xs: "auto",
|
||||||
|
md: index % 2 === 0 ? "auto" : "calc(100% + 24px)",
|
||||||
|
},
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "22px",
|
||||||
|
height: "22px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: `linear-gradient(135deg, ${colors.coral}, ${colors.plum})`,
|
||||||
|
border: `4px solid ${colors.ivory}`,
|
||||||
|
boxShadow: "0 0 0 8px rgba(244,221,212,0.72)",
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineImage
|
||||||
|
sx={{
|
||||||
|
height: 250,
|
||||||
|
width: "100%",
|
||||||
|
backgroundImage: `url(${chapter.image})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
filter: "sepia(0.12) saturate(1.04) contrast(1.02)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TimelineCard>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</TimelineShell>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StoryTimeline;
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@500;600;700&family=Manrope:wght@400;500;600;700&display=swap");
|
||||||
|
|
||||||
/* simple defaults (no Tailwind) */
|
/* simple defaults (no Tailwind) */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -12,8 +14,13 @@ body,
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||||
background: #f7fafc;
|
background: radial-gradient(circle at top, #fff8f1 0%, #f8f1ea 32%, #f7fafc 100%);
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/blogs/BlogCard.tsx","./src/components/blogs/BlogPage.tsx","./src/components/comingsoon/comingsoon.tsx","./src/components/header/StoryHeader.tsx","./src/components/header/WebsiteHeader.tsx","./src/components/product/product.tsx","./src/components/timeline/StoryTimeline.tsx","./src/types/blogs.tsx"],"version":"5.8.3"}
|
||||||
Loading…
Reference in New Issue