Week header on mobile
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useSwipe } from '@/hooks/useSwipe';
|
||||
import { format, addDays, startOfWeek, differenceInDays, parseISO } from 'date-fns';
|
||||
import { format, addDays, startOfWeek, differenceInDays, parseISO, isSameDay } from 'date-fns';
|
||||
import { useWindowSize } from '@/hooks/useWindowSize';
|
||||
import EventModal from '@/components/EventModal';
|
||||
import eventsData from '@/data/events';
|
||||
@@ -35,13 +35,17 @@ const Calendar = () => {
|
||||
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [events, setEvents] = useState<EventsByDate>(eventsData);
|
||||
const [dragPreview, setDragPreview] = useState<{
|
||||
event: Event;
|
||||
targetDate: string;
|
||||
} | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const screenWidth = useRef(isClient ? window.innerWidth : 0);
|
||||
|
||||
const handleDragEnd = useCallback((event: any) => {
|
||||
const { source, location } = event;
|
||||
if (!location?.current?.dropTargets[0]) return;
|
||||
@@ -56,7 +60,6 @@ const Calendar = () => {
|
||||
const sourceDate = source.data.date;
|
||||
const targetDate = dropTarget.date;
|
||||
|
||||
// Update the event's date when moving between weeks
|
||||
const updatedEvent = { ...movedEvent, date: targetDate };
|
||||
|
||||
newEvents[sourceDate] = (newEvents[sourceDate] || []).filter((e: Event) => e.id !== movedEvent.id);
|
||||
@@ -64,42 +67,15 @@ const Calendar = () => {
|
||||
|
||||
setEvents(newEvents);
|
||||
}
|
||||
setDragPreview(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = monitorForElements({
|
||||
onDrop: handleDragEnd,
|
||||
onDrag: ({ source, location }) => {
|
||||
//console.log('Drag source data:', source?.data);
|
||||
//console.log('Current drop targets:', location?.current?.dropTargets);
|
||||
|
||||
if (!source?.data) {
|
||||
console.warn('No drag data found. Source:', source);
|
||||
setDragPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location?.current?.dropTargets[0]?.data;
|
||||
if (dropTarget?.date) {
|
||||
const movedEvent = Object.values(events)
|
||||
.flat()
|
||||
.find((e: Event) => e.id === source.data.id);
|
||||
|
||||
if (movedEvent) {
|
||||
setDragPreview({
|
||||
event: movedEvent,
|
||||
targetDate: dropTarget.date
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDragPreview(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => cleanup();
|
||||
}, [events, handleDragEnd]);
|
||||
}, [handleDragEnd]);
|
||||
|
||||
const eventsRef = useRef(eventsData);
|
||||
eventsRef.current = events;
|
||||
@@ -118,7 +94,7 @@ const Calendar = () => {
|
||||
}, [width]);
|
||||
|
||||
const getWeekDays = (date: Date) => {
|
||||
const start = startOfWeek(date);
|
||||
const start = startOfWeek(date, { weekStartsOn: 0 });
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
};
|
||||
|
||||
@@ -147,13 +123,6 @@ const Calendar = () => {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handlePreviousWeek, handleNextWeek, selectedEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = monitorForElements({
|
||||
onDrop: handleDragEnd,
|
||||
});
|
||||
return () => cleanup();
|
||||
}, [handleDragEnd]);
|
||||
|
||||
const handleSwipe = (dir: 'left' | 'right') => {
|
||||
if (isMobile) {
|
||||
setDirection(dir);
|
||||
@@ -187,11 +156,10 @@ const Calendar = () => {
|
||||
setIsSwiping(false);
|
||||
onTouchEnd(e);
|
||||
|
||||
const containerWidth = containerRef.current?.offsetWidth || window.innerWidth;
|
||||
const containerWidth = isClient ? (containerRef.current?.offsetWidth || window.innerWidth) : 0;
|
||||
const targetIndex = Math.round(-offset / containerWidth);
|
||||
const newDate = addDays(currentDate, targetIndex);
|
||||
|
||||
// Animate to the new center position
|
||||
setCurrentDate(newDate);
|
||||
setOffset(0);
|
||||
setTempDate(null);
|
||||
@@ -230,14 +198,10 @@ const Calendar = () => {
|
||||
const event = newEvents[oldDateKey].find((e: Event) => e.id === eventId);
|
||||
if (event) {
|
||||
const newDateKey = format(newDate, 'yyyy-MM-dd');
|
||||
|
||||
// Remove from old date
|
||||
newEvents[oldDateKey] = newEvents[oldDateKey].filter((e: Event) => e.id !== eventId);
|
||||
|
||||
// Add to new date
|
||||
newEvents[newDateKey] = [
|
||||
...(newEvents[newDateKey] || []),
|
||||
{ ...event, date: newDateKey }
|
||||
event
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -245,12 +209,31 @@ const Calendar = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const mobileViewStyle = {
|
||||
x: offset - (isClient ? (containerRef.current?.offsetWidth || window.innerWidth) : 0),
|
||||
width: '300%',
|
||||
transition: isSwiping ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)'
|
||||
};
|
||||
|
||||
const motionDivStyle = {
|
||||
opacity: 1 - Math.abs(offset)/(isClient ? (containerRef.current?.offsetWidth || 1) * 0.5 : 1),
|
||||
scale: 1 - Math.abs(offset)/(isClient ? (containerRef.current?.offsetWidth || 1) * 2 : 1),
|
||||
};
|
||||
|
||||
const handleEventClick = useCallback(({ event, date }: { event: Event; date: string }) => {
|
||||
setSelectedEvent({
|
||||
...event,
|
||||
date
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header
|
||||
currentDate={currentDate}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{isMobile && <WeekHeader currentDate={currentDate} />}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -263,32 +246,25 @@ const Calendar = () => {
|
||||
{isMobile ? (
|
||||
<motion.div
|
||||
className="flex h-full"
|
||||
style={{
|
||||
x: offset - (containerRef.current?.offsetWidth || 0),
|
||||
width: '300%',
|
||||
transition: isSwiping ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1)'
|
||||
}}
|
||||
style={mobileViewStyle}
|
||||
>
|
||||
{[addDays(currentDate, -1), currentDate, addDays(currentDate, 1)].map((date, index) => (
|
||||
<motion.div
|
||||
key={date.toISOString()}
|
||||
className="w-[100vw] flex-shrink-0 h-full px-2"
|
||||
style={{
|
||||
opacity: 1 - Math.abs(offset)/(containerRef.current?.offsetWidth || 1 * 0.5),
|
||||
scale: 1 - Math.abs(offset)/(containerRef.current?.offsetWidth || 1 * 2),
|
||||
}}
|
||||
style={motionDivStyle}
|
||||
>
|
||||
<DayColumn
|
||||
date={date}
|
||||
events={events[format(date, 'yyyy-MM-dd')] || []}
|
||||
isMobile={isMobile}
|
||||
index={index}
|
||||
onEventClick={setSelectedEvent}
|
||||
dragPreview={dragPreview?.targetDate === format(date, 'yyyy-MM-dd') ? dragPreview.event : null}
|
||||
onEventClick={handleEventClick}
|
||||
onDragStart={() => setIsEventDragging(true)}
|
||||
onDragEnd={() => setIsEventDragging(false)}
|
||||
handleEventMove={handleEventMove}
|
||||
onDayChange={handleDayChange}
|
||||
isClient={isClient}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@@ -301,12 +277,12 @@ const Calendar = () => {
|
||||
events={events[format(date, 'yyyy-MM-dd')] || []}
|
||||
isMobile={isMobile}
|
||||
index={index}
|
||||
onEventClick={setSelectedEvent}
|
||||
dragPreview={dragPreview?.targetDate === format(date, 'yyyy-MM-dd') ? dragPreview.event : null}
|
||||
onEventClick={handleEventClick}
|
||||
onDragStart={() => setIsEventDragging(true)}
|
||||
onDragEnd={() => setIsEventDragging(false)}
|
||||
handleEventMove={handleEventMove}
|
||||
onDayChange={handleDayChange}
|
||||
isClient={isClient}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -330,39 +306,27 @@ interface DayColumnProps {
|
||||
events: Event[];
|
||||
isMobile: boolean;
|
||||
index: number;
|
||||
onEventClick: (event: Event) => void;
|
||||
dragPreview: Event | null;
|
||||
onEventClick: (eventData: { event: Event; date: string }) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
handleEventMove: (eventId: string, newDate: Date) => void;
|
||||
onDayChange: (direction: 'left' | 'right') => void;
|
||||
isClient: boolean;
|
||||
}
|
||||
|
||||
const PreviewComponent = ({ event }: { event: Event }) => (
|
||||
<motion.div
|
||||
className="bg-blue-100 p-4 rounded shadow mb-2 mx-1 border-2 border-blue-300 pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 0.4, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<h3 className="font-medium">{event.title}</h3>
|
||||
<p className="text-sm text-gray-500">{event.time}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const DayColumn = ({
|
||||
date,
|
||||
events,
|
||||
dragPreview,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onEventClick,
|
||||
handleEventMove,
|
||||
onDayChange
|
||||
onDayChange,
|
||||
isClient
|
||||
}: DayColumnProps) => {
|
||||
const columnRef = useRef<HTMLDivElement>(null);
|
||||
const { width } = useWindowSize();
|
||||
const [isMobile, setIsMobile] = useState(false); // Default to false for SSR
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const parseTimeToMinutes = (time: string) => {
|
||||
const [timePart, modifier] = time.split(' ');
|
||||
@@ -385,7 +349,6 @@ const DayColumn = ({
|
||||
[events]
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobile(width < 768); // Update isMobile after hydration
|
||||
}, [width]);
|
||||
@@ -405,17 +368,6 @@ const DayColumn = ({
|
||||
return cleanup;
|
||||
}, [date]);
|
||||
|
||||
const previewPosition = useMemo(() => {
|
||||
if (!dragPreview) return -1;
|
||||
|
||||
const previewMinutes = parseTimeToMinutes(dragPreview.time);
|
||||
|
||||
return sortedEvents.findIndex(event => {
|
||||
const eventMinutes = parseTimeToMinutes(event.time);
|
||||
return eventMinutes >= previewMinutes;
|
||||
});
|
||||
}, [sortedEvents, dragPreview]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={columnRef}
|
||||
@@ -427,23 +379,24 @@ const DayColumn = ({
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{sortedEvents.map((event, index) => (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
date={format(date, 'yyyy-MM-dd')}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDayChange={(dir) => {
|
||||
// Update calendar view first
|
||||
onDayChange(dir);
|
||||
// Then move the event to the new date
|
||||
const newDate = addDays(date, dir === 'left' ? -1 : 1);
|
||||
handleEventMove(event.id, newDate);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{sortedEvents
|
||||
.filter(event => event && event.id) // Filter out invalid events
|
||||
.map((event, index) => (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
date={format(date, 'yyyy-MM-dd')}
|
||||
onEventClick={onEventClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDayChange={(dir) => {
|
||||
onDayChange(dir);
|
||||
const newDate = addDays(date, dir === 'left' ? -1 : 1);
|
||||
handleEventMove(event.id, newDate);
|
||||
}}
|
||||
isClient={isClient}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -455,18 +408,26 @@ const DraggableEvent = ({
|
||||
onEventClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDayChange
|
||||
onDayChange,
|
||||
isClient
|
||||
}: {
|
||||
event: Event;
|
||||
date: string;
|
||||
onEventClick: (event: Event) => void;
|
||||
onEventClick: (eventData: { event: Event; date: string }) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDayChange: (direction: 'left' | 'right') => void;
|
||||
isClient: boolean;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const screenWidth = useRef(window.innerWidth);
|
||||
const screenWidth = useRef(isClient ? window.innerWidth : 0);
|
||||
const lastChangeTime = useRef(0);
|
||||
|
||||
if (!event || typeof event !== 'object' || !event.id) {
|
||||
console.error('Invalid event object:', event);
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
@@ -478,15 +439,20 @@ const DraggableEvent = ({
|
||||
screenWidth.current = window.innerWidth;
|
||||
setIsDragging(true);
|
||||
onDragStart();
|
||||
lastChangeTime.current = Date.now();
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
const currentX = location.current.input.clientX;
|
||||
const now = Date.now();
|
||||
|
||||
// Simple edge detection without delta checks
|
||||
if (currentX < 50) {
|
||||
onDayChange('left');
|
||||
} else if (currentX > screenWidth.current - 50) {
|
||||
onDayChange('right');
|
||||
if (now - lastChangeTime.current > 500) {
|
||||
if (currentX < 50) {
|
||||
lastChangeTime.current = now;
|
||||
onDayChange('left');
|
||||
} else if (currentX > screenWidth.current - 50) {
|
||||
lastChangeTime.current = now;
|
||||
onDayChange('right');
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
@@ -504,7 +470,7 @@ const DraggableEvent = ({
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layoutId={event.id}
|
||||
onClick={() => !isDragging && onEventClick(event)}
|
||||
onClick={() => !isDragging && onEventClick({ event, date })}
|
||||
className="bg-white p-4 rounded shadow mb-2 cursor-grab active:cursor-grabbing transition-all relative select-none"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
style={{
|
||||
@@ -527,15 +493,54 @@ const Header = ({
|
||||
currentDate: Date;
|
||||
isMobile: boolean;
|
||||
}) => {
|
||||
const weekRange = useMemo(() => {
|
||||
if (!isMobile) return '';
|
||||
const start = startOfWeek(currentDate, { weekStartsOn: 0 });
|
||||
const end = addDays(start, 6);
|
||||
return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`;
|
||||
}, [currentDate, isMobile]);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-black">
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
{isMobile ? weekRange : format(currentDate, 'MMMM yyyy')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WeekHeader = ({ currentDate }: { currentDate: Date }) => {
|
||||
const weekDays = useMemo(() => {
|
||||
const start = startOfWeek(currentDate, { weekStartsOn: 0 }); // Start week on Sunday
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
}, [currentDate]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between px-2 py-3 border-b">
|
||||
{weekDays.map((day, index) => (
|
||||
<motion.div
|
||||
key={day.toISOString()}
|
||||
className="flex flex-col items-center flex-1"
|
||||
initial={false}
|
||||
animate={{ backgroundColor: isSameDay(day, currentDate) ? '#3B82F6' : 'transparent' }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className={`text-sm font-medium ${
|
||||
isSameDay(day, currentDate) ? 'text-white' : 'text-gray-600'
|
||||
}`}>
|
||||
{format(day, 'EEE')}
|
||||
</div>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isSameDay(day, currentDate) ? 'bg-blue-500 text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
|
||||
@@ -1,31 +1,74 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Event } from '@/types';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { format, parseISO, isValid } from 'date-fns';
|
||||
|
||||
interface EventWithDate {
|
||||
id: string;
|
||||
title: string;
|
||||
time: string;
|
||||
date: string; // Now required
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const EventModal = ({ event, onClose }: { event: EventWithDate; onClose: () => void }) => {
|
||||
// Now we can safely use event.date
|
||||
const eventDate = parseISO(event.date);
|
||||
const formattedDate = isValid(eventDate) ? format(eventDate, 'MMM d, yyyy') : 'Invalid date';
|
||||
|
||||
const EventModal = ({ event, onClose }: { event: Event; onClose: () => void }) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
layoutId={event.id}
|
||||
className="bg-white rounded-lg p-6 max-w-md w-full"
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-48 object-cover rounded-lg mb-4"
|
||||
/>
|
||||
<motion.h2 className="text-2xl font-bold">{event.title}</motion.h2>
|
||||
<motion.p className="text-gray-600 mt-2">{event.description}</motion.p>
|
||||
<motion.p className="text-sm text-gray-500 mt-4">
|
||||
{event.time}
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="bg-white rounded-lg p-6 max-w-md w-full relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial={{ scale: 0.95 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.95 }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{event.imageUrl && (
|
||||
<img
|
||||
src={event.imageUrl}
|
||||
alt={event.title}
|
||||
className="w-full h-48 object-cover rounded-lg mb-4"
|
||||
/>
|
||||
)}
|
||||
<motion.h2 className="text-2xl font-bold mb-4">{event.title}</motion.h2>
|
||||
<motion.p className="text-gray-600 mb-2">Date: {formattedDate}</motion.p>
|
||||
<motion.p className="text-gray-600">Time: {event.time}</motion.p>
|
||||
{event.description && (
|
||||
<motion.p className="text-gray-600">
|
||||
Description: {event.description}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventsByDate } from '@/types';
|
||||
|
||||
const events: EventsByDate = {
|
||||
"2025-03-20": [
|
||||
"2025-03-21": [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Coffee with Alex",
|
||||
@@ -21,7 +21,7 @@ const events: EventsByDate = {
|
||||
time: "02:00 PM",
|
||||
},
|
||||
],
|
||||
"2025-03-21": [
|
||||
"2025-03-22": [
|
||||
{
|
||||
id: "event-3",
|
||||
title: "Yoga Session",
|
||||
@@ -41,7 +41,7 @@ const events: EventsByDate = {
|
||||
time: "03:30 PM",
|
||||
},
|
||||
],
|
||||
"2025-03-22": [
|
||||
"2025-03-23": [
|
||||
{
|
||||
id: "event-5",
|
||||
title: "Client Meeting",
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface Event {
|
||||
title: string;
|
||||
time: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
// add other properties as needed
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user