added wedding gallery
continuous-integration/drone/push Build is failing Details

This commit is contained in:
hardik 2025-12-06 07:37:36 +00:00
parent 0272d6100e
commit dbff6786f1
19 changed files with 2870 additions and 410 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
npm-debug.log
.DS_Store

Binary file not shown.

View File

@ -5,43 +5,19 @@ FROM node:22-bullseye AS builder
ARG API_BASE_URL="https://navigolabs.com/api" ARG API_BASE_URL="https://navigolabs.com/api"
# Enable Corepack and Yarn 4
RUN corepack enable && corepack prepare yarn@4.9.2 --activate
WORKDIR /app WORKDIR /app
# Copy Yarn 4 files # Copy package.json and package-lock.json
COPY package.json yarn.lock .yarnrc.yml ./ COPY package*.json ./
COPY .yarn .yarn
# Install dependencies (immutable to match lockfile) # Install ALL dependencies (including devDependencies)
RUN yarn install --immutable RUN npm install
# Copy all source files # Copy source files
COPY . . COPY . .
# Pass API URL to Vite # Pass API URL to Vite
ENV VITE_API_BASE_URL=$API_BASE_URL ENV VITE_API_BASE_URL=$API_BASE_URL
# Build the app # Build the app
RUN yarn build RUN npm run build
# ------------------------
# Production stage
# ------------------------
FROM node:22-alpine AS production
WORKDIR /app
# Copy built files from builder stage
COPY --from=builder /app/dist ./dist
# Install a lightweight static server
RUN npm install -g serve@14
# Expose port (optional but recommended)
EXPOSE 3000
# Start the static server
CMD ["serve", "-s", "dist", "-l", "3000"]

787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"@mui/material": "^7.3.2", "@mui/material": "^7.3.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-router-dom": "^7.8.2" "react-router-dom": "^7.8.2"
}, },

View File

@ -3,6 +3,8 @@ 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 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() {
@ -10,12 +12,15 @@ function App() {
<Router> <Router>
<Routes> <Routes>
{/* Default route */} {/* Default route */}
<Route path="/" element={<VintageComingSoonPage />} /> {/* <Route path="/" element={<VintageComingSoonPage />} /> */}
<Route path="/" element={<WeddingGallery />} />
{/* Example extra routes */} {/* Example extra routes */}
<Route path="/home" element={<DarkProductShowcase />} /> {/* <Route path="/home" element={<DarkProductShowcase />} />
<Route path="/blog" element={<BlogPage />} /> <Route path="/blog" element={<BlogPage />} /> */}
{/* <Route path="/blog" element={<BlogCard />} /> */} <Route path="/weddingGallery" element={<WeddingGallery />} />
{/* <Route path="/blog" element={<BlogCard post={undefined} index={0} />} /> */}
</Routes> </Routes>
</Router> </Router>
); );

View File

@ -0,0 +1,43 @@
// 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;

View File

@ -0,0 +1,215 @@
// LightboxModal.tsx
import React, { useEffect, useRef, useState } from 'react';
import {
Dialog,
IconButton,
Box,
Typography,
useTheme,
} 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';
export type LightboxPhoto = {
id: number | string;
url: string;
title?: string;
filename?: string;
created_at?: string;
};
type Props = {
open: boolean;
photos: LightboxPhoto[];
startIndex?: number;
onClose: () => void;
startIndexCallback?: (idx: number) => void; // optional: inform parent when slide changes
};
const SWIPE_THRESHOLD = 40; // pixels
export default function LightboxModal({ open, photos, startIndex = 0, onClose, startIndexCallback }: Props) {
const [index, setIndex] = useState<number>(startIndex);
const theme = useTheme();
const touchStartX = useRef<number | null>(null);
const touchDeltaX = useRef(0);
useEffect(() => {
setIndex(startIndex);
}, [startIndex, open]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, index]);
useEffect(() => {
// inform parent if needed
startIndexCallback?.(index);
}, [index]);
const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length);
const next = () => setIndex((i) => (i + 1) % photos.length);
// Preload neighbors
useEffect(() => {
if (!photos || photos.length === 0) return;
const prevIdx = (index - 1 + photos.length) % photos.length;
const nextIdx = (index + 1) % photos.length;
const p1 = new Image();
p1.src = photos[prevIdx]?.url;
const p2 = new Image();
p2.src = photos[nextIdx]?.url;
}, [index, photos]);
// Touch handlers for swipe
const onTouchStart = (e: React.TouchEvent) => {
touchDeltaX.current = 0;
touchStartX.current = e.touches?.[0]?.clientX ?? null;
};
const onTouchMove = (e: React.TouchEvent) => {
if (touchStartX.current == null) return;
const x = e.touches?.[0]?.clientX ?? 0;
touchDeltaX.current = x - (touchStartX.current ?? 0);
};
const onTouchEnd = () => {
const delta = touchDeltaX.current;
if (Math.abs(delta) > SWIPE_THRESHOLD) {
if (delta < 0) next();
else prev();
}
touchStartX.current = null;
touchDeltaX.current = 0;
};
if (!photos || photos.length === 0) return null;
const photo = photos[index];
return (
<Dialog
open={open}
onClose={onClose}
fullScreen
PaperProps={{ sx: { backgroundColor: 'rgba(0,0,0,0.92)' } }}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
touchAction: 'pan-y',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{/* Close */}
<IconButton
onClick={onClose}
sx={{ position: 'absolute', top: 16, right: 16, color: 'white', zIndex: 40 }}
aria-label="Close"
>
<CloseIcon />
</IconButton>
{/* Download */}
<IconButton
sx={{ position: 'absolute', top: 16, right: 72, color: 'white', zIndex: 40 }}
aria-label="Download"
onClick={() => {
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);
}
}}
>
<DownloadIcon />
</IconButton>
{/* Prev */}
<IconButton
onClick={prev}
sx={{
position: 'absolute',
left: 8,
color: 'rgba(255,255,255,0.9)',
zIndex: 40,
bgcolor: 'transparent',
}}
aria-label="Previous"
>
<ArrowBackIosNewIcon />
</IconButton>
{/* Next */}
<IconButton
onClick={next}
sx={{
position: 'absolute',
right: 8,
color: 'rgba(255,255,255,0.9)',
zIndex: 40,
bgcolor: 'transparent',
}}
aria-label="Next"
>
<ArrowForwardIosIcon />
</IconButton>
{/* Image */}
<Box
sx={{
maxWidth: '95%',
maxHeight: '88%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<img
src={photo.url}
alt={photo.title ?? photo.filename}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 6,
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
}}
draggable={false}
/>
</Box>
{/* Caption / Position */}
<Box sx={{ position: 'absolute', bottom: 20, left: 20, right: 20, textAlign: 'center', zIndex: 40 }}>
<Typography variant="subtitle1" color="white" noWrap>
{photo.title ?? photo.filename}
</Typography>
<Typography variant="caption" color="rgba(255,255,255,0.8)">
{index + 1} / {photos.length} {photo.created_at ? `${new Date(photo.created_at).toLocaleDateString()}` : ''}
</Typography>
</Box>
</Box>
</Dialog>
);
}

View File

@ -0,0 +1,55 @@
// 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 { }
});
};
}, [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>
);
}

View File

@ -0,0 +1,4 @@
// SafeGrid.tsx
import Grid from '@mui/material/Grid';
const SafeGrid = Grid as any;
export default SafeGrid;

View File

@ -0,0 +1,98 @@
// 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')) {
// eslint-disable-next-line no-await-in-loop
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;

View File

@ -0,0 +1,55 @@
// 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>
);
}

View File

@ -0,0 +1,624 @@
// WeddingGallery.tsx
import React, { JSX, useCallback, useEffect, useState } from 'react';
import {
AppBar,
Alert,
Box,
Button,
Card,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
CssBaseline,
Grid,
IconButton,
LinearProgress,
Paper,
Snackbar,
TextField,
ThemeProvider,
Toolbar,
Typography,
createTheme,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
CameraAlt,
CloudUpload,
Delete as DeleteIcon,
Download,
Replay,
Favorite,
} from '@mui/icons-material';
import { useDropzone } from 'react-dropzone';
import LightboxModal from './LightboxModal';
type WeddingPhoto = {
id: number | string;
url: string;
title?: string;
filename?: string;
created_at?: string;
date?: string;
path?: string;
};
type FileQueueItem = {
id: string;
file: File;
previewUrl: string;
progress: number;
status: 'pending' | 'uploading' | 'done' | 'error';
error?: string;
uploadedPhoto?: WeddingPhoto;
};
type FolderInfo = {
title: string;
count: number;
thumb?: string;
};
/* ============================
Config
============================ */
const MFS_API_BASE = 'https://mfs-api.midastix.com';
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const MAX_QUEUE_FILES = 30;
/* ============================
Helpers / API functions
============================ */
const toAbsoluteUrl = (path?: string) => {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) return path;
return `${MFS_API_BASE}${path}`;
};
const uploadFileXHR = (
file: File,
onProgress: (pct: number) => void,
title?: string
): 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 });
} 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);
} catch (err: any) {
resolve({ success: false, error: err?.message ?? String(err) });
}
});
const fetchImagesApi = async (title = ''): Promise<WeddingPhoto[]> => {
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();
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));
if (Array.isArray(firstArray)) imagesArray = firstArray as 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 ?? '',
filename: it.filename,
created_at: it.created_at ?? it.createdAt,
date: it.date,
path: it.path,
}));
};
const deletePhotoApi = async (id: number | string) => {
const url = `${MFS_API_BASE}/image-upload/${id}`;
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
const txt = await res.text();
if (!res.ok) throw new Error(txt || `HTTP ${res.status}`);
return txt || 'deleted';
};
const deleteFolderApi = async (title: string) => {
const url = `${MFS_API_BASE}/image-upload/folder?title=${encodeURIComponent(title)}`;
const res = await fetch(url, { method: 'DELETE', headers: { accept: 'application/json' } });
const txt = await res.text();
if (!res.ok) throw new Error(txt || `HTTP ${res.status}`);
return txt || 'folder deleted';
};
/* ============================
Theme & SafeGrid
============================ */
const theme = createTheme({
palette: {
primary: { main: '#0f766e', light: '#2dd4bf' },
secondary: { main: '#d4af37' },
background: { default: '#fbfdfb' },
},
typography: { fontFamily: '"Inter", "Roboto", sans-serif' },
});
const SafeGrid = Grid as any;
/* ============================
Component
============================ */
export default function WeddingGallery(): JSX.Element {
const [photosAll, setPhotosAll] = useState<WeddingPhoto[]>([]);
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [folderOpenTitle, setFolderOpenTitle] = useState<string | null>(null);
const [folderPhotos, setFolderPhotos] = useState<WeddingPhoto[]>([]);
const [folderModalOpen, setFolderModalOpen] = useState(false);
const [queue, setQueue] = useState<FileQueueItem[]>([]);
const [commonTitle, setCommonTitle] = useState<string>('');
const [snackbar, setSnackbar] = useState<{ open: boolean; severity?: 'success' | 'error' | 'info'; message: string }>({
open: false,
severity: 'info',
message: '',
});
const [deletingId, setDeletingId] = useState<number | string | null>(null);
const [deletingFolder, setDeletingFolder] = useState<string | null>(null);
// lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxStartIndex, setLightboxStartIndex] = useState(0);
const loadAll = useCallback(async () => {
try {
const items = await fetchImagesApi('');
setPhotosAll(items);
const map = new Map<string, FolderInfo>();
items.forEach((it) => {
const title = it.title ?? 'untitled';
if (!map.has(title)) map.set(title, { title, count: 0, thumb: it.url });
const entry = map.get(title)!;
entry.count += 1;
if (!entry.thumb) entry.thumb = it.url;
});
setFolders(Array.from(map.values()).sort((a, b) => b.count - a.count));
} catch (err: any) {
console.error('loadAll error', err);
setFolders([]);
setSnackbar({ open: true, severity: 'error', message: `Failed to load: ${err?.message || err}` });
}
}, []);
useEffect(() => {
loadAll();
return () => {
queue.forEach((q) => {
try {
URL.revokeObjectURL(q.previewUrl);
} catch { }
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ============================
Drop enforce MAX_QUEUE_FILES
============================ */
const onDrop = useCallback((acceptedFiles: File[]) => {
if (!acceptedFiles || acceptedFiles.length === 0) return;
const currentCount = queue.length;
const available = Math.max(0, MAX_QUEUE_FILES - currentCount);
if (available <= 0) {
setSnackbar({ open: true, severity: 'error', message: `Queue full — maximum ${MAX_QUEUE_FILES} files allowed.` });
return;
}
const acceptedToAdd = acceptedFiles.slice(0, available);
const rejectedCount = acceptedFiles.length - acceptedToAdd.length;
const now = Date.now();
const items: FileQueueItem[] = acceptedToAdd.map((f, i) => {
const id = `${now}-${currentCount + i}-${f.name}`;
const tooLarge = f.size > MAX_FILE_SIZE;
const invalidType = ALLOWED_TYPES.length > 0 && !ALLOWED_TYPES.includes(f.type);
return {
id,
file: f,
previewUrl: URL.createObjectURL(f),
progress: 0,
status: tooLarge || invalidType ? 'error' : 'pending',
error: tooLarge ? `File too large ${(f.size / (1024 * 1024)).toFixed(1)}MB` : invalidType ? 'Invalid type' : undefined,
} as FileQueueItem;
});
setQueue((prev) => [...items, ...prev]);
if (rejectedCount > 0) {
setSnackbar({
open: true,
severity: 'info',
message: `Accepted ${acceptedToAdd.length} file(s). Rejected ${rejectedCount} file(s) due to the ${MAX_QUEUE_FILES}-file limit.`,
});
} else {
setSnackbar({ open: true, severity: 'success', message: `Added ${acceptedToAdd.length} file(s) to the queue.` });
}
}, [queue.length]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
maxSize: MAX_FILE_SIZE,
accept: ALLOWED_TYPES.length ? ALLOWED_TYPES.reduce((acc, t) => ({ ...acc, [t]: [] }), {} as any) : undefined,
});
/* ============================
Upload logic
============================ */
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)));
}, commonTitle || undefined);
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 ?? commonTitle ?? '',
filename: it.filename,
created_at: it.created_at ?? it.createdAt,
path: it.path,
};
setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: 'done', progress: 100, uploadedPhoto } : q)));
setPhotosAll((prev) => [uploadedPhoto, ...prev]);
await loadAll();
return true;
} else {
setQueue((prev) => prev.map((q) => (q.id === item.id ? { ...q, status: 'error', error: res.error } : q)));
console.error('upload failed', res.error);
return false;
}
},
[commonTitle, loadAll]
);
const uploadAll = useCallback(async () => {
const pending = queue.filter((q) => q.status === 'pending' || q.status === 'error');
if (!pending.length) {
setSnackbar({ open: true, severity: 'info', message: 'No files to upload' });
return;
}
for (const item of pending) {
// eslint-disable-next-line no-await-in-loop
await uploadSingle(item);
}
setSnackbar({ open: true, severity: 'success', message: 'Uploads complete' });
setQueue((prev) => prev.filter((q) => q.status !== 'done'));
}, [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) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch { }
}
setQueue((prev) => prev.filter((q) => q.id !== id));
};
/* ============================
Folder / modal management + lightbox hookup
============================ */
const openFolder = async (title: string) => {
setFolderOpenTitle(title);
try {
const items = await fetchImagesApi(title);
setFolderPhotos(items);
setFolderModalOpen(true);
} catch (err: any) {
console.error('openFolder error', err);
setSnackbar({ open: true, severity: 'error', message: `Failed to load folder: ${err?.message || err}` });
}
};
const handleDeletePhoto = async (id: number | string) => {
if (!confirm('Delete this photo? This action cannot be undone.')) return;
setDeletingId(id);
try {
await deletePhotoApi(id);
setFolderPhotos((prev) => prev.filter((p) => p.id !== id));
setPhotosAll((prev) => prev.filter((p) => p.id !== id));
setSnackbar({ open: true, severity: 'success', message: 'Photo deleted' });
await loadAll();
} catch (err: any) {
console.error('deletePhoto error', err);
setSnackbar({ open: true, severity: 'error', message: `Failed to delete: ${err?.message || err}` });
} finally {
setDeletingId(null);
}
};
const handleDeleteFolder = async (title: string) => {
if (!confirm(`Delete entire folder "${title}" and all its photos? This is irreversible.`)) return;
setDeletingFolder(title);
try {
await deleteFolderApi(title);
setSnackbar({ open: true, severity: 'success', message: `Folder "${title}" deleted` });
await loadAll();
setFolderModalOpen(false);
} catch (err: any) {
console.error('deleteFolder error', err);
setSnackbar({ open: true, severity: 'error', message: `Failed to delete folder: ${err?.message || err}` });
} finally {
setDeletingFolder(null);
}
};
useEffect(() => {
const map = new Map<string, FolderInfo>();
photosAll.forEach((it) => {
const title = it.title ?? 'untitled';
if (!map.has(title)) map.set(title, { title, count: 0, thumb: it.url });
const e = map.get(title)!;
e.count += 1;
if (!e.thumb) e.thumb = it.url;
});
setFolders(Array.from(map.values()).sort((a, b) => b.count - a.count));
}, [photosAll]);
/* ============================
UI
============================ */
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppBar position="static" sx={{ bgcolor: 'primary.main' }}>
<Toolbar>
<CameraAlt sx={{ mr: 2 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Wedding Gallery Folders
</Typography>
<Chip icon={<Favorite />} label="Events" sx={{ bgcolor: 'secondary.main', color: '#111' }} />
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box textAlign="center" mb={3}>
<Typography variant="h4">Events (Folders)</Typography>
<Typography variant="body2" color="text.secondary">
Only folders are shown here. Click a folder to manage photos inside.
</Typography>
<Typography variant="caption" color="text.secondary">
Queue: {queue.length}/{MAX_QUEUE_FILES}
</Typography>
</Box>
<Paper sx={{ p: 3, mb: 3 }}>
<SafeGrid container spacing={2} alignItems="center">
<SafeGrid item xs={12} md={8}>
<Box
{...getRootProps()}
sx={{
p: 3,
border: '2px dashed',
borderColor: isDragActive ? 'primary.light' : 'grey.300',
borderRadius: 1,
textAlign: 'center',
cursor: 'pointer',
}}
>
<input {...getInputProps()} />
<CloudUpload sx={{ fontSize: 40, color: 'primary.main' }} />
<Typography variant="h6">{isDragActive ? 'Drop images here' : 'Drag & drop images here, or click to select'}</Typography>
<Typography variant="body2" color="text.secondary">
Max {(MAX_FILE_SIZE / (1024 * 1024)).toFixed(0)}MB per file. Allowed: jpg, png, gif, webp. Max {MAX_QUEUE_FILES} files in queue.
</Typography>
</Box>
</SafeGrid>
<SafeGrid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField size="small" placeholder="Event title (folder)" value={commonTitle} onChange={(e) => setCommonTitle(e.target.value)} />
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" onClick={uploadAll} disabled={!queue.some((q) => q.status === 'pending' || q.status === 'error')} sx={{ bgcolor: 'primary.main' }}>
Upload All ({queue.filter((q) => q.status === 'pending' || q.status === 'error').length})
</Button>
<Button variant="outlined" onClick={() => { queue.forEach((q) => { try { URL.revokeObjectURL(q.previewUrl); } catch { } }); setQueue([]); setSnackbar({ open: true, severity: 'info', message: 'Cleared queue' }); }}>
Clear
</Button>
</Box>
{/* Queue preview thumbnails */}
<Box sx={{ display: 'flex', gap: 1, mt: 1, overflowX: 'auto', py: 1 }}>
{queue.map((q) => (
<Card key={q.id} sx={{ width: 140, flex: '0 0 140px' }}>
<CardMedia component="img" height={90} image={q.previewUrl} sx={{ objectFit: 'cover' }} />
<CardContent sx={{ p: 1 }}>
<Typography variant="caption" noWrap>{q.file.name}</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
{q.status === 'uploading' ? <CircularProgress size={18} /> : q.status === 'done' ? <Typography variant="caption" color="success.main">Done</Typography> : q.status === 'error' ? <Typography variant="caption" color="error.main">Err</Typography> : <Typography variant="caption">Pending</Typography>}
<IconButton size="small" onClick={() => retryOne(q.id)} disabled={q.status !== 'error'}><Replay fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => removeOne(q.id)}><DeleteIcon fontSize="small" /></IconButton>
</Box>
<LinearProgress variant="determinate" value={q.progress} sx={{ mt: 1 }} />
</CardContent>
</Card>
))}
</Box>
</Box>
</SafeGrid>
</SafeGrid>
</Paper>
{/* Folders */}
<Box sx={{ mb: 4 }}>
{folders.length === 0 ? (
<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>
) : (
<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={() => openFolder(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" onClick={async (e) => { e.stopPropagation(); await handleDeleteFolder(f.title); }}>
<DeleteIcon />
</IconButton>
</Box>
</Box>
</CardContent>
</Box>
</Card>
</SafeGrid>
))}
</SafeGrid>
)}
</Box>
</Container>
{/* Folder modal */}
<Dialog open={folderModalOpen} onClose={() => setFolderModalOpen(false)} fullWidth maxWidth="md">
<DialogTitle>
Manage {folderOpenTitle} ({folderPhotos.length})
<Box component="span" sx={{ float: 'right' }}>
<Button variant="outlined" color="error" size="small" onClick={() => { if (folderOpenTitle) handleDeleteFolder(folderOpenTitle); }} disabled={!!deletingFolder}>
Delete Folder
</Button>
</Box>
</DialogTitle>
<DialogContent dividers>
{folderPhotos.length === 0 ? (
<Typography variant="body2">No photos in this event.</Typography>
) : (
<Grid container spacing={2}>
{folderPhotos.map((p, idx) => (
<Grid key={p.id}>
<Card>
<Box sx={{ position: 'relative' }}>
{/* clicking image opens lightbox at that index */}
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
setLightboxStartIndex(idx);
setLightboxOpen(true);
}}
>
<CardMedia component="img" height="140" image={p.url} />
</Box>
<IconButton
sx={{ position: 'absolute', top: 6, right: 6, bgcolor: 'rgba(255,255,255,0.8)' }}
onClick={() => handleDeletePhoto(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 sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<IconButton size="small" onClick={() => {
try {
const a = document.createElement('a');
a.href = p.url;
a.download = `${p.filename ?? p.title ?? 'photo'}.jpg`;
document.body.appendChild(a);
a.click();
a.remove();
} catch (err) {
console.error('download error', err);
setSnackbar({ open: true, severity: 'error', message: 'Download failed' });
}
}}>
<Download fontSize="small" />
</IconButton>
<Typography variant="caption" color="text.secondary">{p.created_at ? new Date(p.created_at).toLocaleDateString() : ''}</Typography>
</Box>
</Box>
</Card>
</Grid>
))}
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setFolderModalOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
{/* Lightbox */}
<LightboxModal
open={lightboxOpen}
photos={folderPhotos}
startIndex={lightboxStartIndex}
onClose={() => setLightboxOpen(false)}
/>
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
<Alert severity={snackbar.severity || 'info'} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
{snackbar.message}
</Alert>
</Snackbar>
</ThemeProvider>
);
}

102
src/WeddingGallery/apis.ts Normal file
View File

@ -0,0 +1,102 @@
// 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
): 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 });
} 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);
} 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();
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));
if (Array.isArray(firstArray)) imagesArray = firstArray as 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 (id: number | string) => {
const url = `${MFS_API_BASE}/image-upload/${id}`;
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/folder?title=${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';
};

View File

@ -0,0 +1,53 @@
// 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;

View File

@ -0,0 +1,54 @@
// 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;

View File

@ -0,0 +1,26 @@
// 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;
};

View File

@ -1,5 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Box, Container, Grid, Typography, TextField, Chip, IconButton, InputAdornment } from '@mui/material'; import {
Box, Container, Grid, Typography, TextField, Chip, IconButton,
InputAdornment, Card, CardContent, CardMedia, Button, Avatar,
useTheme, useMediaQuery
} from '@mui/material';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import InstagramIcon from '@mui/icons-material/Instagram'; import InstagramIcon from '@mui/icons-material/Instagram';
import FacebookIcon from '@mui/icons-material/Facebook'; import FacebookIcon from '@mui/icons-material/Facebook';
@ -7,10 +11,34 @@ import TwitterIcon from '@mui/icons-material/Twitter';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { keyframes } from '@emotion/react'; import { keyframes } from '@emotion/react';
import { styled } from '@mui/system'; import { styled } from '@mui/system';
import BlogCard from '../blogs/BlogCard'; import { useNavigate } from 'react-router-dom';
import { BlogData, BlogPost } from '../../types/blogs';
// Use your existing color palette // Define TypeScript interfaces
interface Author {
name: string;
avatar: string;
role: string;
}
interface BlogPost {
id: string;
title: string;
excerpt: string;
content: string;
image: string;
author: Author;
date: string;
readTime: string;
tags: string[];
category: string;
}
interface BlogCardProps {
post: BlogPost;
index: number;
}
// Color palette
const colors = { const colors = {
paper: '#F5F5EF', paper: '#F5F5EF',
ink: '#1A2526', ink: '#1A2526',
@ -22,9 +50,26 @@ const colors = {
}; };
// Animations // Animations
const fadeInUp = keyframes`
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
`;
const fadeIn = keyframes` const fadeIn = keyframes`
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; }
`;
const float = keyframes`
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
`; `;
const drawUnderline = keyframes` const drawUnderline = keyframes`
@ -32,6 +77,25 @@ const drawUnderline = keyframes`
100% { width: 100%; } 100% { width: 100%; }
`; `;
const pulse = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
`;
// Styled Components
interface AnimatedSectionProps {
delay?: string;
}
const AnimatedSection = styled(Box, {
shouldForwardProp: (prop) => prop !== 'delay',
})<AnimatedSectionProps>(({ delay = '0s' }) => ({
opacity: 0,
animation: `${fadeInUp} 0.8s forwards`,
animationDelay: delay,
}));
const VintageUnderline = styled(Box)({ const VintageUnderline = styled(Box)({
position: 'relative', position: 'relative',
display: 'inline-block', display: 'inline-block',
@ -48,106 +112,343 @@ const VintageUnderline = styled(Box)({
}, },
}); });
const AnimatedSection = styled(Box)({ const FloatingElement = styled(Box)({
opacity: 0, animation: `${float} 6s ease-in-out infinite`,
transform: 'translateY(20px)',
animation: `${fadeIn} 0.8s forwards`,
}); });
// Mock data - in real app, this would be imported from JSON // BlogCard Component
const blogData: BlogData = { const BlogCard: React.FC<BlogCardProps> = ({ post, index }) => {
posts: [ const navigate = useNavigate();
{ const [ref, inView] = useInView({ threshold: 0.2, triggerOnce: true });
id: "1", const theme = useTheme();
title: "The Art of Hand Block Printing", const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
excerpt: "Discover the ancient technique of hand block printing that has been passed down through generations of Indian artisans.",
content: "Full content would be here...", const handleReadMore = () => {
image: "https://images.pexels.com/photos/6011599/pexels-photo-6011599.jpeg?auto=compress&cs=tinysrgb&w=500", navigate(`/blog/${post.id}`);
author: { };
name: "Priya Sharma",
avatar: "https://images.pexels.com/photos/3762800/pexels-photo-3762800.jpeg?auto=compress&cs=tinysrgb&w=500", return (
role: "Textile Conservationist" <Card
}, ref={ref}
date: "2023-10-15", sx={{
readTime: "5 min read", opacity: 0,
tags: ["Textiles", "Craft", "Heritage"], animation: inView ? `${fadeInUp} 0.8s forwards` : 'none',
category: "Artisan Techniques" animationDelay: inView ? `${index * 0.1}s` : '0s',
}, height: '100%',
{ display: 'flex',
id: "2", flexDirection: 'column',
title: "Natural Dyes: Colors from Nature", background: colors.paper,
excerpt: "Explore how traditional Indian artisans extract vibrant colors from plants, minerals, and other natural sources.", border: `1px solid ${colors.border}`,
content: "Full content would be here...", borderRadius: '8px',
image: "https://images.pexels.com/photos/1375736/pexels-photo-1375736.jpeg?auto=compress&cs=tinysrgb&w=500", overflow: 'hidden',
author: { transition: 'all 0.3s ease',
name: "Rajiv Mehta", boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
avatar: "https://images.pexels.com/photos/2379004/pexels-photo-2379004.jpeg?auto=compress&cs=tinysrgb&w=500", '&:hover': {
role: "Natural Dye Expert" transform: 'translateY(-5px)',
}, boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
date: "2023-09-22", '& .blog-image': {
readTime: "7 min read", transform: 'scale(1.05)',
tags: ["Eco-friendly", "Sustainability", "Natural"], }
category: "Sustainable Practices" },
}, }}
{ >
id: "3", <Box sx={{ position: 'relative', overflow: 'hidden' }}>
title: "The Weavers of Varanasi", <CardMedia
excerpt: "A journey into the world of Varanasi's master weavers who create the exquisite Banarasi silk sarees.", component="img"
content: "Full content would be here...", height="240"
image: "https://images.pexels.com/photos/942803/pexels-photo-942803.jpeg?auto=compress&cs=tinysrgb&w=500", image={post.image}
author: { alt={post.title}
name: "Anjali Patel", className="blog-image"
avatar: "https://images.pexels.com/photos/3785077/pexels-photo-3785077.jpeg?auto=compress&cs=tinysrgb&w=500", sx={{
role: "Textile Historian" transition: 'transform 0.5s ease',
}, objectFit: 'cover',
date: "2023-08-30", width: '100%',
readTime: "8 min read", }}
tags: ["Weaving", "Silk", "Heritage"], />
category: "Artisan Stories" <Chip
}, label={post.category}
{ size="small"
id: "4", sx={{
title: "Reviving Ancient Embroidery Techniques", position: 'absolute',
excerpt: "How contemporary designers are working with artisans to preserve and modernize traditional embroidery methods.", top: 16,
content: "Full content would be here...", right: 16,
image: "https://images.pexels.com/photos/6347892/pexels-photo-6347892.jpeg?auto=compress&cs=tinysrgb&w=500", background: colors.accent,
author: { color: colors.ink,
name: "Sanjay Kumar", fontWeight: 600,
avatar: "https://images.pexels.com/photos/2182970/pexels-photo-2182970.jpeg?auto=compress&cs=tinysrgb&w=500", fontSize: '0.7rem',
role: "Fashion Designer" animation: `${pulse} 2s infinite`,
}, }}
date: "2023-08-15", />
readTime: "6 min read", </Box>
tags: ["Embroidery", "Design", "Revival"],
category: "Design Innovation" <CardContent sx={{ p: 3, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
} <Box sx={{ mb: 2, display: 'flex', alignItems: 'center' }}>
], <Avatar
categories: [ src={post.author.avatar}
"Artisan Techniques", alt={post.author.name}
"Sustainable Practices", sx={{
"Artisan Stories", width: 32,
"Design Innovation", height: 32,
"Cultural Heritage" mr: 1.5,
], border: `2px solid ${colors.border}`,
tags: [ }}
"Textiles", />
"Craft", <Box>
"Heritage", <Typography
"Eco-friendly", variant="caption"
"Sustainability", sx={{
"Natural", color: colors.dark,
"Weaving", fontFamily: '"Cormorant Garamond", serif',
"Silk", fontWeight: 600,
"Embroidery", display: 'block',
"Design", lineHeight: 1.2,
"Revival" }}
] >
{post.author.name}
</Typography>
<Typography
variant="caption"
sx={{
color: colors.secondary,
fontFamily: '"Cormorant Garamond", serif',
fontSize: '0.7rem',
fontStyle: 'italic',
}}
>
{post.author.role}
</Typography>
</Box>
</Box>
<Typography
variant="h6"
sx={{
fontWeight: 600,
color: colors.ink,
fontFamily: '"Playfair Display", serif',
mb: 2,
lineHeight: 1.3,
fontSize: isMobile ? '1.1rem' : '1.25rem',
minHeight: isMobile ? 'auto' : '64px',
}}
>
{post.title}
</Typography>
<Typography
variant="body2"
sx={{
color: colors.secondary,
fontFamily: '"Cormorant Garamond", serif',
mb: 2,
flexGrow: 1,
fontSize: '1rem',
lineHeight: 1.6,
}}
>
{post.excerpt}
</Typography>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 'auto',
}}>
<Typography
variant="caption"
sx={{
color: colors.secondary,
fontFamily: '"Cormorant Garamond", serif',
display: 'flex',
alignItems: 'center',
}}
>
{post.date} {post.readTime}
</Typography>
<Button
onClick={handleReadMore}
sx={{
color: colors.accent,
fontFamily: '"Cormorant Garamond", serif',
fontWeight: 600,
fontSize: '0.9rem',
position: 'relative',
padding: '4px 8px',
'&::after': {
content: '""',
position: 'absolute',
bottom: 0,
left: 0,
width: '0',
height: '1px',
backgroundColor: colors.accent,
transition: 'width 0.3s ease',
},
'&:hover': {
color: colors.dark,
background: 'transparent',
'&::after': {
width: '100%',
},
}
}}
>
Read Story
</Button>
</Box>
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{post.tags.map((tag: string, i: number) => (
<Chip
key={i}
label={tag}
size="small"
variant="outlined"
sx={{
color: colors.secondary,
borderColor: colors.border,
fontSize: '0.6rem',
height: '20px',
}}
/>
))}
</Box>
</CardContent>
</Card>
);
}; };
// BlogPage Component
const BlogPage: React.FC = () => { const BlogPage: React.FC = () => {
const [selectedCategory, setSelectedCategory] = useState('all'); const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [heroRef, heroInView] = useInView({ threshold: 0.1, triggerOnce: true }); const [heroRef, heroInView] = useInView({ threshold: 0.1, triggerOnce: true });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Mock data
const blogData = {
posts: [
{
id: "1",
title: "The Art of Hand Block Printing",
excerpt: "Discover the ancient technique of hand block printing that has been passed down through generations of Indian artisans.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/6011599/pexels-photo-6011599.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Priya Sharma",
avatar: "https://images.pexels.com/photos/3762800/pexels-photo-3762800.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Textile Conservationist"
},
date: "2023-10-15",
readTime: "5 min read",
tags: ["Textiles", "Craft", "Heritage"],
category: "Artisan Techniques"
},
{
id: "2",
title: "Natural Dyes: Colors from Nature",
excerpt: "Explore how traditional Indian artisans extract vibrant colors from plants, minerals, and other natural sources.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/1375736/pexels-photo-1375736.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Rajiv Mehta",
avatar: "https://images.pexels.com/photos/2379004/pexels-photo-2379004.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Natural Dye Expert"
},
date: "2023-09-22",
readTime: "7 min read",
tags: ["Eco-friendly", "Sustainability", "Natural"],
category: "Sustainable Practices"
},
{
id: "3",
title: "The Weavers of Varanasi",
excerpt: "A journey into the world of Varanasi's master weavers who create the exquisite Banarasi silk sarees.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/942803/pexels-photo-942803.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Anjali Patel",
avatar: "https://images.pexels.com/photos/3785077/pexels-photo-3785077.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Textile Historian"
},
date: "2023-08-30",
readTime: "8 min read",
tags: ["Weaving", "Silk", "Heritage"],
category: "Artisan Stories"
},
{
id: "4",
title: "Reviving Ancient Embroidery Techniques",
excerpt: "How contemporary designers are working with artisans to preserve and modernize traditional embroidery methods.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/6347892/pexels-photo-6347892.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Sanjay Kumar",
avatar: "https://images.pexels.com/photos/2182970/pexels-photo-2182970.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Fashion Designer"
},
date: "2023-08-15",
readTime: "6 min read",
tags: ["Embroidery", "Design", "Revival"],
category: "Design Innovation"
},
{
id: "5",
title: "Pottery Traditions of Rajasthan",
excerpt: "Exploring the centuries-old pottery techniques that continue to thrive in the desert state.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/4110012/pexels-photo-4110012.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Vikram Singh",
avatar: "https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Cultural Anthropologist"
},
date: "2023-07-28",
readTime: "9 min read",
tags: ["Pottery", "Tradition", "Rajasthan"],
category: "Cultural Heritage"
},
{
id: "6",
title: "Sustainable Fashion Revolution",
excerpt: "How traditional Indian textiles are leading the way in sustainable fashion practices worldwide.",
content: "Full content would be here...",
image: "https://images.pexels.com/photos/4947734/pexels-photo-4947734.jpeg?auto=compress&cs=tinysrgb&w=500",
author: {
name: "Meera Desai",
avatar: "https://images.pexels.com/photos/3756678/pexels-photo-3756678.jpeg?auto=compress&cs=tinysrgb&w=500",
role: "Fashion Entrepreneur"
},
date: "2023-07-10",
readTime: "7 min read",
tags: ["Fashion", "Sustainability", "Innovation"],
category: "Sustainable Practices"
}
],
categories: [
"Artisan Techniques",
"Sustainable Practices",
"Artisan Stories",
"Design Innovation",
"Cultural Heritage"
],
tags: [
"Textiles",
"Craft",
"Heritage",
"Eco-friendly",
"Sustainability",
"Natural",
"Weaving",
"Silk",
"Embroidery",
"Design",
"Revival"
]
};
const filteredPosts = useMemo(() => { const filteredPosts = useMemo(() => {
return blogData.posts.filter(post => { return blogData.posts.filter(post => {
@ -166,48 +467,65 @@ const BlogPage: React.FC = () => {
minHeight: '100vh', minHeight: '100vh',
background: colors.paper, background: colors.paper,
color: colors.ink, color: colors.ink,
pt: isMobile ? 4 : 0,
}}> }}>
{/* Header Section */} {/* Header Section */}
<Box sx={{ <Box sx={{
background: `linear-gradient(to bottom, ${colors.paper} 0%, ${colors.highlight} 100%)`, background: `linear-gradient(to bottom, ${colors.paper} 0%, ${colors.highlight}40 100%)`,
py: 6, py: isMobile ? 4 : 8,
borderBottom: `1px solid ${colors.border}`, borderBottom: `1px solid ${colors.border}30`,
}}> }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}> <Box sx={{
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.1s' : '0s' }}> display: 'flex',
<Typography justifyContent: 'space-between',
variant="h3" alignItems: isMobile ? 'center' : 'flex-start',
sx={{ flexDirection: isMobile ? 'column' : 'row',
fontWeight: 300, gap: isMobile ? 2 : 0,
color: colors.ink, mb: 4
letterSpacing: '2px', }}>
fontFamily: '"Playfair Display", serif', <AnimatedSection delay={heroInView ? '0.1s' : '0s'}>
}} <Box>
> <FloatingElement>
<VintageUnderline>Journal</VintageUnderline> <Typography
</Typography> variant={isMobile ? "h4" : "h3"}
<Typography sx={{
variant="h6" fontWeight: 300,
sx={{ color: colors.ink,
mt: 1, letterSpacing: '2px',
color: colors.secondary, fontFamily: '"Playfair Display", serif',
fontWeight: 300, }}
fontStyle: 'italic', >
fontFamily: '"Cormorant Garamond", serif', <VintageUnderline>Journal</VintageUnderline>
}} </Typography>
> </FloatingElement>
Stories of Craft, Culture & Heritage <Typography
</Typography> variant={isMobile ? "body1" : "h6"}
sx={{
mt: 1,
color: colors.secondary,
fontWeight: 300,
fontStyle: 'italic',
fontFamily: '"Cormorant Garamond", serif',
}}
>
Stories of Craft, Culture & Heritage
</Typography>
</Box>
</AnimatedSection> </AnimatedSection>
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.2s' : '0s' }}>
<Box sx={{ display: 'flex', gap: 1 }}> <AnimatedSection delay={heroInView ? '0.2s' : '0s'}>
<Box sx={{ display: 'flex', gap: 1, mt: isMobile ? 1 : 0 }}>
<IconButton <IconButton
href="https://instagram.com" href="https://instagram.com"
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >
@ -218,7 +536,11 @@ const BlogPage: React.FC = () => {
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >
@ -229,7 +551,11 @@ const BlogPage: React.FC = () => {
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >
@ -239,7 +565,7 @@ const BlogPage: React.FC = () => {
</AnimatedSection> </AnimatedSection>
</Box> </Box>
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.3s' : '0s' }}> <AnimatedSection delay={heroInView ? '0.3s' : '0s'}>
<TextField <TextField
fullWidth fullWidth
placeholder="Search articles..." placeholder="Search articles..."
@ -270,8 +596,13 @@ const BlogPage: React.FC = () => {
/> />
</AnimatedSection> </AnimatedSection>
<AnimatedSection ref={heroRef} style={{ animationDelay: heroInView ? '0.4s' : '0s' }}> <AnimatedSection delay={heroInView ? '0.4s' : '0s'}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> <Box sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
justifyContent: isMobile ? 'center' : 'flex-start'
}}>
<Chip <Chip
label="All" label="All"
onClick={() => setSelectedCategory('all')} onClick={() => setSelectedCategory('all')}
@ -281,9 +612,12 @@ const BlogPage: React.FC = () => {
backgroundColor: selectedCategory === 'all' ? colors.accent : 'transparent', backgroundColor: selectedCategory === 'all' ? colors.accent : 'transparent',
borderColor: colors.border, borderColor: colors.border,
fontFamily: '"Cormorant Garamond", serif', fontFamily: '"Cormorant Garamond", serif',
'&:hover': {
backgroundColor: selectedCategory === 'all' ? colors.accent : `${colors.highlight}80`
}
}} }}
/> />
{blogData.categories.map((category) => ( {blogData.categories.map((category: string) => (
<Chip <Chip
key={category} key={category}
label={category} label={category}
@ -294,6 +628,9 @@ const BlogPage: React.FC = () => {
backgroundColor: selectedCategory === category ? colors.accent : 'transparent', backgroundColor: selectedCategory === category ? colors.accent : 'transparent',
borderColor: colors.border, borderColor: colors.border,
fontFamily: '"Cormorant Garamond", serif', fontFamily: '"Cormorant Garamond", serif',
'&:hover': {
backgroundColor: selectedCategory === category ? colors.accent : `${colors.highlight}80`
}
}} }}
/> />
))} ))}
@ -303,9 +640,9 @@ const BlogPage: React.FC = () => {
</Box> </Box>
{/* Blog Posts Grid */} {/* Blog Posts Grid */}
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: isMobile ? 4 : 8 }}>
<Grid container spacing={4}> <Grid container spacing={isMobile ? 2 : 4}>
{filteredPosts.map((post, index) => ( {filteredPosts.map((post: BlogPost, index: number) => (
<Grid key={post.id}> <Grid key={post.id}>
<BlogCard post={post} index={index} /> <BlogCard post={post} index={index} />
</Grid> </Grid>
@ -320,9 +657,19 @@ const BlogPage: React.FC = () => {
color: colors.secondary, color: colors.secondary,
fontFamily: '"Cormorant Garamond", serif', fontFamily: '"Cormorant Garamond", serif',
fontStyle: 'italic', fontStyle: 'italic',
mb: 2
}} }}
> >
No articles found matching your criteria No articles found
</Typography>
<Typography
variant="body1"
sx={{
color: colors.secondary,
fontFamily: '"Cormorant Garamond", serif',
}}
>
Try a different search or category
</Typography> </Typography>
</Box> </Box>
)} )}
@ -332,11 +679,12 @@ const BlogPage: React.FC = () => {
<Box sx={{ <Box sx={{
py: 5, py: 5,
textAlign: 'center', textAlign: 'center',
borderTop: `1px solid ${colors.border}`, borderTop: `1px solid ${colors.border}30`,
background: colors.paper, background: colors.paper,
mt: 4
}}> }}>
<Container maxWidth="lg"> <Container maxWidth="lg">
<AnimatedSection style={{ animationDelay: '0.1s' }}> <AnimatedSection delay={'0.1s'}>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
@ -370,7 +718,11 @@ const BlogPage: React.FC = () => {
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >
@ -381,7 +733,11 @@ const BlogPage: React.FC = () => {
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >
@ -392,7 +748,11 @@ const BlogPage: React.FC = () => {
target="_blank" target="_blank"
sx={{ sx={{
color: colors.secondary, color: colors.secondary,
'&:hover': { color: colors.accent, transform: 'scale(1.1)' }, '&:hover': {
color: colors.accent,
transform: 'scale(1.1)',
background: `${colors.highlight}80`
},
transition: 'all 0.3s ease' transition: 'all 0.3s ease'
}} }}
> >

458
yarn.lock
View File

@ -173,7 +173,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.28.3, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7": "@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.28.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
version: 7.28.4 version: 7.28.4
resolution: "@babel/runtime@npm:7.28.4" resolution: "@babel/runtime@npm:7.28.4"
checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7
@ -661,6 +661,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@isaacs/balanced-match@npm:^4.0.1":
version: 4.0.1
resolution: "@isaacs/balanced-match@npm:4.0.1"
checksum: 10c0/7da011805b259ec5c955f01cee903da72ad97c5e6f01ca96197267d3f33103d5b2f8a1af192140f3aa64526c593c8d098ae366c2b11f7f17645d12387c2fd420
languageName: node
linkType: hard
"@isaacs/brace-expansion@npm:^5.0.0":
version: 5.0.0
resolution: "@isaacs/brace-expansion@npm:5.0.0"
dependencies:
"@isaacs/balanced-match": "npm:^4.0.1"
checksum: 10c0/b4d4812f4be53afc2c5b6c545001ff7a4659af68d4484804e9d514e183d20269bb81def8682c01a22b17c4d6aed14292c8494f7d2ac664e547101c1a905aa977
languageName: node
linkType: hard
"@isaacs/cliui@npm:^8.0.2": "@isaacs/cliui@npm:^8.0.2":
version: 8.0.2 version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2" resolution: "@isaacs/cliui@npm:8.0.2"
@ -728,49 +744,49 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/core-downloads-tracker@npm:^7.3.2": "@mui/core-downloads-tracker@npm:^7.3.5":
version: 7.3.2 version: 7.3.5
resolution: "@mui/core-downloads-tracker@npm:7.3.2" resolution: "@mui/core-downloads-tracker@npm:7.3.5"
checksum: 10c0/8549ac661e07926e1c1de2664ad50f68fb4f3f6050f3cfe7bf2e8a7ceaefde99c1615f4ab5185dff22a7d72874d1dcc5fdc3651d08ed4eb1abfb798629f3991f checksum: 10c0/72c71d43b3609ccd5eab5b3bfc5bfc2232b79cfb210cb64a66298de0b2effccb1843aa8cdb6e062bc6f5df91c02d70de84984bb6fab9745ffdf00e81a574dc9b
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/icons-material@npm:^7.3.2": "@mui/icons-material@npm:^7.3.2":
version: 7.3.2 version: 7.3.5
resolution: "@mui/icons-material@npm:7.3.2" resolution: "@mui/icons-material@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
peerDependencies: peerDependencies:
"@mui/material": ^7.3.2 "@mui/material": ^7.3.5
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/25d1f0dcbbb4a35f320aac03317657b15631f9d5b9d48d07e34850d61253a2b4d8dd5e5ca624d76f44b5ceda471ea57d2611f6627fec1eef9fce78855ec01cbf checksum: 10c0/5fa551acabc8eddf30113a45a7c7b9923ec0f520608442cb914345493208aaa5b4ba852ad4b766ab58c547a2eb498ab4d6c1a3ebf5d4055a006c2327e397f88c
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/material@npm:^7.3.2": "@mui/material@npm:^7.3.2":
version: 7.3.2 version: 7.3.5
resolution: "@mui/material@npm:7.3.2" resolution: "@mui/material@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
"@mui/core-downloads-tracker": "npm:^7.3.2" "@mui/core-downloads-tracker": "npm:^7.3.5"
"@mui/system": "npm:^7.3.2" "@mui/system": "npm:^7.3.5"
"@mui/types": "npm:^7.4.6" "@mui/types": "npm:^7.4.8"
"@mui/utils": "npm:^7.3.2" "@mui/utils": "npm:^7.3.5"
"@popperjs/core": "npm:^2.11.8" "@popperjs/core": "npm:^2.11.8"
"@types/react-transition-group": "npm:^4.4.12" "@types/react-transition-group": "npm:^4.4.12"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
csstype: "npm:^3.1.3" csstype: "npm:^3.1.3"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
react-is: "npm:^19.1.1" react-is: "npm:^19.2.0"
react-transition-group: "npm:^4.4.5" react-transition-group: "npm:^4.4.5"
peerDependencies: peerDependencies:
"@emotion/react": ^11.5.0 "@emotion/react": ^11.5.0
"@emotion/styled": ^11.3.0 "@emotion/styled": ^11.3.0
"@mui/material-pigment-css": ^7.3.2 "@mui/material-pigment-css": ^7.3.5
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
@ -783,16 +799,16 @@ __metadata:
optional: true optional: true
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/4b82a65af93fe9517991f45c2f9dc127728199921f5c4c5cd7a8cd48e1c89ba17799011440f1b7e32993871c13b3044878a3170ddd9ce0e7cfe5ca0e7e3613f2 checksum: 10c0/363ff79d0eaf8044510529bf8d143d7e6f5298297dcfbd0816c5caf49e9514bde6b187b0e135cc1d73ac2ecfec1b06d3fea3bba952e7d8d0bfc997d0510fff8a
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/private-theming@npm:^7.3.2": "@mui/private-theming@npm:^7.3.5":
version: 7.3.2 version: 7.3.5
resolution: "@mui/private-theming@npm:7.3.2" resolution: "@mui/private-theming@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
"@mui/utils": "npm:^7.3.2" "@mui/utils": "npm:^7.3.5"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
peerDependencies: peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
@ -800,15 +816,15 @@ __metadata:
peerDependenciesMeta: peerDependenciesMeta:
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/fb6067e92a1bc02d4b2b49fa58901200ccf4b79760a0227bf2859bd2cb99c46ba0f43ece9494eaa220710703eaf309a7c6e732daf4176520c0a16e1407846399 checksum: 10c0/1347cf2a1ec1ae93d26134143c20314d53dac61fe5c8c7aa00ab37d9e89f5e6245f787dee9b0bf3d34fc614c9a5da1f5d45759fcd2520ddef4c10e755c4abc5e
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/styled-engine@npm:^7.3.2": "@mui/styled-engine@npm:^7.3.5":
version: 7.3.2 version: 7.3.5
resolution: "@mui/styled-engine@npm:7.3.2" resolution: "@mui/styled-engine@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
"@emotion/cache": "npm:^11.14.0" "@emotion/cache": "npm:^11.14.0"
"@emotion/serialize": "npm:^1.3.3" "@emotion/serialize": "npm:^1.3.3"
"@emotion/sheet": "npm:^1.4.0" "@emotion/sheet": "npm:^1.4.0"
@ -823,19 +839,19 @@ __metadata:
optional: true optional: true
"@emotion/styled": "@emotion/styled":
optional: true optional: true
checksum: 10c0/d5644b40269a70a1c86844f7301aa6865289994e7835b471f3503e67795010d5334362cfd21d8804f54e8b71d6c9c932ca78bafc2325767e3abbe037f9e8e10b checksum: 10c0/01dc8aefde58d5257564b7fd40f37de0f76d79e6bc6b52738cf41c333a053623baf2648f0557fb4b5ded306fd2b98e94797d7e48ad1c1f297747d2a265e22ad0
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/system@npm:^7.3.2": "@mui/system@npm:^7.3.5":
version: 7.3.2 version: 7.3.5
resolution: "@mui/system@npm:7.3.2" resolution: "@mui/system@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
"@mui/private-theming": "npm:^7.3.2" "@mui/private-theming": "npm:^7.3.5"
"@mui/styled-engine": "npm:^7.3.2" "@mui/styled-engine": "npm:^7.3.5"
"@mui/types": "npm:^7.4.6" "@mui/types": "npm:^7.4.8"
"@mui/utils": "npm:^7.3.2" "@mui/utils": "npm:^7.3.5"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
csstype: "npm:^3.1.3" csstype: "npm:^3.1.3"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
@ -851,41 +867,41 @@ __metadata:
optional: true optional: true
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/ed385c37f29a8d4b57bc1c59f8bc06a3e4cc393d86a6e0059229eacc7c96bcb11ae80369de0e459971bde24bdd33078f5578f152f0ac2e796222b269a80833ed checksum: 10c0/12ed6e0f4770848c91c066b6ffb315f2cd31d6281ff12780f8d994d5b677750277812491ba502831601bbe66cbc48812268ae08b7bbc10120e7faf5616807489
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/types@npm:^7.4.6": "@mui/types@npm:^7.4.8":
version: 7.4.6 version: 7.4.8
resolution: "@mui/types@npm:7.4.6" resolution: "@mui/types@npm:7.4.8"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
peerDependencies: peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/baa901e410591d0216b3f959cdbf5a1ee2ce560726d2fba1c700b40f64c1be3e63bd799f1b30a7d0bc8cc45a46d782928ea28d9906d64438f21e305884c48a99 checksum: 10c0/dfdca47c894da372236f7c209544abd2998a77af646baf28d97a49313064b293b2fb434e45f9e5331e3123f9f8863f7b3e1db8542d7bde2d4f1e5f030d85f0c1
languageName: node languageName: node
linkType: hard linkType: hard
"@mui/utils@npm:^7.3.2": "@mui/utils@npm:^7.3.5":
version: 7.3.2 version: 7.3.5
resolution: "@mui/utils@npm:7.3.2" resolution: "@mui/utils@npm:7.3.5"
dependencies: dependencies:
"@babel/runtime": "npm:^7.28.3" "@babel/runtime": "npm:^7.28.4"
"@mui/types": "npm:^7.4.6" "@mui/types": "npm:^7.4.8"
"@types/prop-types": "npm:^15.7.15" "@types/prop-types": "npm:^15.7.15"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
prop-types: "npm:^15.8.1" prop-types: "npm:^15.8.1"
react-is: "npm:^19.1.1" react-is: "npm:^19.2.0"
peerDependencies: peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
"@types/react": "@types/react":
optional: true optional: true
checksum: 10c0/5a88ff08a823b976421f8d61098d56d527d95c222800d5b3f71acff795e7b0db6b02e40228773a6ed7ee22d8eaa607d816215b5a4b6497c21aaa9668c2699b56 checksum: 10c0/c9f9ce12a5053d7aeafd0c390e7d17d331e0366dec9d993c9ad860f78c1d9410e5a33c40601afa039f4120ea299d2a59e76eff705359c7d96fb09ce636ba72b9
languageName: node languageName: node
linkType: hard linkType: hard
@ -916,16 +932,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@npmcli/agent@npm:^3.0.0": "@npmcli/agent@npm:^4.0.0":
version: 3.0.0 version: 4.0.0
resolution: "@npmcli/agent@npm:3.0.0" resolution: "@npmcli/agent@npm:4.0.0"
dependencies: dependencies:
agent-base: "npm:^7.1.0" agent-base: "npm:^7.1.0"
http-proxy-agent: "npm:^7.0.0" http-proxy-agent: "npm:^7.0.0"
https-proxy-agent: "npm:^7.0.1" https-proxy-agent: "npm:^7.0.1"
lru-cache: "npm:^10.0.1" lru-cache: "npm:^11.2.1"
socks-proxy-agent: "npm:^8.0.3" socks-proxy-agent: "npm:^8.0.3"
checksum: 10c0/efe37b982f30740ee77696a80c196912c274ecd2cb243bc6ae7053a50c733ce0f6c09fda085145f33ecf453be19654acca74b69e81eaad4c90f00ccffe2f9271 checksum: 10c0/f7b5ce0f3dd42c3f8c6546e8433573d8049f67ef11ec22aa4704bc41483122f68bf97752e06302c455ead667af5cb753e6a09bff06632bc465c1cfd4c4b75a53
languageName: node languageName: node
linkType: hard linkType: hard
@ -938,13 +954,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
checksum: 10c0/5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd
languageName: node
linkType: hard
"@popperjs/core@npm:^2.11.8": "@popperjs/core@npm:^2.11.8":
version: 2.11.8 version: 2.11.8
resolution: "@popperjs/core@npm:2.11.8" resolution: "@popperjs/core@npm:2.11.8"
@ -1355,10 +1364,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"abbrev@npm:^3.0.0": "abbrev@npm:^4.0.0":
version: 3.0.1 version: 4.0.0
resolution: "abbrev@npm:3.0.1" resolution: "abbrev@npm:4.0.0"
checksum: 10c0/21ba8f574ea57a3106d6d35623f2c4a9111d9ee3e9a5be47baed46ec2457d2eac46e07a5c4a60186f88cb98abbe3e24f2d4cca70bc2b12f1692523e2209a9ccf checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5
languageName: node languageName: node
linkType: hard linkType: hard
@ -1535,6 +1544,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"attr-accept@npm:^2.2.4":
version: 2.2.5
resolution: "attr-accept@npm:2.2.5"
checksum: 10c0/9b4cb82213925cab2d568f71b3f1c7a7778f9192829aac39a281e5418cd00c04a88f873eb89f187e0bf786fa34f8d52936f178e62cbefb9254d57ecd88ada99b
languageName: node
linkType: hard
"available-typed-arrays@npm:^1.0.7": "available-typed-arrays@npm:^1.0.7":
version: 1.0.7 version: 1.0.7
resolution: "available-typed-arrays@npm:1.0.7" resolution: "available-typed-arrays@npm:1.0.7"
@ -1604,23 +1620,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"cacache@npm:^19.0.1": "cacache@npm:^20.0.1":
version: 19.0.1 version: 20.0.1
resolution: "cacache@npm:19.0.1" resolution: "cacache@npm:20.0.1"
dependencies: dependencies:
"@npmcli/fs": "npm:^4.0.0" "@npmcli/fs": "npm:^4.0.0"
fs-minipass: "npm:^3.0.0" fs-minipass: "npm:^3.0.0"
glob: "npm:^10.2.2" glob: "npm:^11.0.3"
lru-cache: "npm:^10.0.1" lru-cache: "npm:^11.1.0"
minipass: "npm:^7.0.3" minipass: "npm:^7.0.3"
minipass-collect: "npm:^2.0.1" minipass-collect: "npm:^2.0.1"
minipass-flush: "npm:^1.0.5" minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4" minipass-pipeline: "npm:^1.2.4"
p-map: "npm:^7.0.2" p-map: "npm:^7.0.2"
ssri: "npm:^12.0.0" ssri: "npm:^12.0.0"
tar: "npm:^7.4.3"
unique-filename: "npm:^4.0.0" unique-filename: "npm:^4.0.0"
checksum: 10c0/01f2134e1bd7d3ab68be851df96c8d63b492b1853b67f2eecb2c37bb682d37cb70bb858a16f2f0554d3c0071be6dfe21456a1ff6fa4b7eed996570d6a25ffe9c checksum: 10c0/e3efcf3af1c984e6e59e03372d9289861736a572e6e05b620606b87a67e71d04cff6dbc99607801cb21bcaae1fb4fb84d4cc8e3fda725e95881329ef03dac602
languageName: node languageName: node
linkType: hard linkType: hard
@ -1802,7 +1817,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": "debug@npm:4":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6
languageName: node
linkType: hard
"debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
version: 4.4.1 version: 4.4.1
resolution: "debug@npm:4.4.1" resolution: "debug@npm:4.4.1"
dependencies: dependencies:
@ -1925,11 +1952,11 @@ __metadata:
linkType: hard linkType: hard
"error-ex@npm:^1.3.1": "error-ex@npm:^1.3.1":
version: 1.3.2 version: 1.3.4
resolution: "error-ex@npm:1.3.2" resolution: "error-ex@npm:1.3.4"
dependencies: dependencies:
is-arrayish: "npm:^0.2.1" is-arrayish: "npm:^0.2.1"
checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce checksum: 10c0/b9e34ff4778b8f3b31a8377e1c654456f4c41aeaa3d10a1138c3b7635d8b7b2e03eb2475d46d8ae055c1f180a1063e100bffabf64ea7e7388b37735df5328664
languageName: node languageName: node
linkType: hard linkType: hard
@ -2341,9 +2368,9 @@ __metadata:
linkType: hard linkType: hard
"exponential-backoff@npm:^3.1.1": "exponential-backoff@npm:^3.1.1":
version: 3.1.2 version: 3.1.3
resolution: "exponential-backoff@npm:3.1.2" resolution: "exponential-backoff@npm:3.1.3"
checksum: 10c0/d9d3e1eafa21b78464297df91f1776f7fbaa3d5e3f7f0995648ca5b89c069d17055033817348d9f4a43d1c20b0eab84f75af6991751e839df53e4dfd6f22e844 checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267
languageName: node languageName: node
linkType: hard linkType: hard
@ -2411,6 +2438,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"file-selector@npm:^2.1.0":
version: 2.1.2
resolution: "file-selector@npm:2.1.2"
dependencies:
tslib: "npm:^2.7.0"
checksum: 10c0/fe827e0e95410aacfcc3eabc38c29cc36055257f03c1c06b631a2b5af9730c142ad2c52f5d64724d02231709617bda984701f52bd1f4b7aca50fb6585a27c1d2
languageName: node
linkType: hard
"fill-range@npm:^7.1.1": "fill-range@npm:^7.1.1":
version: 7.1.1 version: 7.1.1
resolution: "fill-range@npm:7.1.1" resolution: "fill-range@npm:7.1.1"
@ -2463,7 +2499,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"foreground-child@npm:^3.1.0": "foreground-child@npm:^3.3.1":
version: 3.3.1 version: 3.3.1
resolution: "foreground-child@npm:3.3.1" resolution: "foreground-child@npm:3.3.1"
dependencies: dependencies:
@ -2593,19 +2629,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"glob@npm:^10.2.2": "glob@npm:^11.0.3":
version: 10.4.5 version: 11.0.3
resolution: "glob@npm:10.4.5" resolution: "glob@npm:11.0.3"
dependencies: dependencies:
foreground-child: "npm:^3.1.0" foreground-child: "npm:^3.3.1"
jackspeak: "npm:^3.1.2" jackspeak: "npm:^4.1.1"
minimatch: "npm:^9.0.4" minimatch: "npm:^10.0.3"
minipass: "npm:^7.1.2" minipass: "npm:^7.1.2"
package-json-from-dist: "npm:^1.0.0" package-json-from-dist: "npm:^1.0.0"
path-scurry: "npm:^1.11.1" path-scurry: "npm:^2.0.0"
bin: bin:
glob: dist/esm/bin.mjs glob: dist/esm/bin.mjs
checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e checksum: 10c0/7d24457549ec2903920dfa3d8e76850e7c02aa709122f0164b240c712f5455c0b457e6f2a1eee39344c6148e39895be8094ae8cfef7ccc3296ed30bce250c661
languageName: node languageName: node
linkType: hard linkType: hard
@ -2799,9 +2835,9 @@ __metadata:
linkType: hard linkType: hard
"ip-address@npm:^10.0.1": "ip-address@npm:^10.0.1":
version: 10.0.1 version: 10.1.0
resolution: "ip-address@npm:10.0.1" resolution: "ip-address@npm:10.1.0"
checksum: 10c0/1634d79dae18394004775cb6d699dc46b7c23df6d2083164025a2b15240c1164fccde53d0e08bd5ee4fc53913d033ab6b5e395a809ad4b956a940c446e948843 checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566
languageName: node languageName: node
linkType: hard linkType: hard
@ -2862,7 +2898,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.0": "is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1":
version: 2.16.1 version: 2.16.1
resolution: "is-core-module@npm:2.16.1" resolution: "is-core-module@npm:2.16.1"
dependencies: dependencies:
@ -3086,16 +3122,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jackspeak@npm:^3.1.2": "jackspeak@npm:^4.1.1":
version: 3.4.3 version: 4.1.1
resolution: "jackspeak@npm:3.4.3" resolution: "jackspeak@npm:4.1.1"
dependencies: dependencies:
"@isaacs/cliui": "npm:^8.0.2" "@isaacs/cliui": "npm:^8.0.2"
"@pkgjs/parseargs": "npm:^0.11.0" checksum: 10c0/84ec4f8e21d6514db24737d9caf65361511f75e5e424980eebca4199f400874f45e562ac20fa8aeb1dd20ca2f3f81f0788b6e9c3e64d216a5794fd6f30e0e042
dependenciesMeta:
"@pkgjs/parseargs":
optional: true
checksum: 10c0/6acc10d139eaefdbe04d2f679e6191b3abf073f111edf10b1de5302c97ec93fffeb2fdd8681ed17f16268aa9dd4f8c588ed9d1d3bffbbfa6e8bf897cbb3149b9
languageName: node languageName: node
linkType: hard linkType: hard
@ -3228,10 +3260,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": "lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 10.4.3 version: 11.2.2
resolution: "lru-cache@npm:10.4.3" resolution: "lru-cache@npm:11.2.2"
checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb checksum: 10c0/72d7831bbebc85e2bdefe01047ee5584db69d641c48d7a509e86f66f6ee111b30af7ec3bd68a967d47b69a4b1fa8bbf3872630bd06a63b6735e6f0a5f1c8e83d
languageName: node languageName: node
linkType: hard linkType: hard
@ -3244,22 +3276,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"make-fetch-happen@npm:^14.0.3": "make-fetch-happen@npm:^15.0.0":
version: 14.0.3 version: 15.0.3
resolution: "make-fetch-happen@npm:14.0.3" resolution: "make-fetch-happen@npm:15.0.3"
dependencies: dependencies:
"@npmcli/agent": "npm:^3.0.0" "@npmcli/agent": "npm:^4.0.0"
cacache: "npm:^19.0.1" cacache: "npm:^20.0.1"
http-cache-semantics: "npm:^4.1.1" http-cache-semantics: "npm:^4.1.1"
minipass: "npm:^7.0.2" minipass: "npm:^7.0.2"
minipass-fetch: "npm:^4.0.0" minipass-fetch: "npm:^5.0.0"
minipass-flush: "npm:^1.0.5" minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4" minipass-pipeline: "npm:^1.2.4"
negotiator: "npm:^1.0.0" negotiator: "npm:^1.0.0"
proc-log: "npm:^5.0.0" proc-log: "npm:^6.0.0"
promise-retry: "npm:^2.0.1" promise-retry: "npm:^2.0.1"
ssri: "npm:^12.0.0" ssri: "npm:^13.0.0"
checksum: 10c0/c40efb5e5296e7feb8e37155bde8eb70bc57d731b1f7d90e35a092fde403d7697c56fb49334d92d330d6f1ca29a98142036d6480a12681133a0a1453164cb2f0 checksum: 10c0/525f74915660be60b616bcbd267c4a5b59481b073ba125e45c9c3a041bb1a47a2bd0ae79d028eb6f5f95bf9851a4158423f5068539c3093621abb64027e8e461
languageName: node languageName: node
linkType: hard linkType: hard
@ -3287,6 +3319,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"minimatch@npm:^10.0.3":
version: 10.1.1
resolution: "minimatch@npm:10.1.1"
dependencies:
"@isaacs/brace-expansion": "npm:^5.0.0"
checksum: 10c0/c85d44821c71973d636091fddbfbffe62370f5ee3caf0241c5b60c18cd289e916200acb2361b7e987558cd06896d153e25d505db9fc1e43e6b4b6752e2702902
languageName: node
linkType: hard
"minimatch@npm:^3.1.2": "minimatch@npm:^3.1.2":
version: 3.1.2 version: 3.1.2
resolution: "minimatch@npm:3.1.2" resolution: "minimatch@npm:3.1.2"
@ -3314,9 +3355,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"minipass-fetch@npm:^4.0.0": "minipass-fetch@npm:^5.0.0":
version: 4.0.1 version: 5.0.0
resolution: "minipass-fetch@npm:4.0.1" resolution: "minipass-fetch@npm:5.0.0"
dependencies: dependencies:
encoding: "npm:^0.1.13" encoding: "npm:^0.1.13"
minipass: "npm:^7.0.3" minipass: "npm:^7.0.3"
@ -3325,7 +3366,7 @@ __metadata:
dependenciesMeta: dependenciesMeta:
encoding: encoding:
optional: true optional: true
checksum: 10c0/a3147b2efe8e078c9bf9d024a0059339c5a09c5b1dded6900a219c218cc8b1b78510b62dae556b507304af226b18c3f1aeb1d48660283602d5b6586c399eed5c checksum: 10c0/9443aab5feab190972f84b64116e54e58dd87a58e62399cae0a4a7461b80568281039b7c3a38ba96453431ebc799d1e26999e548540156216729a4967cd5ef06
languageName: node languageName: node
linkType: hard linkType: hard
@ -3365,28 +3406,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": "minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
version: 7.1.2 version: 7.1.2
resolution: "minipass@npm:7.1.2" resolution: "minipass@npm:7.1.2"
checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557 checksum: 10c0/b0fd20bb9fb56e5fa9a8bfac539e8915ae07430a619e4b86ff71f5fc757ef3924b23b2c4230393af1eda647ed3d75739e4e0acb250a6b1eb277cf7f8fe449557
languageName: node languageName: node
linkType: hard linkType: hard
"minizlib@npm:^3.0.1": "minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
version: 3.0.2 version: 3.1.0
resolution: "minizlib@npm:3.0.2" resolution: "minizlib@npm:3.1.0"
dependencies: dependencies:
minipass: "npm:^7.1.2" minipass: "npm:^7.1.2"
checksum: 10c0/9f3bd35e41d40d02469cb30470c55ccc21cae0db40e08d1d0b1dff01cc8cc89a6f78e9c5d2b7c844e485ec0a8abc2238111213fdc5b2038e6d1012eacf316f78 checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
"mkdirp@npm:^3.0.1":
version: 3.0.1
resolution: "mkdirp@npm:3.0.1"
bin:
mkdirp: dist/cjs/src/bin.js
checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d
languageName: node languageName: node
linkType: hard linkType: hard
@ -3421,22 +3453,22 @@ __metadata:
linkType: hard linkType: hard
"node-gyp@npm:latest": "node-gyp@npm:latest":
version: 11.4.2 version: 12.1.0
resolution: "node-gyp@npm:11.4.2" resolution: "node-gyp@npm:12.1.0"
dependencies: dependencies:
env-paths: "npm:^2.2.0" env-paths: "npm:^2.2.0"
exponential-backoff: "npm:^3.1.1" exponential-backoff: "npm:^3.1.1"
graceful-fs: "npm:^4.2.6" graceful-fs: "npm:^4.2.6"
make-fetch-happen: "npm:^14.0.3" make-fetch-happen: "npm:^15.0.0"
nopt: "npm:^8.0.0" nopt: "npm:^9.0.0"
proc-log: "npm:^5.0.0" proc-log: "npm:^6.0.0"
semver: "npm:^7.3.5" semver: "npm:^7.3.5"
tar: "npm:^7.4.3" tar: "npm:^7.5.2"
tinyglobby: "npm:^0.2.12" tinyglobby: "npm:^0.2.12"
which: "npm:^5.0.0" which: "npm:^6.0.0"
bin: bin:
node-gyp: bin/node-gyp.js node-gyp: bin/node-gyp.js
checksum: 10c0/0bfd3e96770ed70f07798d881dd37b4267708966d868a0e585986baac487d9cf5831285579fd629a83dc4e434f53e6416ce301097f2ee464cb74d377e4d8bdbe checksum: 10c0/f43efea8aaf0beb6b2f6184e533edad779b2ae38062953e21951f46221dd104006cc574154f2ad4a135467a5aae92c49e84ef289311a82e08481c5df0e8dc495
languageName: node languageName: node
linkType: hard linkType: hard
@ -3447,14 +3479,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"nopt@npm:^8.0.0": "nopt@npm:^9.0.0":
version: 8.1.0 version: 9.0.0
resolution: "nopt@npm:8.1.0" resolution: "nopt@npm:9.0.0"
dependencies: dependencies:
abbrev: "npm:^3.0.0" abbrev: "npm:^4.0.0"
bin: bin:
nopt: bin/nopt.js nopt: bin/nopt.js
checksum: 10c0/62e9ea70c7a3eb91d162d2c706b6606c041e4e7b547cbbb48f8b3695af457dd6479904d7ace600856bf923dd8d1ed0696f06195c8c20f02ac87c1da0e1d315ef checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd
languageName: node languageName: node
linkType: hard linkType: hard
@ -3573,9 +3605,9 @@ __metadata:
linkType: hard linkType: hard
"p-map@npm:^7.0.2": "p-map@npm:^7.0.2":
version: 7.0.3 version: 7.0.4
resolution: "p-map@npm:7.0.3" resolution: "p-map@npm:7.0.4"
checksum: 10c0/46091610da2b38ce47bcd1d8b4835a6fa4e832848a6682cf1652bc93915770f4617afc844c10a77d1b3e56d2472bb2d5622353fa3ead01a7f42b04fc8e744a5c checksum: 10c0/a5030935d3cb2919d7e89454d1ce82141e6f9955413658b8c9403cfe379283770ed3048146b44cde168aa9e8c716505f196d5689db0ae3ce9a71521a2fef3abd
languageName: node languageName: node
linkType: hard linkType: hard
@ -3628,13 +3660,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"path-scurry@npm:^1.11.1": "path-scurry@npm:^2.0.0":
version: 1.11.1 version: 2.0.1
resolution: "path-scurry@npm:1.11.1" resolution: "path-scurry@npm:2.0.1"
dependencies: dependencies:
lru-cache: "npm:^10.2.0" lru-cache: "npm:^11.0.0"
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" minipass: "npm:^7.1.2"
checksum: 10c0/32a13711a2a505616ae1cc1b5076801e453e7aae6ac40ab55b388bb91b9d0547a52f5aaceff710ea400205f18691120d4431e520afbe4266b836fadede15872d checksum: 10c0/2a16ed0e81fbc43513e245aa5763354e25e787dab0d539581a6c3f0f967461a159ed6236b2559de23aa5b88e7dc32b469b6c47568833dd142a4b24b4f5cd2620
languageName: node languageName: node
linkType: hard linkType: hard
@ -3691,10 +3723,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"proc-log@npm:^5.0.0": "proc-log@npm:^6.0.0":
version: 5.0.0 version: 6.0.0
resolution: "proc-log@npm:5.0.0" resolution: "proc-log@npm:6.0.0"
checksum: 10c0/bbe5edb944b0ad63387a1d5b1911ae93e05ce8d0f60de1035b218cdcceedfe39dbd2c697853355b70f1a090f8f58fe90da487c85216bf9671f9499d1a897e9e3 checksum: 10c0/40c5e2b4c55e395a3bd72e38cba9c26e58598a1f4844fa6a115716d5231a0919f46aa8e351147035d91583ad39a794593615078c948bc001fe3beb99276be776
languageName: node languageName: node
linkType: hard linkType: hard
@ -3744,6 +3776,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-dropzone@npm:^14.3.8":
version: 14.3.8
resolution: "react-dropzone@npm:14.3.8"
dependencies:
attr-accept: "npm:^2.2.4"
file-selector: "npm:^2.1.0"
prop-types: "npm:^15.8.1"
peerDependencies:
react: ">= 16.8 || 18.0.0"
checksum: 10c0/e17b1832783cda7b8824fe9370e99185d1abbdd5e4980b2985d6321c5768c8de18ff7b9ad550c809ee9743269dea608ff74d5208062754ce8377ad022897b278
languageName: node
linkType: hard
"react-intersection-observer@npm:^9.16.0": "react-intersection-observer@npm:^9.16.0":
version: 9.16.0 version: 9.16.0
resolution: "react-intersection-observer@npm:9.16.0" resolution: "react-intersection-observer@npm:9.16.0"
@ -3764,10 +3809,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-is@npm:^19.1.1": "react-is@npm:^19.2.0":
version: 19.1.1 version: 19.2.0
resolution: "react-is@npm:19.1.1" resolution: "react-is@npm:19.2.0"
checksum: 10c0/3dba763fcd69835ae263dcd6727d7ffcc44c1d616f04b7329e67aefdc66a567af4f8dcecdd29454c7a707c968aa1eb85083a83fb616f01675ef25e71cf082f97 checksum: 10c0/a63cb346aeced8ac0e671b0f9b33720d2906de02a066ca067075d871a5d4c64cdb328f495baf9b5842d5868c0d5edd1ce18465a7358b52f4b6aa983479c9bfa2
languageName: node languageName: node
linkType: hard linkType: hard
@ -3779,20 +3824,20 @@ __metadata:
linkType: hard linkType: hard
"react-router-dom@npm:^7.8.2": "react-router-dom@npm:^7.8.2":
version: 7.8.2 version: 7.9.6
resolution: "react-router-dom@npm:7.8.2" resolution: "react-router-dom@npm:7.9.6"
dependencies: dependencies:
react-router: "npm:7.8.2" react-router: "npm:7.9.6"
peerDependencies: peerDependencies:
react: ">=18" react: ">=18"
react-dom: ">=18" react-dom: ">=18"
checksum: 10c0/b285182ffa1b26df5025f6e7952edca066928d5688d3447b02e4a7e699ca16941e8a42ecad65ab505914e27fed04a2023c92ae3ebf838226b4381a2e3a69ae01 checksum: 10c0/63984c46385da232655b9e3a8a99f6dd7b94c36827be6e954f246c362f83740b5f59b1de99cae81da3b0cef2220d701dcc22e4fafb4a84600541e1c0450b9d57
languageName: node languageName: node
linkType: hard linkType: hard
"react-router@npm:7.8.2": "react-router@npm:7.9.6":
version: 7.8.2 version: 7.9.6
resolution: "react-router@npm:7.8.2" resolution: "react-router@npm:7.9.6"
dependencies: dependencies:
cookie: "npm:^1.0.1" cookie: "npm:^1.0.1"
set-cookie-parser: "npm:^2.6.0" set-cookie-parser: "npm:^2.6.0"
@ -3802,7 +3847,7 @@ __metadata:
peerDependenciesMeta: peerDependenciesMeta:
react-dom: react-dom:
optional: true optional: true
checksum: 10c0/e3122c2949bcad5e9c990cfb88e9cbd139e5a2a5c1d29664732623907a488634c0ddbf673d07af8f113d418f66270c174f014de8b885996722f431d09f5734be checksum: 10c0/2a177bbe19021e3b8211df849ea5b3f3a4f482327e6de3341aaeaa4f1406dc9be7b675b229eefea6761e04a59a40ccaaf8188f2ee88eb2d0b2a6b6448daea368
languageName: node languageName: node
linkType: hard linkType: hard
@ -3866,15 +3911,15 @@ __metadata:
linkType: hard linkType: hard
"resolve@npm:^1.19.0": "resolve@npm:^1.19.0":
version: 1.22.10 version: 1.22.11
resolution: "resolve@npm:1.22.10" resolution: "resolve@npm:1.22.11"
dependencies: dependencies:
is-core-module: "npm:^2.16.0" is-core-module: "npm:^2.16.1"
path-parse: "npm:^1.0.7" path-parse: "npm:^1.0.7"
supports-preserve-symlinks-flag: "npm:^1.0.0" supports-preserve-symlinks-flag: "npm:^1.0.0"
bin: bin:
resolve: bin/resolve resolve: bin/resolve
checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409
languageName: node languageName: node
linkType: hard linkType: hard
@ -3892,15 +3937,15 @@ __metadata:
linkType: hard linkType: hard
"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin<compat/resolve>": "resolve@patch:resolve@npm%3A^1.19.0#optional!builtin<compat/resolve>":
version: 1.22.10 version: 1.22.11
resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin<compat/resolve>::version=1.22.10&hash=c3c19d" resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin<compat/resolve>::version=1.22.11&hash=c3c19d"
dependencies: dependencies:
is-core-module: "npm:^2.16.0" is-core-module: "npm:^2.16.1"
path-parse: "npm:^1.0.7" path-parse: "npm:^1.0.7"
supports-preserve-symlinks-flag: "npm:^1.0.0" supports-preserve-symlinks-flag: "npm:^1.0.0"
bin: bin:
resolve: bin/resolve resolve: bin/resolve
checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63
languageName: node languageName: node
linkType: hard linkType: hard
@ -4075,7 +4120,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:^7.3.5, semver@npm:^7.6.0": "semver@npm:^7.3.5":
version: 7.7.3
resolution: "semver@npm:7.7.3"
bin:
semver: bin/semver.js
checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e
languageName: node
linkType: hard
"semver@npm:^7.6.0":
version: 7.7.2 version: 7.7.2
resolution: "semver@npm:7.7.2" resolution: "semver@npm:7.7.2"
bin: bin:
@ -4085,9 +4139,9 @@ __metadata:
linkType: hard linkType: hard
"set-cookie-parser@npm:^2.6.0": "set-cookie-parser@npm:^2.6.0":
version: 2.7.1 version: 2.7.2
resolution: "set-cookie-parser@npm:2.7.1" resolution: "set-cookie-parser@npm:2.7.2"
checksum: 10c0/060c198c4c92547ac15988256f445eae523f57f2ceefeccf52d30d75dedf6bff22b9c26f756bd44e8e560d44ff4ab2130b178bd2e52ef5571bf7be3bd7632d9a checksum: 10c0/4381a9eb7ee951dfe393fe7aacf76b9a3b4e93a684d2162ab35594fa4053cc82a4d7d7582bf397718012c9adcf839b8cd8f57c6c42901ea9effe33c752da4a45
languageName: node languageName: node
linkType: hard linkType: hard
@ -4250,6 +4304,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ssri@npm:^13.0.0":
version: 13.0.0
resolution: "ssri@npm:13.0.0"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10c0/405f3a531cd98b013cecb355d63555dca42fd12c7bc6671738aaa9a82882ff41cdf0ef9a2b734ca4f9a760338f114c29d01d9238a65db3ccac27929bd6e6d4b2
languageName: node
linkType: hard
"stop-iteration-iterator@npm:^1.1.0": "stop-iteration-iterator@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "stop-iteration-iterator@npm:1.1.0" resolution: "stop-iteration-iterator@npm:1.1.0"
@ -4399,17 +4462,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tar@npm:^7.4.3": "tar@npm:^7.5.2":
version: 7.4.3 version: 7.5.2
resolution: "tar@npm:7.4.3" resolution: "tar@npm:7.5.2"
dependencies: dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0" "@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0" chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2" minipass: "npm:^7.1.2"
minizlib: "npm:^3.0.1" minizlib: "npm:^3.1.0"
mkdirp: "npm:^3.0.1"
yallist: "npm:^5.0.0" yallist: "npm:^5.0.0"
checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d checksum: 10c0/a7d8b801139b52f93a7e34830db0de54c5aa45487c7cb551f6f3d44a112c67f1cb8ffdae856b05fd4f17b1749911f1c26f1e3a23bbe0279e17fd96077f13f467
languageName: node languageName: node
linkType: hard linkType: hard
@ -4434,6 +4496,7 @@ __metadata:
globals: "npm:^16.3.0" globals: "npm:^16.3.0"
react: "npm:^19.1.1" react: "npm:^19.1.1"
react-dom: "npm:^19.1.1" react-dom: "npm:^19.1.1"
react-dropzone: "npm:^14.3.8"
react-intersection-observer: "npm:^9.16.0" react-intersection-observer: "npm:^9.16.0"
react-router-dom: "npm:^7.8.2" react-router-dom: "npm:^7.8.2"
typescript: "npm:~5.8.3" typescript: "npm:~5.8.3"
@ -4470,6 +4533,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tslib@npm:^2.7.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
languageName: node
linkType: hard
"type-check@npm:^0.4.0, type-check@npm:~0.4.0": "type-check@npm:^0.4.0, type-check@npm:~0.4.0":
version: 0.4.0 version: 0.4.0
resolution: "type-check@npm:0.4.0" resolution: "type-check@npm:0.4.0"
@ -4747,14 +4817,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"which@npm:^5.0.0": "which@npm:^6.0.0":
version: 5.0.0 version: 6.0.0
resolution: "which@npm:5.0.0" resolution: "which@npm:6.0.0"
dependencies: dependencies:
isexe: "npm:^3.1.1" isexe: "npm:^3.1.1"
bin: bin:
node-which: bin/which.js node-which: bin/which.js
checksum: 10c0/e556e4cd8b7dbf5df52408c9a9dd5ac6518c8c5267c8953f5b0564073c66ed5bf9503b14d876d0e9c7844d4db9725fb0dcf45d6e911e17e26ab363dc3965ae7b checksum: 10c0/fe9d6463fe44a76232bb6e3b3181922c87510a5b250a98f1e43a69c99c079b3f42ddeca7e03d3e5f2241bf2d334f5a7657cfa868b97c109f3870625842f4cc15
languageName: node languageName: node
linkType: hard linkType: hard