feature/coming_soon #3
|
|
@ -1,9 +1,9 @@
|
||||||
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 BlogCard from "./components/blogs/BlogCard";
|
// import BlogCard from "./components/blogs/BlogCard";
|
||||||
import WeddingGallery from "./WeddingGallery/WeddingGallery";
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,17 @@ import {
|
||||||
IconButton,
|
IconButton,
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
useTheme,
|
Tooltip,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
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 = {
|
export type LightboxPhoto = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
|
|
@ -18,6 +23,8 @@ export type LightboxPhoto = {
|
||||||
title?: string;
|
title?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
naturalWidth?: number;
|
||||||
|
naturalHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -25,111 +32,384 @@ type Props = {
|
||||||
photos: LightboxPhoto[];
|
photos: LightboxPhoto[];
|
||||||
startIndex?: number;
|
startIndex?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
startIndexCallback?: (idx: number) => void; // optional: inform parent when slide changes
|
startIndexCallback?: (idx: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 40; // pixels
|
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) {
|
export default function LightboxModal({
|
||||||
|
open,
|
||||||
|
photos,
|
||||||
|
startIndex = 0,
|
||||||
|
onClose,
|
||||||
|
startIndexCallback,
|
||||||
|
}: Props) {
|
||||||
const [index, setIndex] = useState<number>(startIndex);
|
const [index, setIndex] = useState<number>(startIndex);
|
||||||
const theme = useTheme();
|
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 touchStartX = useRef<number | null>(null);
|
||||||
const touchDeltaX = useRef(0);
|
const touchDeltaX = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
// For pinch
|
||||||
setIndex(startIndex);
|
const pinchStartDist = useRef<number | null>(null);
|
||||||
}, [startIndex, open]);
|
const pinchStartScale = useRef<number>(1);
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => setIndex(startIndex), [startIndex, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
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 (!open) return;
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
if (e.key === 'ArrowLeft') prev();
|
if (e.key === 'ArrowLeft') prev();
|
||||||
if (e.key === 'ArrowRight') next();
|
if (e.key === 'ArrowRight') next();
|
||||||
}
|
if (e.key === '+' || (e.ctrlKey && e.key === '=')) zoomIn();
|
||||||
window.addEventListener('keydown', onKey);
|
if (e.key === '-') zoomOut();
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
if (e.key === '0') fitToScreen();
|
||||||
}, [open, index]);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// inform parent if needed
|
|
||||||
startIndexCallback?.(index);
|
|
||||||
}, [index]);
|
|
||||||
|
|
||||||
const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length);
|
const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length);
|
||||||
const next = () => setIndex((i) => (i + 1) % photos.length);
|
const next = () => setIndex((i) => (i + 1) % photos.length);
|
||||||
|
|
||||||
// Preload neighbors
|
// Preload neighbors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!photos || photos.length === 0) return;
|
if (!photos?.length) return;
|
||||||
const prevIdx = (index - 1 + photos.length) % photos.length;
|
const a = new Image();
|
||||||
const nextIdx = (index + 1) % photos.length;
|
a.src = photos[(index - 1 + photos.length) % photos.length]?.url;
|
||||||
const p1 = new Image();
|
const b = new Image();
|
||||||
p1.src = photos[prevIdx]?.url;
|
b.src = photos[(index + 1) % photos.length]?.url;
|
||||||
const p2 = new Image();
|
|
||||||
p2.src = photos[nextIdx]?.url;
|
|
||||||
}, [index, photos]);
|
}, [index, photos]);
|
||||||
|
|
||||||
// Touch handlers for swipe
|
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) => {
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
touchDeltaX.current = 0;
|
touchDeltaX.current = 0;
|
||||||
touchStartX.current = e.touches?.[0]?.clientX ?? null;
|
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) => {
|
const onTouchMove = (e: React.TouchEvent) => {
|
||||||
if (touchStartX.current == null) return;
|
if (e.touches.length === 1 && scale > 1 && isPanning && panStart.current) {
|
||||||
const x = e.touches?.[0]?.clientX ?? 0;
|
const dx = e.touches[0].clientX - panStart.current.x;
|
||||||
touchDeltaX.current = x - (touchStartX.current ?? 0);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onTouchEnd = () => {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const delta = touchDeltaX.current;
|
const onTouchEnd = (_e: React.TouchEvent) => {
|
||||||
if (Math.abs(delta) > SWIPE_THRESHOLD) {
|
if (isPanning) {
|
||||||
if (delta < 0) next();
|
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();
|
else prev();
|
||||||
}
|
}
|
||||||
touchStartX.current = null;
|
|
||||||
touchDeltaX.current = 0;
|
touchDeltaX.current = 0;
|
||||||
|
touchStartX.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!photos || photos.length === 0) return 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);
|
||||||
|
}
|
||||||
|
|
||||||
const photo = photos[index];
|
// 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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
fullScreen
|
fullScreen
|
||||||
PaperProps={{ sx: { backgroundColor: 'rgba(0,0,0,0.92)' } }}
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
m: 0,
|
||||||
|
p: 0,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.95)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'& .MuiDialog-container': {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
boxSizing: 'border-box',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
userSelect: 'none',
|
userSelect: isPanning ? 'none' : 'auto',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'none',
|
||||||
}}
|
}}
|
||||||
|
onWheel={onWheel}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Close */}
|
{/* Loading indicator */}
|
||||||
<IconButton
|
{isLoading && (
|
||||||
onClick={onClose}
|
<Box sx={{
|
||||||
sx={{ position: 'absolute', top: 16, right: 16, color: 'white', zIndex: 40 }}
|
position: 'absolute',
|
||||||
aria-label="Close"
|
top: '50%',
|
||||||
>
|
left: '50%',
|
||||||
<CloseIcon />
|
transform: 'translate(-50%, -50%)',
|
||||||
</IconButton>
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<CircularProgress sx={{ color: 'rgba(255,255,255,0.7)' }} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Download */}
|
{/* 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
|
<IconButton
|
||||||
sx={{ position: 'absolute', top: 16, right: 72, color: 'white', zIndex: 40 }}
|
size="small"
|
||||||
aria-label="Download"
|
onClick={(ev) => {
|
||||||
onClick={() => {
|
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 {
|
try {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = photo.url;
|
a.href = photo.url;
|
||||||
|
|
@ -141,74 +421,171 @@ export default function LightboxModal({ open, photos, startIndex = 0, onClose, s
|
||||||
console.error('download', err);
|
console.error('download', err);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255,255,255,0.1)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Prev */}
|
<Tooltip title="Close (Esc)">
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={prev}
|
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={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 8,
|
left: 16,
|
||||||
color: 'rgba(255,255,255,0.9)',
|
zIndex: 50,
|
||||||
zIndex: 40,
|
color: 'white',
|
||||||
bgcolor: 'transparent',
|
bgcolor: 'rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
|
||||||
}}
|
}}
|
||||||
aria-label="Previous"
|
|
||||||
>
|
>
|
||||||
<ArrowBackIosNewIcon />
|
<ArrowBackIosNewIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Next */}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={next}
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
next();
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 8,
|
right: 16,
|
||||||
color: 'rgba(255,255,255,0.9)',
|
zIndex: 50,
|
||||||
zIndex: 40,
|
color: 'white',
|
||||||
bgcolor: 'transparent',
|
bgcolor: 'rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
|
||||||
}}
|
}}
|
||||||
aria-label="Next"
|
|
||||||
>
|
>
|
||||||
<ArrowForwardIosIcon />
|
<ArrowForwardIosIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image container */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: '95%',
|
width: '100%',
|
||||||
maxHeight: '88%',
|
height: '100%',
|
||||||
|
px: 2,
|
||||||
|
boxSizing: 'border-box',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
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
|
<img
|
||||||
|
ref={imageRef}
|
||||||
src={photo.url}
|
src={photo.url}
|
||||||
alt={photo.title ?? photo.filename}
|
alt={photo.title ?? photo.filename}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
display: isLoading ? 'none' : 'block',
|
||||||
maxHeight: '100%',
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '80vh',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderRadius: 6,
|
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)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
||||||
|
touchAction: 'none',
|
||||||
|
...getImageDisplayStyle(),
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Caption / Position */}
|
{/* Caption */}
|
||||||
<Box sx={{ position: 'absolute', bottom: 20, left: 20, right: 20, textAlign: 'center', zIndex: 40 }}>
|
<Box sx={{
|
||||||
<Typography variant="subtitle1" color="white" noWrap>
|
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}
|
{photo.title ?? photo.filename}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="rgba(255,255,255,0.8)">
|
<Typography variant="caption" color="rgba(255,255,255,0.85)" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
{index + 1} / {photos.length} {photo.created_at ? `• ${new Date(photo.created_at).toLocaleDateString()}` : ''}
|
{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>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,16 @@ export default function PreviewTester() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
try { URL.revokeObjectURL(it.previewUrl); } catch { }
|
try {
|
||||||
|
URL.revokeObjectURL(it.previewUrl);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
<Box {...getRootProps()} sx={{ border: '2px dashed', p: 2, textAlign: 'center', mb: 2 }}>
|
<Box {...getRootProps()} sx={{ border: '2px dashed', p: 2, textAlign: 'center', mb: 2 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// SafeGrid.tsx
|
// SafeGrid.tsx
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const SafeGrid = Grid as any;
|
const SafeGrid = Grid as any;
|
||||||
export default SafeGrid;
|
export default SafeGrid;
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ const UploadQueue: React.FC<Props> = ({ queue, setQueue, newPhotoTitle, onUpload
|
||||||
|
|
||||||
const uploadAll = useCallback(async () => {
|
const uploadAll = useCallback(async () => {
|
||||||
for (const item of queue.filter((q) => q.status === 'pending' || q.status === 'error')) {
|
for (const item of queue.filter((q) => q.status === 'pending' || q.status === 'error')) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await uploadSingle(item);
|
await uploadSingle(item);
|
||||||
}
|
}
|
||||||
}, [queue, uploadSingle]);
|
}, [queue, uploadSingle]);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
// UploadTester.tsx
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Box, Button, Input, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
export default function UploadTester() {
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
function UploadTester() {
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setFile(e.target.files?.[0] ?? null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <input type="file" onChange={onFileChange} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!file) return alert('Choose a file first');
|
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file, file.name);
|
|
||||||
fd.append('title', 'test-upload');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://mfs-api.midastix.com/image-upload/single', {
|
|
||||||
method: 'POST',
|
|
||||||
body: fd,
|
|
||||||
// DO NOT set Content-Type — browser will set multipart boundary
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
console.log('UploadTester: status', res.status, 'body:', text);
|
|
||||||
alert(`Status: ${res.status} — see console for body`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('UploadTester error', err);
|
|
||||||
alert('Upload failed — see console');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2 }}>
|
|
||||||
<Typography variant="subtitle1">Upload Tester (one file)</Typography>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
const f = input.files && input.files[0];
|
|
||||||
setFile(f ?? null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<Button variant="contained" onClick={handleUpload} disabled={!file}>Upload</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,7 @@ export const uploadFileXHR = (
|
||||||
file: File,
|
file: File,
|
||||||
onProgress: (pct: number) => void,
|
onProgress: (pct: number) => void,
|
||||||
title?: string
|
title?: string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
): Promise<{ success: boolean; data?: any; error?: string }> =>
|
): Promise<{ success: boolean; data?: any; error?: string }> =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -34,6 +35,7 @@ export const uploadFileXHR = (
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(xhr.responseText);
|
const json = JSON.parse(xhr.responseText);
|
||||||
resolve({ success: true, data: json });
|
resolve({ success: true, data: json });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
resolve({ success: true, data: xhr.responseText });
|
resolve({ success: true, data: xhr.responseText });
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +51,7 @@ export const uploadFileXHR = (
|
||||||
fd.append('file', file, file.name);
|
fd.append('file', file, file.name);
|
||||||
if (title) fd.append('title', title);
|
if (title) fd.append('title', title);
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
resolve({ success: false, error: err?.message ?? String(err) });
|
resolve({ success: false, error: err?.message ?? String(err) });
|
||||||
}
|
}
|
||||||
|
|
@ -64,14 +67,17 @@ export const fetchImagesApi = async (title = '') => {
|
||||||
throw new Error(txt || `HTTP ${res.status}`);
|
throw new Error(txt || `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let imagesArray: any[] = [];
|
let imagesArray: any[] = [];
|
||||||
if (Array.isArray(body)) imagesArray = body;
|
if (Array.isArray(body)) imagesArray = body;
|
||||||
else if (Array.isArray(body.images)) imagesArray = body.images;
|
else if (Array.isArray(body.images)) imagesArray = body.images;
|
||||||
else if (body.data && Array.isArray(body.data.images)) imagesArray = body.data.images;
|
else if (body.data && Array.isArray(body.data.images)) imagesArray = body.data.images;
|
||||||
else {
|
else {
|
||||||
const firstArray = Object.values(body).find((v) => Array.isArray(v));
|
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[];
|
if (Array.isArray(firstArray)) imagesArray = firstArray as any[];
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return imagesArray.map((it: any) => ({
|
return imagesArray.map((it: any) => ({
|
||||||
id: it.id ?? it._id ?? `${it.filename ?? Math.random()}`,
|
id: it.id ?? it._id ?? `${it.filename ?? Math.random()}`,
|
||||||
url: toAbsoluteUrl(it.path ?? it.url),
|
url: toAbsoluteUrl(it.path ?? it.url),
|
||||||
|
|
@ -84,17 +90,27 @@ export const fetchImagesApi = async (title = '') => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Delete photo (by id) */
|
/* Delete photo (by id) */
|
||||||
export const deletePhotoApi = async (id: number | string) => {
|
export const deletePhotoApi = async (filename: string) => {
|
||||||
const url = `${MFS_API_BASE}/image-upload/${id}`;
|
const url = `${MFS_API_BASE}/image-upload/${filename}`;
|
||||||
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
|
||||||
return text || 'deleted';
|
if (!res.ok) {
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || "deleted";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/* Delete folder by title (assumed endpoint) */
|
/* Delete folder by title (assumed endpoint) */
|
||||||
export const deleteFolderApi = async (title: string) => {
|
export const deleteFolderApi = async (title: string) => {
|
||||||
const url = `${MFS_API_BASE}/image-upload/folder?title=${encodeURIComponent(title)}`;
|
const url = `${MFS_API_BASE}/image-upload/${encodeURIComponent(title)}`;
|
||||||
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
|
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,10 @@ const fadeInUp = keyframes`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fadeIn = keyframes`
|
// const fadeIn = keyframes`
|
||||||
from { opacity: 0; }
|
// from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
// to { opacity: 1; }
|
||||||
`;
|
// `;
|
||||||
|
|
||||||
const float = keyframes`
|
const float = keyframes`
|
||||||
0% { transform: translateY(0px); }
|
0% { transform: translateY(0px); }
|
||||||
|
|
@ -324,6 +324,7 @@ const BlogCard: React.FC<BlogCardProps> = ({ post, index }) => {
|
||||||
const BlogPage: React.FC = () => {
|
const BlogPage: React.FC = () => {
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [heroRef, heroInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
const [heroRef, heroInView] = useInView({ threshold: 0.1, triggerOnce: true });
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue