mirror of
https://github.com/urosran/cally.git
synced 2025-07-09 22:57:16 +00:00
Month cal changes
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">"Cally "</string>
|
||||
<string name="app_name">\"Cally \"</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, {memo} from "react";
|
||||
import React, {memo, useState} from "react";
|
||||
import {Button, Picker, PickerModes, SegmentedControl, Text, View} from "react-native-ui-lib";
|
||||
import {MaterialIcons} from "@expo/vector-icons";
|
||||
import {months} from "./constants";
|
||||
@ -12,6 +12,7 @@ import {Mode} from "react-native-big-calendar";
|
||||
export const CalendarHeader = memo(() => {
|
||||
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
|
||||
const [mode, setMode] = useAtom(modeAtom);
|
||||
const [tempIndex, setTempIndex] = useState<number | null>(null);
|
||||
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
|
||||
|
||||
const segments = isTablet
|
||||
@ -26,9 +27,12 @@ export const CalendarHeader = memo(() => {
|
||||
selectedMode = ["day", "3days", "month"][index] as Mode;
|
||||
}
|
||||
|
||||
setTempIndex(index);
|
||||
|
||||
if (selectedMode) {
|
||||
setTimeout(() => {
|
||||
setMode(selectedMode as "day" | "week" | "month" | "3days");
|
||||
setTempIndex(null);
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
@ -131,7 +135,7 @@ export const CalendarHeader = memo(() => {
|
||||
outlineWidth={3}
|
||||
segmentLabelStyle={styles.segmentslblStyle}
|
||||
onChangeIndex={handleSegmentChange}
|
||||
initialIndex={getInitialIndex()}
|
||||
initialIndex={tempIndex ?? getInitialIndex()}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
@ -1,92 +1,94 @@
|
||||
import { Text, TouchableOpacity, View } from "react-native-ui-lib";
|
||||
import React from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { isFamilyViewAtom } from "@/components/pages/calendar/atoms";
|
||||
import {Text, TouchableOpacity, View} from "react-native-ui-lib";
|
||||
import React, {useState, useCallback} from "react";
|
||||
import {StyleSheet} from "react-native";
|
||||
import {useAtom} from "jotai";
|
||||
import {isFamilyViewAtom} from "@/components/pages/calendar/atoms";
|
||||
|
||||
const CalendarViewSwitch = () => {
|
||||
const [isFamilyView, setIsFamilyView] = useAtom(isFamilyViewAtom);
|
||||
const [isFamilyView, setIsFamilyView] = useAtom(isFamilyViewAtom);
|
||||
const [localState, setLocalState] = useState(isFamilyView);
|
||||
|
||||
return (
|
||||
<View
|
||||
row
|
||||
spread
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
borderRadius: 30,
|
||||
backgroundColor: "white",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
// iOS shadow
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
// Android shadow (elevation)
|
||||
elevation: 6,
|
||||
}}
|
||||
centerV
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setIsFamilyView(true);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
centerV
|
||||
centerH
|
||||
height={40}
|
||||
paddingH-15
|
||||
style={isFamilyView ? styles.switchBtnActive : styles.switchBtn}
|
||||
>
|
||||
<Text
|
||||
color={isFamilyView ? "white" : "#a1a1a1"}
|
||||
style={styles.switchTxt}
|
||||
>
|
||||
Family View
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
const handleViewChange = useCallback((newValue: boolean) => {
|
||||
setLocalState(newValue);
|
||||
setTimeout(() => {
|
||||
setIsFamilyView(newValue);
|
||||
}, 150);
|
||||
}, [setIsFamilyView]);
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setIsFamilyView(false);
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<View
|
||||
centerV
|
||||
centerH
|
||||
height={40}
|
||||
paddingH-15
|
||||
style={!isFamilyView ? styles.switchBtnActive : styles.switchBtn}
|
||||
row
|
||||
spread
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
borderRadius: 30,
|
||||
backgroundColor: "white",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 6,
|
||||
}}
|
||||
centerV
|
||||
>
|
||||
<Text
|
||||
color={!isFamilyView ? "white" : "#a1a1a1"}
|
||||
style={styles.switchTxt}
|
||||
>
|
||||
My View
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleViewChange(true)}
|
||||
>
|
||||
<View
|
||||
centerV
|
||||
centerH
|
||||
height={40}
|
||||
paddingH-15
|
||||
style={localState ? styles.switchBtnActive : styles.switchBtn}
|
||||
>
|
||||
<Text
|
||||
color={localState ? "white" : "#a1a1a1"}
|
||||
style={styles.switchTxt}
|
||||
>
|
||||
Family View
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleViewChange(false)}
|
||||
>
|
||||
<View
|
||||
centerV
|
||||
centerH
|
||||
height={40}
|
||||
paddingH-15
|
||||
style={!localState ? styles.switchBtnActive : styles.switchBtn}
|
||||
>
|
||||
<Text
|
||||
color={!localState ? "white" : "#a1a1a1"}
|
||||
style={styles.switchTxt}
|
||||
>
|
||||
My View
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarViewSwitch;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
switchBtnActive: {
|
||||
backgroundColor: "#a1a1a1",
|
||||
borderRadius: 50,
|
||||
},
|
||||
switchBtn: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 50,
|
||||
},
|
||||
switchTxt: {
|
||||
fontSize: 16,
|
||||
fontFamily: "Manrope_600SemiBold",
|
||||
},
|
||||
});
|
||||
switchBtnActive: {
|
||||
backgroundColor: "#a1a1a1",
|
||||
borderRadius: 50,
|
||||
},
|
||||
switchBtn: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 50,
|
||||
},
|
||||
switchTxt: {
|
||||
fontSize: 16,
|
||||
fontFamily: "Manrope_600SemiBold",
|
||||
},
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from "react";
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
||||
import {useAuthContext} from "@/contexts/AuthContext";
|
||||
import {useAtomValue} from "jotai";
|
||||
import {modeAtom, selectedDateAtom, selectedUserAtom} from "@/components/pages/calendar/atoms";
|
||||
@ -9,8 +9,8 @@ import {useFormattedEvents} from "@/components/pages/calendar/useFormattedEvents
|
||||
import {useCalendarControls} from "@/components/pages/calendar/useCalendarControls";
|
||||
import {EventCell} from "@/components/pages/calendar/EventCell";
|
||||
import {isToday} from "date-fns";
|
||||
import { View } from "react-native-ui-lib";
|
||||
import { DeviceType } from "expo-device";
|
||||
import {View} from "react-native-ui-lib";
|
||||
import {DeviceType} from "expo-device";
|
||||
import * as Device from "expo-device"
|
||||
import {useAtomCallback} from 'jotai/utils'
|
||||
|
||||
@ -21,7 +21,35 @@ interface EventCalendarProps {
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const MemoizedEventCell = React.memo(EventCell);
|
||||
const HEADER_PROPS = {
|
||||
dayBarHeight: 60,
|
||||
headerBottomHeight: 20,
|
||||
};
|
||||
|
||||
const BODY_PROPS = {
|
||||
showNowIndicator: true,
|
||||
hourFormat: "h:mm a"
|
||||
};
|
||||
|
||||
const MODE_TO_DAYS = {
|
||||
'week': 7,
|
||||
'3days': 3,
|
||||
'day': 1,
|
||||
'month': 1
|
||||
};
|
||||
|
||||
const getContainerProps = (selectedDate: Date) => ({
|
||||
hourWidth: 70,
|
||||
allowPinchToZoom: true,
|
||||
useHaptic: true,
|
||||
scrollToNow: true,
|
||||
initialDate: selectedDate.toISOString(),
|
||||
});
|
||||
|
||||
const MemoizedEventCell = React.memo(EventCell, (prev, next) => {
|
||||
return prev.event.id === next.event.id &&
|
||||
prev.event.lastModified === next.event.lastModified;
|
||||
});
|
||||
|
||||
export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
|
||||
{
|
||||
@ -36,11 +64,16 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
|
||||
const calendarRef = useRef<CalendarKitHandle>(null);
|
||||
const {data: events} = useGetEvents();
|
||||
const selectedUser = useAtomValue(selectedUserAtom);
|
||||
const [customKey, setCustomKey] = useState("defaultKey");
|
||||
|
||||
const memoizedFamilyMembers = useMemo(() => familyMembers || [], [familyMembers]);
|
||||
const containerProps = useMemo(() => getContainerProps(selectedDate), [selectedDate]);
|
||||
|
||||
const checkModeAndGoToDate = useAtomCallback(useCallback((get) => {
|
||||
const currentMode = get(modeAtom);
|
||||
if ((selectedDate && isToday(selectedDate)) || currentMode === "month") {
|
||||
calendarRef?.current?.goToDate({date: selectedDate});
|
||||
setCustomKey(selectedDate.toISOString())
|
||||
}
|
||||
}, [selectedDate]));
|
||||
|
||||
@ -55,35 +88,9 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
|
||||
debouncedOnDateChanged
|
||||
} = useCalendarControls(events ?? []);
|
||||
|
||||
const numberOfDays = useMemo(() => {
|
||||
return mode === 'week' ? 7 : mode === '3days' ? 3 : 1;
|
||||
}, [mode]);
|
||||
|
||||
const firstDay = useMemo(() => {
|
||||
return profileData?.firstDayOfWeek === "Mondays" ? 1 : 0;
|
||||
}, [profileData?.firstDayOfWeek]);
|
||||
|
||||
const headerProps = useMemo(() => ({
|
||||
dayBarHeight: 60,
|
||||
headerBottomHeight: 20,
|
||||
}), []);
|
||||
|
||||
const bodyProps = useMemo(() => ({
|
||||
showNowIndicator: true,
|
||||
hourFormat: "h:mm a"
|
||||
}), []);
|
||||
|
||||
const containerProps = useMemo(() => ({
|
||||
hourWidth: 70,
|
||||
allowPinchToZoom: true,
|
||||
useHaptic: true,
|
||||
scrollToNow: true,
|
||||
initialDate: selectedDate.toISOString(),
|
||||
}), [selectedDate]);
|
||||
|
||||
const getAttendees = useCallback((event: any) => {
|
||||
return familyMembers?.filter(member => event?.attendees?.includes(member?.uid!)) || [];
|
||||
}, [familyMembers]);
|
||||
return memoizedFamilyMembers.filter(member => event?.attendees?.includes(member?.uid!));
|
||||
}, [memoizedFamilyMembers]);
|
||||
|
||||
const renderEvent = useCallback((event: any) => {
|
||||
const attendees = getAttendees(event);
|
||||
@ -94,27 +101,30 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
|
||||
attendees={attendees}
|
||||
/>
|
||||
);
|
||||
}, [familyMembers, handlePressEvent, getAttendees]);
|
||||
}, [getAttendees, handlePressEvent]);
|
||||
|
||||
return (
|
||||
<CalendarContainer
|
||||
ref={calendarRef}
|
||||
{...containerProps}
|
||||
numberOfDays={numberOfDays}
|
||||
numberOfDays={MODE_TO_DAYS[mode]}
|
||||
calendarWidth={calendarWidth}
|
||||
onDateChanged={debouncedOnDateChanged}
|
||||
firstDay={firstDay}
|
||||
firstDay={profileData?.firstDayOfWeek === "Mondays" ? 1 : 0}
|
||||
events={formattedEvents ?? []}
|
||||
onPressEvent={handlePressEvent}
|
||||
onPressBackground={handlePressCell}
|
||||
onLoad={onLoad}
|
||||
key={customKey}
|
||||
>
|
||||
<CalendarHeader {...headerProps} />
|
||||
<CalendarHeader {...HEADER_PROPS} />
|
||||
<CalendarBody
|
||||
{...bodyProps}
|
||||
{...BODY_PROPS}
|
||||
renderEvent={renderEvent}
|
||||
/>
|
||||
{Device.deviceType === DeviceType.TABLET && <View style={{backgroundColor: 'white', height: '9%', width: '100%'}}/>}
|
||||
{Device.deviceType === DeviceType.TABLET && (
|
||||
<View style={{backgroundColor: 'white', height: '9%', width: '100%'}}/>
|
||||
)}
|
||||
</CalendarContainer>
|
||||
);
|
||||
});
|
||||
|
@ -64,7 +64,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) =>
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{(isLoading || isSyncing) && (
|
||||
{(isLoading || isSyncing) && mode !== 'month' && (
|
||||
<Animated.View
|
||||
exiting={FadeOut.duration(300)}
|
||||
style={styles.loadingContainer}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, {useCallback, useMemo, useRef} from 'react';
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
@ -16,16 +15,14 @@ import {
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isWithinInterval,
|
||||
startOfMonth,
|
||||
subDays,
|
||||
addMonths,
|
||||
subMonths
|
||||
addMonths, startOfWeek, isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
|
||||
import {useAtom, useSetAtom} from "jotai";
|
||||
import {modeAtom, selectedDateAtom, selectedNewEventDateAtom} from "@/components/pages/calendar/atoms";
|
||||
import {useAuthContext} from "@/contexts/AuthContext";
|
||||
import {FlashList} from "@shopify/flash-list";
|
||||
import * as Device from "expo-device";
|
||||
|
||||
interface CalendarEvent {
|
||||
@ -41,176 +38,104 @@ interface CustomMonthCalendarProps {
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MAX_VISIBLE_EVENTS = 3;
|
||||
const CALENDAR_BUFFER_DAYS = 40;
|
||||
const MAX_VISIBLE_EVENTS = 3 ;
|
||||
const CENTER_MONTH_INDEX = 24;
|
||||
|
||||
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
|
||||
const {data: rawEvents, isLoading} = useGetEvents();
|
||||
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
|
||||
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
|
||||
const [mode, setMode] = useAtom(modeAtom);
|
||||
const {profileData} = useAuthContext();
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
|
||||
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
|
||||
const dayWidth = (screenWidth - 32) / 7;
|
||||
const isScrolling = useRef(false);
|
||||
const currentScrollX = useRef(screenWidth);
|
||||
const DayHeader = React.memo(({day, width}: { day: string; width: string }) => (
|
||||
<View style={[styles.weekDay, {width}]}>
|
||||
<Text style={styles.weekDayText}>{day}</Text>
|
||||
</View>
|
||||
));
|
||||
|
||||
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
|
||||
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={styles.eventText} numberOfLines={1}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
));
|
||||
|
||||
const events = useMemo(() => {
|
||||
if (!rawEvents?.length) return [];
|
||||
if (!selectedDate) return [];
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
color?: string;
|
||||
weekPosition?: number;
|
||||
}
|
||||
|
||||
const rangeStart = subDays(selectedDate, CALENDAR_BUFFER_DAYS);
|
||||
const rangeEnd = addDays(selectedDate, CALENDAR_BUFFER_DAYS);
|
||||
const MultiDayEvent = React.memo(({
|
||||
event,
|
||||
isStart,
|
||||
isEnd,
|
||||
onPress,
|
||||
}: {
|
||||
event: CalendarEvent;
|
||||
dayWidth: number;
|
||||
isStart: boolean;
|
||||
isEnd: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const style = {
|
||||
position: 'absolute' as const,
|
||||
height: 14,
|
||||
backgroundColor: event.color || '#6200ee',
|
||||
borderRadius: isStart || isEnd ? 4 : 0,
|
||||
borderTopLeftRadius: isStart ? 4 : 0,
|
||||
borderBottomLeftRadius: isStart ? 4 : 0,
|
||||
borderTopRightRadius: isEnd ? 4 : 0,
|
||||
borderBottomRightRadius: isEnd ? 4 : 0,
|
||||
padding: 1,
|
||||
zIndex: 1,
|
||||
left: isStart ? 2 : 0,
|
||||
right: isEnd ? 2 : 0,
|
||||
top: event.weekPosition ? event.weekPosition * 20 : 0,
|
||||
marginRight: !isEnd ? -1 : 0,
|
||||
};
|
||||
|
||||
return rawEvents.filter((event) => {
|
||||
if (!event?.start || !event?.end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
|
||||
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return startDate <= rangeEnd && endDate >= rangeStart;
|
||||
});
|
||||
}, [rawEvents, selectedDate]);
|
||||
|
||||
const onDayPress = useCallback(
|
||||
(date: Date) => {
|
||||
date && setSelectedDate(date);
|
||||
setTimeout(() => {
|
||||
setMode("day");
|
||||
}, 100)
|
||||
},
|
||||
[mode, setSelectedNewEndDate, setSelectedDate]
|
||||
);
|
||||
|
||||
const getMonthData = useCallback((date: Date) => {
|
||||
const start = startOfMonth(date);
|
||||
const end = endOfMonth(date);
|
||||
const days = eachDayOfInterval({start, end});
|
||||
|
||||
// Add padding days at the start
|
||||
const firstDay = days[0];
|
||||
const startPadding = [];
|
||||
let startDay = firstDay.getDay();
|
||||
while (startDay !== weekStartsOn) {
|
||||
startDay = (startDay - 1 + 7) % 7;
|
||||
startPadding.unshift(addDays(firstDay, -startPadding.length - 1));
|
||||
}
|
||||
|
||||
// Add padding days at the end
|
||||
const lastDay = days[days.length - 1];
|
||||
const endPadding = [];
|
||||
let endDay = lastDay.getDay();
|
||||
while (endDay !== (weekStartsOn + 6) % 7) {
|
||||
endDay = (endDay + 1) % 7;
|
||||
endPadding.push(addDays(lastDay, endPadding.length + 1));
|
||||
}
|
||||
|
||||
return [...startPadding, ...days, ...endPadding];
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const monthsToRender = useMemo(() => {
|
||||
const prevMonth = subMonths(selectedDate, 1);
|
||||
const nextMonth = addMonths(selectedDate, 1);
|
||||
|
||||
return [
|
||||
{
|
||||
date: prevMonth,
|
||||
days: getMonthData(prevMonth)
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
days: getMonthData(selectedDate)
|
||||
},
|
||||
{
|
||||
date: nextMonth,
|
||||
days: getMonthData(nextMonth)
|
||||
}
|
||||
];
|
||||
}, [selectedDate, getMonthData]);
|
||||
|
||||
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isScrolling.current) {
|
||||
currentScrollX.current = event.nativeEvent.contentOffset.x;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMomentumScrollEnd = useCallback(() => {
|
||||
if (isScrolling.current) return;
|
||||
|
||||
const pageWidth = screenWidth;
|
||||
const currentX = currentScrollX.current;
|
||||
|
||||
if (currentX < pageWidth / 2) {
|
||||
isScrolling.current = true;
|
||||
setSelectedDate(prev => {
|
||||
const newDate = subMonths(prev, 1);
|
||||
// Immediately scroll back to center
|
||||
scrollViewRef.current?.scrollTo({
|
||||
x: pageWidth,
|
||||
animated: false,
|
||||
});
|
||||
isScrolling.current = false;
|
||||
currentScrollX.current = pageWidth;
|
||||
return newDate;
|
||||
});
|
||||
} else if (currentX > pageWidth * 1.5) {
|
||||
isScrolling.current = true;
|
||||
setSelectedDate(prev => {
|
||||
const newDate = addMonths(prev, 1);
|
||||
// Immediately scroll back to center
|
||||
scrollViewRef.current?.scrollTo({
|
||||
x: pageWidth,
|
||||
animated: false,
|
||||
});
|
||||
isScrolling.current = false;
|
||||
currentScrollX.current = pageWidth;
|
||||
return newDate;
|
||||
});
|
||||
}
|
||||
}, [screenWidth, setSelectedDate]);
|
||||
|
||||
const getEventsForDay = useCallback((date: Date) => {
|
||||
return events?.filter(event =>
|
||||
isSameDay(new Date(event.start), date)
|
||||
) ?? [];
|
||||
}, [events]);
|
||||
|
||||
const renderEvent = useCallback((event: CalendarEvent) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={event.id}
|
||||
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
|
||||
onPress={() => onDayPress(event.start)}
|
||||
>
|
||||
<Text style={styles.eventText} numberOfLines={1}>
|
||||
return (
|
||||
<TouchableOpacity style={style} onPress={onPress}>
|
||||
{isStart && (
|
||||
<Text style={[styles.eventText]} numberOfLines={1}>
|
||||
{event.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [onDayPress]);
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const renderDay = useCallback((date: Date, index: number) => {
|
||||
const dayEvents = getEventsForDay(date);
|
||||
const isCurrentMonth = isSameMonth(date, selectedDate);
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const hasMoreEvents = dayEvents.length > MAX_VISIBLE_EVENTS;
|
||||
const Day = React.memo(({
|
||||
date,
|
||||
selectedDate,
|
||||
events,
|
||||
multiDayEvents,
|
||||
dayWidth,
|
||||
onPress
|
||||
}: {
|
||||
date: Date;
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
multiDayEvents: CalendarEvent[];
|
||||
dayWidth: number;
|
||||
onPress: (date: Date) => void;
|
||||
}) => {
|
||||
const isCurrentMonth = isSameMonth(date, selectedDate);
|
||||
const isToday = isSameDay(date, new Date());
|
||||
|
||||
return (
|
||||
const remainingSlots = Math.max(0, MAX_VISIBLE_EVENTS - multiDayEvents.length);
|
||||
const singleDayEvents = events.filter(event => !event.isMultiDay);
|
||||
const visibleSingleDayEvents = singleDayEvents.slice(0, remainingSlots);
|
||||
const totalHiddenEvents = singleDayEvents.length - remainingSlots;
|
||||
|
||||
return (
|
||||
<View style={[styles.day, { width: dayWidth }]}>
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.day, {width: dayWidth}]}
|
||||
onPress={() => onDayPress(date)}
|
||||
style={styles.dayContent}
|
||||
onPress={() => onPress(date)}
|
||||
>
|
||||
<View style={[
|
||||
styles.dateContainer,
|
||||
@ -224,62 +149,389 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
|
||||
{format(date, 'd')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.eventsContainer}>
|
||||
{dayEvents.slice(0, MAX_VISIBLE_EVENTS).map(renderEvent)}
|
||||
{hasMoreEvents && (
|
||||
{/* Multi-day events first */}
|
||||
{multiDayEvents.map(event => (
|
||||
<MultiDayEvent
|
||||
key={event.id}
|
||||
event={event}
|
||||
dayWidth={dayWidth}
|
||||
isStart={isSameDay(date, event.start)}
|
||||
isEnd={isSameDay(date, event.end)}
|
||||
onPress={() => onPress(event.start)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Single-day events */}
|
||||
{visibleSingleDayEvents.map(event => (
|
||||
<Event
|
||||
key={event.id}
|
||||
event={event}
|
||||
onPress={() => onPress(event.start)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show total number of hidden events */}
|
||||
{totalHiddenEvents > 0 && (
|
||||
<Text style={styles.moreEvents}>
|
||||
{dayEvents.length - MAX_VISIBLE_EVENTS} More
|
||||
{totalHiddenEvents} More
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [dayWidth, selectedDate, getEventsForDay, onDayPress, renderEvent]);
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const findFirstAvailablePosition = (usedPositions: number[]): number => {
|
||||
let position = 0;
|
||||
while (usedPositions.includes(position)) {
|
||||
position++;
|
||||
}
|
||||
return position;
|
||||
};
|
||||
|
||||
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
|
||||
const {data: rawEvents} = useGetEvents();
|
||||
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
|
||||
const setMode = useSetAtom(modeAtom);
|
||||
const {profileData} = useAuthContext();
|
||||
|
||||
const scrollViewRef = useRef<FlashList<any>>(null);
|
||||
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
|
||||
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
|
||||
const screenHeight = isTablet ? Dimensions.get('window').height * 0.89 : Dimensions.get('window').height;
|
||||
const dayWidth = (screenWidth - 32) / 7;
|
||||
const centerMonth = useRef(selectedDate);
|
||||
|
||||
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
|
||||
|
||||
const events = useMemo(() => {
|
||||
if (!rawEvents?.length) return new Map();
|
||||
if (!selectedDate) return new Map();
|
||||
|
||||
const eventMap = new Map();
|
||||
|
||||
rawEvents.forEach((event) => {
|
||||
if (!event?.start || !event?.end) return;
|
||||
|
||||
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
|
||||
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
|
||||
|
||||
const dateStr = format(startDate, 'yyyy-MM-dd');
|
||||
const existing = eventMap.get(dateStr) || [];
|
||||
eventMap.set(dateStr, [...existing, event]);
|
||||
});
|
||||
|
||||
return eventMap;
|
||||
}, [rawEvents, selectedDate]);
|
||||
|
||||
const onDayPress = useCallback(
|
||||
(date: Date) => {
|
||||
date && setSelectedDate(date);
|
||||
setTimeout(() => {
|
||||
setMode("day");
|
||||
}, 100)
|
||||
},
|
||||
[setSelectedDate, setMode]
|
||||
);
|
||||
|
||||
const getMonthData = useCallback((date: Date) => {
|
||||
const start = startOfMonth(date);
|
||||
const end = endOfMonth(date);
|
||||
const days = eachDayOfInterval({start, end});
|
||||
|
||||
const firstDay = days[0];
|
||||
const startPadding = [];
|
||||
let startDay = firstDay.getDay();
|
||||
while (startDay !== weekStartsOn) {
|
||||
startDay = (startDay - 1 + 7) % 7;
|
||||
startPadding.unshift(addDays(firstDay, -startPadding.length - 1));
|
||||
}
|
||||
|
||||
const lastDay = days[days.length - 1];
|
||||
const endPadding = [];
|
||||
let endDay = lastDay.getDay();
|
||||
while (endDay !== (weekStartsOn + 6) % 7) {
|
||||
endDay = (endDay + 1) % 7;
|
||||
endPadding.push(addDays(lastDay, endPadding.length + 1));
|
||||
}
|
||||
|
||||
return [...startPadding, ...days, ...endPadding];
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const monthsToRender = useMemo(() => {
|
||||
const months = [];
|
||||
for (let i = -CENTER_MONTH_INDEX; i <= CENTER_MONTH_INDEX; i++) {
|
||||
const monthDate = addMonths(centerMonth.current, i);
|
||||
months.push({
|
||||
date: monthDate,
|
||||
days: getMonthData(monthDate)
|
||||
});
|
||||
}
|
||||
return months;
|
||||
}, [getMonthData]);
|
||||
|
||||
const processedEvents = useMemo(() => {
|
||||
if (!rawEvents?.length || !selectedDate) return {
|
||||
eventMap: new Map(),
|
||||
multiDayEvents: []
|
||||
};
|
||||
|
||||
const eventMap = new Map();
|
||||
const multiDayEvents: CalendarEvent[] = [];
|
||||
|
||||
rawEvents.forEach((event) => {
|
||||
if (!event?.start || !event?.end) return;
|
||||
|
||||
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
|
||||
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return;
|
||||
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (duration > 1) {
|
||||
multiDayEvents.push({
|
||||
...event,
|
||||
isMultiDay: true,
|
||||
start: startDate,
|
||||
end: endDate
|
||||
});
|
||||
} else {
|
||||
const dateStr = format(startDate, 'yyyy-MM-dd');
|
||||
const existing = eventMap.get(dateStr) || [];
|
||||
eventMap.set(dateStr, [...existing, { ...event, start: startDate, end: endDate }]);
|
||||
}
|
||||
});
|
||||
|
||||
multiDayEvents.sort((a, b) => {
|
||||
if (!a.start || !b.start || !a.end || !b.end) return 0;
|
||||
const durationA = a.end.getTime() - a.start.getTime();
|
||||
const durationB = b.end.getTime() - b.start.getTime();
|
||||
return durationB - durationA;
|
||||
});
|
||||
|
||||
return { eventMap, multiDayEvents };
|
||||
}, [rawEvents, selectedDate]);
|
||||
|
||||
const getMultiDayEventsForDay = useCallback((date: Date) => {
|
||||
return processedEvents.multiDayEvents.filter(event => {
|
||||
if (!event.start || !event.end) return false;
|
||||
return isWithinInterval(date, {
|
||||
start: event.start,
|
||||
end: event.end
|
||||
});
|
||||
});
|
||||
}, [processedEvents.multiDayEvents]);
|
||||
|
||||
const getEventsForDay = useCallback((date: Date) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
return processedEvents.eventMap.get(dateStr) || [];
|
||||
}, [processedEvents.eventMap]);
|
||||
|
||||
const sortedDaysOfWeek = useMemo(() => {
|
||||
const days = [...DAYS_OF_WEEK];
|
||||
const sortedDays = days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
|
||||
return sortedDays;
|
||||
return days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const renderMonth = useCallback(({item}: { item: MonthData }) => (
|
||||
<Month
|
||||
date={item.date}
|
||||
days={item.days}
|
||||
selectedDate={selectedDate}
|
||||
getEventsForDay={getEventsForDay}
|
||||
getMultiDayEventsForDay={getMultiDayEventsForDay}
|
||||
dayWidth={dayWidth}
|
||||
onPress={onDayPress}
|
||||
screenWidth={screenWidth}
|
||||
/>
|
||||
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
|
||||
|
||||
const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const currentMonthIndex = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
|
||||
const currentMonth = monthsToRender[currentMonthIndex];
|
||||
|
||||
if (currentMonth) {
|
||||
setSelectedDate(currentMonth.date);
|
||||
centerMonth.current = currentMonth.date;
|
||||
}
|
||||
}, [screenWidth, setSelectedDate, monthsToRender]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
{sortedDaysOfWeek.map((day, index) => (
|
||||
<View key={index} style={[styles.weekDay, {width: `${100 / 7}%`}]}>
|
||||
<Text style={styles.weekDayText}>{day}</Text>
|
||||
</View>
|
||||
<DayHeader key={index} day={day} width={`${100 / 7}%`}/>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
<FlashList
|
||||
ref={scrollViewRef}
|
||||
data={monthsToRender}
|
||||
renderItem={renderMonth}
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
initialScrollIndex={CENTER_MONTH_INDEX}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
contentOffset={{x: screenWidth, y: 0}}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
{monthsToRender.map(({date, days}, monthIndex) => (
|
||||
<View
|
||||
key={monthIndex}
|
||||
style={[styles.scrollView, {width: screenWidth}]}
|
||||
>
|
||||
<View style={styles.daysGrid}>
|
||||
{days.map((day, index) => renderDay(day, index))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
removeClippedSubviews={true}
|
||||
estimatedItemSize={screenWidth}
|
||||
estimatedListSize={{width: screenWidth, height: screenHeight * 0.9}}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 10,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type MonthData = {
|
||||
date: Date;
|
||||
days: Date[];
|
||||
};
|
||||
|
||||
const keyExtractor = (item: MonthData, index: number) => `month-${index}`;
|
||||
|
||||
const Month = React.memo(({
|
||||
date,
|
||||
days,
|
||||
selectedDate,
|
||||
getEventsForDay,
|
||||
getMultiDayEventsForDay,
|
||||
dayWidth,
|
||||
onPress,
|
||||
screenWidth
|
||||
}: {
|
||||
date: Date;
|
||||
days: Date[];
|
||||
selectedDate: Date;
|
||||
getEventsForDay: (date: Date) => CalendarEvent[];
|
||||
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
|
||||
dayWidth: number;
|
||||
onPress: (date: Date) => void;
|
||||
screenWidth: number;
|
||||
}) => {
|
||||
const weeks = useMemo(() => {
|
||||
const result = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
result.push(days.slice(i, i + 7));
|
||||
}
|
||||
return result;
|
||||
}, [days]);
|
||||
|
||||
const eventPositions = useMemo(() => {
|
||||
const positions = new Map<string, number>();
|
||||
const weekTracking = new Map<number, Set<string>>();
|
||||
|
||||
weeks.forEach((week, weekIndex) => {
|
||||
const activeEvents = new Set<string>();
|
||||
|
||||
week.forEach(day => {
|
||||
const events = getMultiDayEventsForDay(day);
|
||||
events.forEach(event => {
|
||||
activeEvents.add(event.id);
|
||||
});
|
||||
});
|
||||
|
||||
weekTracking.set(weekIndex, activeEvents);
|
||||
|
||||
activeEvents.forEach(eventId => {
|
||||
if (!positions.has(eventId)) {
|
||||
const prevWeekEvents = weekIndex > 0 ? weekTracking.get(weekIndex - 1) : new Set<string>();
|
||||
const usedPositions = new Set<number>();
|
||||
|
||||
if (prevWeekEvents) {
|
||||
prevWeekEvents.forEach(prevEventId => {
|
||||
if (activeEvents.has(prevEventId)) {
|
||||
usedPositions.add(positions.get(prevEventId) || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
while (usedPositions.has(position)) {
|
||||
position++;
|
||||
}
|
||||
positions.set(eventId, position);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}, [weeks, getMultiDayEventsForDay]);
|
||||
|
||||
return (
|
||||
<View style={[styles.scrollView, {width: screenWidth}]}>
|
||||
<View style={styles.daysGrid}>
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<React.Fragment key={weekIndex}>
|
||||
{week.map((date, dayIndex) => {
|
||||
const multiDayEvents = getMultiDayEventsForDay(date).map(event => ({
|
||||
...event,
|
||||
weekPosition: eventPositions.get(event.id) || 0
|
||||
}));
|
||||
|
||||
return (
|
||||
<Day
|
||||
key={`${weekIndex}-${dayIndex}`}
|
||||
date={date}
|
||||
selectedDate={selectedDate}
|
||||
events={getEventsForDay(date)}
|
||||
multiDayEvents={multiDayEvents}
|
||||
dayWidth={dayWidth}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const HEADER_HEIGHT = 40;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
multiDayContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
dayContent: {
|
||||
flex: 1,
|
||||
},
|
||||
eventsContainer: {
|
||||
flex: 1,
|
||||
marginTop: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
event: {
|
||||
borderRadius: 4,
|
||||
padding: 1,
|
||||
marginVertical: 1,
|
||||
height: 14,
|
||||
},
|
||||
eventText: {
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
fontWeight: '500',
|
||||
},
|
||||
day: {
|
||||
height: '14%',
|
||||
padding: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#eee',
|
||||
position: 'relative',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
@ -302,12 +554,6 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
day: {
|
||||
height: '15%', // 100% / 6 weeks
|
||||
padding: 4,
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#eee',
|
||||
},
|
||||
weekDay: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@ -342,19 +588,6 @@ const styles = StyleSheet.create({
|
||||
outsideMonthText: {
|
||||
color: '#ccc',
|
||||
},
|
||||
eventsContainer: {
|
||||
flex: 1,
|
||||
marginTop: 2,
|
||||
},
|
||||
event: {
|
||||
borderRadius: 4,
|
||||
padding: 2,
|
||||
marginVertical: 1,
|
||||
},
|
||||
eventText: {
|
||||
fontSize: 10,
|
||||
color: '#fff',
|
||||
},
|
||||
moreEvents: {
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
|
@ -52,6 +52,7 @@
|
||||
"@react-native/assets-registry": "^0.76.3",
|
||||
"@react-navigation/drawer": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@shopify/flash-list": "^1.7.2",
|
||||
"@tanstack/query-async-storage-persister": "^5.62.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-persist-client": "^5.62.7",
|
||||
|
15
yarn.lock
15
yarn.lock
@ -3226,6 +3226,14 @@
|
||||
component-type "^1.2.1"
|
||||
join-component "^1.1.0"
|
||||
|
||||
"@shopify/flash-list@^1.7.2":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.7.2.tgz#a862515a6aae912486d4515909761320d6a6e964"
|
||||
integrity sha512-rnadpDht/mlJLDM02HBni49EJJQhc51zjJ3diGnTz3MV6U8vK9Hztou+2C5d6bNLb4oZvSG5f7NTWejkipyMLw==
|
||||
dependencies:
|
||||
recyclerlistview "4.2.1"
|
||||
tslib "2.6.3"
|
||||
|
||||
"@sideway/address@^4.1.5":
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
|
||||
@ -9974,7 +9982,7 @@ recast@^0.21.0:
|
||||
source-map "~0.6.1"
|
||||
tslib "^2.0.1"
|
||||
|
||||
recyclerlistview@^4.0.0:
|
||||
recyclerlistview@4.2.1, recyclerlistview@^4.0.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/recyclerlistview/-/recyclerlistview-4.2.1.tgz#4537a0959400cdce1df1f38d26aab823786e9b13"
|
||||
integrity sha512-NtVYjofwgUCt1rEsTp6jHQg/47TWjnO92TU2kTVgJ9wsc/ely4HnizHHa+f/dI7qaw4+zcSogElrLjhMltN2/g==
|
||||
@ -11143,6 +11151,11 @@ ts-object-utils@0.0.5:
|
||||
resolved "https://registry.yarnpkg.com/ts-object-utils/-/ts-object-utils-0.0.5.tgz#95361cdecd7e52167cfc5e634c76345e90a26077"
|
||||
integrity sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==
|
||||
|
||||
tslib@2.6.3:
|
||||
version "2.6.3"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0"
|
||||
integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==
|
||||
|
||||
tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
|
Reference in New Issue
Block a user