Merge branch 'dev' into main
This commit is contained in:
commit
9e32440f15
@ -63,6 +63,8 @@ model User {
|
|||||||
eventFontFamily String? @default("Inter")
|
eventFontFamily String? @default("Inter")
|
||||||
eventFontSize String? @default("0.85rem")
|
eventFontSize String? @default("0.85rem")
|
||||||
eventFontWeight String? @default("400")
|
eventFontWeight String? @default("400")
|
||||||
|
lightTheme Json?
|
||||||
|
darkTheme Json?
|
||||||
fontWeight String @default("400")
|
fontWeight String @default("400")
|
||||||
weekdayColor String? @default("#0ea5e9")
|
weekdayColor String? @default("#0ea5e9")
|
||||||
dateColor String? @default("#888888")
|
dateColor String? @default("#888888")
|
||||||
|
|||||||
@ -92,6 +92,8 @@ export async function GET(request: NextRequest) {
|
|||||||
quoteLanguages: true,
|
quoteLanguages: true,
|
||||||
kanbanStages: true,
|
kanbanStages: true,
|
||||||
accountNumber: true,
|
accountNumber: true,
|
||||||
|
lightTheme: true,
|
||||||
|
darkTheme: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -134,7 +136,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
showTaskCheckboxes, dayHeaderGap,
|
showTaskCheckboxes, dayHeaderGap,
|
||||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||||
kanbanStages
|
kanbanStages, lightTheme, darkTheme
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@ -215,6 +217,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
|
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
|
||||||
...(quoteLanguages !== undefined && { quoteLanguages }),
|
...(quoteLanguages !== undefined && { quoteLanguages }),
|
||||||
...(kanbanStages !== undefined && { kanbanStages }),
|
...(kanbanStages !== undefined && { kanbanStages }),
|
||||||
|
...(lightTheme !== undefined && { lightTheme }),
|
||||||
|
...(darkTheme !== undefined && { darkTheme }),
|
||||||
};
|
};
|
||||||
if (password && password.trim() !== "") {
|
if (password && password.trim() !== "") {
|
||||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||||
@ -304,6 +308,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
quoteLanguages: true,
|
quoteLanguages: true,
|
||||||
kanbanStages: true,
|
kanbanStages: true,
|
||||||
accountNumber: true,
|
accountNumber: true,
|
||||||
|
lightTheme: true,
|
||||||
|
darkTheme: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -498,8 +498,8 @@ h3 {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekly-logo {
|
.weekly-logo {
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
@ -2317,14 +2317,16 @@ h3 {
|
|||||||
.time-grid-wrapper {
|
.time-grid-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Side Navigation Arrows (hover overlays) */
|
/* Side Navigation Arrows (hover overlays) */
|
||||||
.side-nav {
|
.side-nav {
|
||||||
position: absolute;
|
position: sticky;
|
||||||
top: 0;
|
top: 50px; /* Below header height if needed, or 0 */
|
||||||
|
height: 0; /* Let it be a point of origin */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2332,6 +2334,11 @@ h3 {
|
|||||||
padding: 0.35rem 0.15rem;
|
padding: 0.35rem 0.15rem;
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
|
pointer-events: none; /* Let clicks pass through to grid except buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-btn {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav .side-nav-btn {
|
.side-nav .side-nav-btn {
|
||||||
@ -2387,7 +2394,7 @@ h3 {
|
|||||||
|
|
||||||
.time-column-slots {
|
.time-column-slots {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: visible; /* Unify with parent scroll */
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-top: 0.4rem;
|
padding-top: 0.4rem;
|
||||||
}
|
}
|
||||||
@ -2415,10 +2422,12 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-slot-label span {
|
.time-slot-label span {
|
||||||
transform: translateY(-50%); /* Changed from 50% to -50% for centering on the line */
|
transform: translateY(-50%); /* Centers the label on the grid line */
|
||||||
background: var(--weekly-bg);
|
background: var(--weekly-bg);
|
||||||
padding: 0 2px;
|
padding: 0 4px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot-label .sub-hour-label {
|
.time-slot-label .sub-hour-label {
|
||||||
@ -2433,8 +2442,9 @@ h3 {
|
|||||||
/* Time Slots Container */
|
/* Time Slots Container */
|
||||||
.time-slots-container {
|
.time-slots-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow: visible; /* Changed from auto to unify scroll */
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
|
padding-top: 0.4rem; /* Aligned with .time-column-slots */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot {
|
.time-slot {
|
||||||
@ -2586,7 +2596,8 @@ h3 {
|
|||||||
/* Make day columns scrollable when using time grid */
|
/* Make day columns scrollable when using time grid */
|
||||||
.time-grid-wrapper .weekly-days-grid {
|
.time-grid-wrapper .weekly-days-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow-y: auto; /* Unified scroll container */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-grid-wrapper .weekly-day-column {
|
.time-grid-wrapper .weekly-day-column {
|
||||||
@ -2601,8 +2612,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-grid-wrapper .time-slots-container {
|
.time-grid-wrapper .time-slots-container {
|
||||||
overflow-y: visible;
|
overflow: visible;
|
||||||
overflow-x: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All-Day Events Section */
|
/* All-Day Events Section */
|
||||||
@ -3956,3 +3966,78 @@ h3 {
|
|||||||
left: auto !important;
|
left: auto !important;
|
||||||
right: auto !important;
|
right: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
UNIFIED SCROLLING FOR TIME GRID VIEWS
|
||||||
|
============================================ */
|
||||||
|
/* Active time grid container becomes the sole vertical scroll wrapper */
|
||||||
|
.time-grid-on .time-grid-wrapper {
|
||||||
|
overflow-y: scroll !important; /* Force scrollbar space to always exist */
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force the all-day section to align with the scrollbar-constrained grid below it */
|
||||||
|
.time-grid-on .all-day-events-section {
|
||||||
|
overflow-y: scroll !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the actual scrollbar thumb in the all-day section so it is just transparent reserved space */
|
||||||
|
.time-grid-on .all-day-events-section::-webkit-scrollbar {
|
||||||
|
width: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.time-grid-on .all-day-events-section::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable independent scrolling on all inner grid containers */
|
||||||
|
.time-grid-on .weekly-days-grid,
|
||||||
|
.time-grid-on .weekly-day-column,
|
||||||
|
.time-grid-on .time-slots-container,
|
||||||
|
.time-grid-on .time-column-slots,
|
||||||
|
.time-grid-on .time-column {
|
||||||
|
overflow-y: visible !important;
|
||||||
|
overflow-x: visible !important;
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure no rogue scrollbars appear and hide webskit ones */
|
||||||
|
.time-grid-on .weekly-days-grid::-webkit-scrollbar,
|
||||||
|
.time-grid-on .weekly-day-column::-webkit-scrollbar,
|
||||||
|
.time-grid-on .time-slots-container::-webkit-scrollbar,
|
||||||
|
.time-grid-on .time-column-slots::-webkit-scrollbar {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
.time-grid-on .weekly-days-grid,
|
||||||
|
.time-grid-on .weekly-day-column,
|
||||||
|
.time-grid-on .time-slots-container,
|
||||||
|
.time-grid-on .time-column-slots {
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make Day and Time Headers Sticky to the top of the viewport */
|
||||||
|
.time-grid-on .weekly-day-header,
|
||||||
|
.time-grid-on .time-column-header {
|
||||||
|
position: sticky !important;
|
||||||
|
top: 0 !important;
|
||||||
|
z-index: 40 !important;
|
||||||
|
background-color: var(--weekly-bg, white);
|
||||||
|
border-bottom: 1px solid var(--weekly-border, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .time-grid-on .weekly-day-header,
|
||||||
|
.dark-mode .time-grid-on .time-column-header {
|
||||||
|
background-color: var(--weekly-bg, #111) !important;
|
||||||
|
border-bottom-color: var(--weekly-border, #333) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provide a solid background for the top-left empty corner above the time column */
|
||||||
|
.time-grid-on .time-column-header {
|
||||||
|
z-index: 45 !important;
|
||||||
|
}
|
||||||
|
|||||||
@ -1337,13 +1337,35 @@ function formatDateToISO(date: Date): string {
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHour(hour: number, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
|
/**
|
||||||
|
* Parses a date string from a calendar event.
|
||||||
|
* If strictly a date (YYYY-MM-DD), it's parsed as local mid-night.
|
||||||
|
* If an ISO string with time, it's parsed regularly.
|
||||||
|
*/
|
||||||
|
function parseCalendarDate(dateStr: string): Date {
|
||||||
|
if (!dateStr) return new Date();
|
||||||
|
// If it's date-only (YYYY-MM-DD), parse as local midnight
|
||||||
|
if (!dateStr.includes("T")) {
|
||||||
|
const parts = dateStr.split("-").map(Number);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If it's an ISO string but we want local midnight (e.g. from cache or older backend)
|
||||||
|
// we still parse it. The fix in the backend should reduce this.
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
|
||||||
if (timeFormat === "12h") {
|
if (timeFormat === "12h") {
|
||||||
const h = hour % 12 || 12;
|
const h = hour % 12 || 12;
|
||||||
const ampm = hour >= 12 ? "PM" : "AM";
|
const ampm = hour >= 12 ? "PM" : "AM";
|
||||||
return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`;
|
const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : "";
|
||||||
|
return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`;
|
||||||
}
|
}
|
||||||
return format === "full" ? `${hour}:00` : `${hour}`;
|
// 24h format: always show minutes if non-zero, or if format is full.
|
||||||
|
// Standardize to always include :00 for the hour start to avoid ambiguity.
|
||||||
|
return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimeSlots(
|
function getTimeSlots(
|
||||||
@ -1400,8 +1422,8 @@ const isAllDayEvent = (event: CalendarEvent): boolean => {
|
|||||||
// Date-only format (YYYY-MM-DD)
|
// Date-only format (YYYY-MM-DD)
|
||||||
if (!event.startTime.includes("T")) return true;
|
if (!event.startTime.includes("T")) return true;
|
||||||
|
|
||||||
const start = new Date(event.startTime);
|
const start = parseCalendarDate(event.startTime);
|
||||||
const end = new Date(event.endTime);
|
const end = parseCalendarDate(event.endTime);
|
||||||
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
// Check if strictly midnight to midnight in local time
|
// Check if strictly midnight to midnight in local time
|
||||||
@ -1772,6 +1794,8 @@ export default function WeeklyView() {
|
|||||||
customWeekdayNames?: string;
|
customWeekdayNames?: string;
|
||||||
dateVerticalAlign?: "top" | "middle" | "bottom";
|
dateVerticalAlign?: "top" | "middle" | "bottom";
|
||||||
headerDisplay?: "kw" | "month";
|
headerDisplay?: "kw" | "month";
|
||||||
|
lightTheme?: any;
|
||||||
|
darkTheme?: any;
|
||||||
}>({
|
}>({
|
||||||
name: session?.user?.name || "",
|
name: session?.user?.name || "",
|
||||||
email: session?.user?.email || "",
|
email: session?.user?.email || "",
|
||||||
@ -2051,68 +2075,95 @@ export default function WeeklyView() {
|
|||||||
const timeColumnRef = useRef<HTMLDivElement>(null);
|
const timeColumnRef = useRef<HTMLDivElement>(null);
|
||||||
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
||||||
const isScrollSyncing = useRef(false);
|
const isScrollSyncing = useRef(false);
|
||||||
|
const isInitialScrollDone = useRef(false);
|
||||||
|
const intendedScrollTop = useRef<number | null>(null);
|
||||||
const dayHeaderRef = useRef<HTMLElement>(null);
|
const dayHeaderRef = useRef<HTMLElement>(null);
|
||||||
const somedayGridRef = useRef<HTMLDivElement>(null);
|
const somedayGridRef = useRef<HTMLDivElement>(null);
|
||||||
const somedaySectionRef = useRef<HTMLElement | null>(null);
|
const somedaySectionRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
// Scroll sync handler
|
// Unified scroll sync handlers
|
||||||
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const scrollTop = e.currentTarget.scrollTop;
|
||||||
|
|
||||||
|
// Detection for unexpected midnight reset during initial stabilization
|
||||||
|
if (intendedScrollTop.current !== null && scrollTop === 0 && !isInitialScrollDone.current) {
|
||||||
|
console.log(`[SCROLL] Detected unexpected reset to 0. Correcting to ${intendedScrollTop.current}px...`);
|
||||||
|
e.currentTarget.scrollTop = intendedScrollTop.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isScrollSyncing.current) return;
|
if (isScrollSyncing.current) return;
|
||||||
isScrollSyncing.current = true;
|
isScrollSyncing.current = true;
|
||||||
const scrollTop = e.currentTarget.scrollTop;
|
|
||||||
dayColumnsRef.current.forEach((col) => {
|
if (gridRef.current) {
|
||||||
if (col) col.scrollTop = scrollTop;
|
gridRef.current.scrollTop = scrollTop;
|
||||||
});
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isScrollSyncing.current = false;
|
isScrollSyncing.current = false;
|
||||||
}, 10);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDayColumnScroll = (
|
const handleGridScroll = (e: React.UIEvent<HTMLElement>) => {
|
||||||
e: React.UIEvent<HTMLDivElement>,
|
const scrollTop = e.currentTarget.scrollTop;
|
||||||
index: number,
|
|
||||||
) => {
|
// Detection for unexpected midnight reset during initial stabilization
|
||||||
|
if (intendedScrollTop.current !== null && scrollTop === 0 && !isInitialScrollDone.current) {
|
||||||
|
console.log(`[SCROLL] Detected unexpected reset to 0. Correcting to ${intendedScrollTop.current}px...`);
|
||||||
|
e.currentTarget.scrollTop = intendedScrollTop.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isScrollSyncing.current) return;
|
if (isScrollSyncing.current) return;
|
||||||
isScrollSyncing.current = true;
|
isScrollSyncing.current = true;
|
||||||
const scrollTop = e.currentTarget.scrollTop;
|
|
||||||
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop;
|
if (timeColumnRef.current) {
|
||||||
dayColumnsRef.current.forEach((col, i) => {
|
timeColumnRef.current.scrollTop = scrollTop;
|
||||||
if (col && i !== index) col.scrollTop = scrollTop;
|
}
|
||||||
});
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isScrollSyncing.current = false;
|
isScrollSyncing.current = false;
|
||||||
}, 10);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slot height based on cell duration
|
const jumpToHour = (hour: number) => {
|
||||||
|
const slotsPerHour = 60 / cellDuration;
|
||||||
|
const slotHeight = getSlotHeight(cellDuration);
|
||||||
|
// Offset relative to workingHoursStart because the grid now starts there
|
||||||
|
const relativeHour = Math.max(0, hour - workingHoursStart);
|
||||||
|
const scrollOffset = relativeHour * slotsPerHour * slotHeight;
|
||||||
|
|
||||||
|
console.log(`[SCROLL] Jumping to hour ${hour} (relative ${relativeHour}, offset ${scrollOffset}px)`);
|
||||||
|
|
||||||
|
isScrollSyncing.current = true;
|
||||||
|
intendedScrollTop.current = scrollOffset;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (gridRef.current) gridRef.current.scrollTo({ top: scrollOffset, behavior: 'instant' });
|
||||||
|
if (timeColumnRef.current) timeColumnRef.current.scrollTo({ top: scrollOffset, behavior: 'instant' });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isScrollSyncing.current = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slot and Header height based on cell duration
|
||||||
const getSlotHeight = (duration: number) => {
|
const getSlotHeight = (duration: number) => {
|
||||||
switch (duration) {
|
switch (duration) {
|
||||||
case 15:
|
case 15: return 25;
|
||||||
return 25;
|
case 30: return 35;
|
||||||
case 30:
|
case 60: return 50;
|
||||||
return 35;
|
case 120: return 80;
|
||||||
case 60:
|
default: return 50;
|
||||||
return 50;
|
|
||||||
case 120:
|
|
||||||
return 80;
|
|
||||||
default:
|
|
||||||
return 50;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header height based on cell duration for alignment
|
const getHeaderHeight = (duration: number) => {
|
||||||
const getHeaderHeight = (duration: CellDuration) => {
|
|
||||||
switch (duration) {
|
switch (duration) {
|
||||||
case 15:
|
case 15: return 65;
|
||||||
return 65;
|
case 30: return 55;
|
||||||
case 30:
|
case 60: return 50;
|
||||||
return 55;
|
case 120: return 50;
|
||||||
case 60:
|
default: return 50;
|
||||||
return 50;
|
|
||||||
case 120:
|
|
||||||
return 50;
|
|
||||||
default:
|
|
||||||
return 50;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2455,6 +2506,38 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
}, [currentWeekStart, session, fetchCalendarEvents]);
|
}, [currentWeekStart, session, fetchCalendarEvents]);
|
||||||
|
|
||||||
|
// Initial scroll to top (grid now starts at workingHoursStart)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
const performScroll = () => {
|
||||||
|
// Since the grid restricted to [workingHoursStart, workingHoursEnd],
|
||||||
|
// the top is already the start of the working hours.
|
||||||
|
const scrollOffset = 0;
|
||||||
|
|
||||||
|
isScrollSyncing.current = true;
|
||||||
|
intendedScrollTop.current = scrollOffset;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const jump = (el: HTMLElement | null) => {
|
||||||
|
if (el) el.scrollTo({ top: scrollOffset, behavior: 'auto' });
|
||||||
|
};
|
||||||
|
|
||||||
|
jump(gridRef.current);
|
||||||
|
jump(timeColumnRef.current);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isScrollSyncing.current = false;
|
||||||
|
isInitialScrollDone.current = true;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay slightly to ensure layout is stable
|
||||||
|
const timer = setTimeout(performScroll, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isLoading, workingHoursStart, cellDuration]);
|
||||||
|
|
||||||
// Update current time every 30 seconds for the "Now" line and clock
|
// Update current time every 30 seconds for the "Now" line and clock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@ -3064,33 +3147,25 @@ export default function WeeklyView() {
|
|||||||
return calendarEvents.filter((event) => {
|
return calendarEvents.filter((event) => {
|
||||||
if (!isAllDayEvent(event)) return false;
|
if (!isAllDayEvent(event)) return false;
|
||||||
|
|
||||||
// Parse date from startTime
|
// Parse date safely using our local-time helper
|
||||||
const eventStart = new Date(event.startTime);
|
const start = parseCalendarDate(event.startTime);
|
||||||
const eventEnd = event.endTime
|
const end = event.endTime
|
||||||
? new Date(event.endTime)
|
? parseCalendarDate(event.endTime)
|
||||||
: new Date(eventStart);
|
: new Date(start);
|
||||||
|
|
||||||
// Normalize dates to start of day for comparison
|
// Normalize dates to start of day for comparison
|
||||||
const targetDate = new Date(date);
|
const targetDate = new Date(date);
|
||||||
targetDate.setHours(0, 0, 0, 0);
|
targetDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const start = new Date(eventStart);
|
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const end = new Date(eventEnd);
|
|
||||||
end.setHours(0, 0, 0, 0);
|
end.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// If strictly dates, often end date is exclusive or same day?
|
// Handle single day case where start == end
|
||||||
// Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day).
|
if (start.getTime() === end.getTime()) {
|
||||||
// If start == end, it's 1 day (but usually GCal sends next day).
|
|
||||||
// Let's assume inclusive start, exclusive end logic or "overlaps" logic.
|
|
||||||
// Check if targetDate is >= start AND targetDate < end
|
|
||||||
|
|
||||||
// Handle single day case where start == end or end is not provided
|
|
||||||
if (!event.endTime || start.getTime() === end.getTime()) {
|
|
||||||
return start.getTime() === targetDate.getTime();
|
return start.getTime() === targetDate.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard range comparison (inclusive start, exclusive end)
|
||||||
return (
|
return (
|
||||||
targetDate.getTime() >= start.getTime() &&
|
targetDate.getTime() >= start.getTime() &&
|
||||||
targetDate.getTime() < end.getTime()
|
targetDate.getTime() < end.getTime()
|
||||||
@ -4388,8 +4463,8 @@ export default function WeeklyView() {
|
|||||||
// Slot is taken — find next free slot
|
// Slot is taken — find next free slot
|
||||||
const allSlots = getTimeSlots(
|
const allSlots = getTimeSlots(
|
||||||
cellDuration,
|
cellDuration,
|
||||||
workingHoursStart,
|
0,
|
||||||
workingHoursEnd,
|
24,
|
||||||
);
|
);
|
||||||
const startIndex = allSlots.indexOf(resolvedStartTime);
|
const startIndex = allSlots.indexOf(resolvedStartTime);
|
||||||
if (startIndex !== -1) {
|
if (startIndex !== -1) {
|
||||||
@ -4503,8 +4578,8 @@ export default function WeeklyView() {
|
|||||||
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
|
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
|
||||||
const allSlots = getTimeSlots(
|
const allSlots = getTimeSlots(
|
||||||
cellDuration,
|
cellDuration,
|
||||||
workingHoursStart,
|
0,
|
||||||
workingHoursEnd,
|
24,
|
||||||
);
|
);
|
||||||
const startIndex = allSlots.indexOf(targetSlot);
|
const startIndex = allSlots.indexOf(targetSlot);
|
||||||
if (startIndex !== -1) {
|
if (startIndex !== -1) {
|
||||||
@ -4821,8 +4896,18 @@ export default function WeeklyView() {
|
|||||||
const num = parseFloat(base);
|
const num = parseFloat(base);
|
||||||
return `${(num * fontSizeScale).toFixed(3)}rem`;
|
return `${(num * fontSizeScale).toFixed(3)}rem`;
|
||||||
};
|
};
|
||||||
|
const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record<string, string> | null;
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
|
...(activeTheme ? {
|
||||||
|
"--weekly-bg": activeTheme.background,
|
||||||
|
"--weekly-text": activeTheme.foreground,
|
||||||
|
"--weekly-text-light": activeTheme.color8 || activeTheme.color7,
|
||||||
|
"--weekly-border": activeTheme.color0,
|
||||||
|
"--weekly-teal": activeTheme.color4 || activeTheme.color6,
|
||||||
|
"--weekly-settings-item-bg": activeTheme.color0,
|
||||||
|
"--weekly-item-hover": activeTheme.color0,
|
||||||
|
} : {}),
|
||||||
"--weekly-font-headline":
|
"--weekly-font-headline":
|
||||||
fontVal(profile.headlineFont) || headlineFont
|
fontVal(profile.headlineFont) || headlineFont
|
||||||
? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif`
|
? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif`
|
||||||
@ -4870,27 +4955,27 @@ export default function WeeklyView() {
|
|||||||
"--weekly-event-weight":
|
"--weekly-event-weight":
|
||||||
profile.eventFontWeight || eventFontWeight || "400",
|
profile.eventFontWeight || eventFontWeight || "400",
|
||||||
"--font-weight-body": profile.fontWeight || fontWeight || "400",
|
"--font-weight-body": profile.fontWeight || fontWeight || "400",
|
||||||
"--weekly-weekend-sat": darkMode
|
"--weekly-weekend-sat": activeTheme?.color3 || (darkMode
|
||||||
? invertColor(profile.weekendColorSat || "#666666")
|
? invertColor(profile.weekendColorSat || "#666666")
|
||||||
: profile.weekendColorSat || "#666666",
|
: profile.weekendColorSat || "#666666"),
|
||||||
"--weekly-weekend-sun": darkMode
|
"--weekly-weekend-sun": activeTheme?.color1 || (darkMode
|
||||||
? invertColor(profile.weekendColorSun || "#dc2626")
|
? invertColor(profile.weekendColorSun || "#dc2626")
|
||||||
: profile.weekendColorSun || "#dc2626",
|
: profile.weekendColorSun || "#dc2626"),
|
||||||
"--weekly-weekday-color": darkMode
|
"--weekly-weekday-color": activeTheme?.foreground || (darkMode
|
||||||
? invertColor(profile.weekdayColor || "#888888")
|
? invertColor(profile.weekdayColor || "#888888")
|
||||||
: profile.weekdayColor || "#888888",
|
: profile.weekdayColor || "#888888"),
|
||||||
"--weekly-date-color": darkMode
|
"--weekly-date-color": activeTheme?.color8 || (darkMode
|
||||||
? invertColor(profile.dateColor || "#888888")
|
? invertColor(profile.dateColor || "#888888")
|
||||||
: profile.dateColor || "#888888",
|
: profile.dateColor || "#888888"),
|
||||||
"--weekly-task-color": darkMode
|
"--weekly-task-color": activeTheme?.color7 || (darkMode
|
||||||
? invertColor(profile.taskColor || "#333333")
|
? invertColor(profile.taskColor || "#333333")
|
||||||
: profile.taskColor || "#333333",
|
: profile.taskColor || "#333333"),
|
||||||
"--weekly-today-highlight": darkMode
|
"--weekly-today-highlight": activeTheme?.color0 || (darkMode
|
||||||
? invertColor(profile.todayHighlightColor || "#f0fafa")
|
? invertColor(profile.todayHighlightColor || "#f0fafa")
|
||||||
: profile.todayHighlightColor || "#f0fafa",
|
: profile.todayHighlightColor || "#f0fafa"),
|
||||||
"--weekly-past-color": darkMode
|
"--weekly-past-color": activeTheme?.color8 || (darkMode
|
||||||
? invertColor(profile.pastDayColor || "#a6a6a7")
|
? invertColor(profile.pastDayColor || "#a6a6a7")
|
||||||
: profile.pastDayColor || "#a6a6a7",
|
: profile.pastDayColor || "#a6a6a7"),
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -5738,7 +5823,7 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT SECTION: Navigation & Tools */}
|
{/* RIGHT SECTION: Navigation & Tools */}
|
||||||
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
<div className="weekly-header-controls flex-shrink-0 flex items-center gap-2 sm:gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 10 }}>
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<button
|
<button
|
||||||
onClick={handleUndo}
|
onClick={handleUndo}
|
||||||
@ -6127,10 +6212,29 @@ export default function WeeklyView() {
|
|||||||
<ChevronsLeft size={16} />
|
<ChevronsLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Column */}
|
{/* Time Column */}
|
||||||
{showTimeGrid && (
|
{showTimeGrid && (
|
||||||
<div className="time-column">
|
<div
|
||||||
<div className="time-column-header" style={{ border: 'none', background: 'transparent' }}>
|
className="time-column"
|
||||||
|
ref={timeColumnRef}
|
||||||
|
onScroll={handleTimeColumnScroll}
|
||||||
|
style={{
|
||||||
|
height: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||||
|
maxHeight: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 'none',
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="time-column-header" style={{
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--weekly-bg)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 30
|
||||||
|
}}>
|
||||||
{/* Invisible structural match of day header to guarantee perfect height alignment */}
|
{/* Invisible structural match of day header to guarantee perfect height alignment */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -6154,7 +6258,7 @@ export default function WeeklyView() {
|
|||||||
: activeDateLayout === "below"
|
: activeDateLayout === "below"
|
||||||
? "column"
|
? "column"
|
||||||
: "row",
|
: "row",
|
||||||
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
|
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeDateLayout === "left" && (
|
{activeDateLayout === "left" && (
|
||||||
@ -6173,8 +6277,10 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="time-column-slots"
|
className="time-column-slots"
|
||||||
ref={timeColumnRef}
|
style={{
|
||||||
onScroll={handleTimeColumnScroll}
|
flex: "none",
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{visibleSlots.map((slot, index) => {
|
{visibleSlots.map((slot, index) => {
|
||||||
const hour = getHourFromSlot(slot);
|
const hour = getHourFromSlot(slot);
|
||||||
@ -6191,10 +6297,13 @@ export default function WeeklyView() {
|
|||||||
<div
|
<div
|
||||||
key={slot}
|
key={slot}
|
||||||
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
|
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
|
||||||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
style={{ height: `${getSlotHeight(cellDuration)}px`, cursor: isHourStart ? 'pointer' : 'default' }}
|
||||||
|
onClick={isHourStart ? () => jumpToHour(hour) : undefined}
|
||||||
|
title={isHourStart ? `Jump to ${hour}:00` : undefined}
|
||||||
>
|
>
|
||||||
{isHourStart && <span>{formatHour(hour, hourLabelFormat, timeFormat)}</span>}
|
{(isHourStart || showSubHourSlots) && (
|
||||||
{!isHourStart && showSubHourSlots && <span className="sub-hour-label">:{minutes}</span>}
|
<span>{formatHour(hour, parseInt(minutes), (isHourStart ? 'short' : 'full') as "short" | "full", timeFormat)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -6208,6 +6317,14 @@ export default function WeeklyView() {
|
|||||||
className={`weekly-days-grid cols-${viewDays}`}
|
className={`weekly-days-grid cols-${viewDays}`}
|
||||||
data-slide-direction={slideDirection}
|
data-slide-direction={slideDirection}
|
||||||
data-nav-type={viewDays > 1 ? "week" : "day"}
|
data-nav-type={viewDays > 1 ? "week" : "day"}
|
||||||
|
onScroll={handleGridScroll}
|
||||||
|
style={{
|
||||||
|
height: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||||
|
maxHeight: `${(workingHoursEnd - workingHoursStart) * (60 / cellDuration) * getSlotHeight(cellDuration) + getHeaderHeight(cellDuration)}px`,
|
||||||
|
flex: 1,
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getVisibleDays().map((date, colIndex) => {
|
{getVisibleDays().map((date, colIndex) => {
|
||||||
const todayMidnight = new Date();
|
const todayMidnight = new Date();
|
||||||
@ -6272,10 +6389,6 @@ export default function WeeklyView() {
|
|||||||
{showTimeGrid ? (
|
{showTimeGrid ? (
|
||||||
<div
|
<div
|
||||||
className="time-slots-container"
|
className="time-slots-container"
|
||||||
ref={(el) => {
|
|
||||||
if (el) dayColumnsRef.current[colIndex] = el;
|
|
||||||
}}
|
|
||||||
onScroll={(e) => handleDayColumnScroll(e, colIndex)}
|
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
style={{ position: "relative" }}
|
style={{ position: "relative" }}
|
||||||
>
|
>
|
||||||
@ -6293,10 +6406,10 @@ export default function WeeklyView() {
|
|||||||
const now = currentTime;
|
const now = currentTime;
|
||||||
const nowHour = now.getHours();
|
const nowHour = now.getHours();
|
||||||
const nowMinute = now.getMinutes();
|
const nowMinute = now.getMinutes();
|
||||||
// Only show if within visible time range
|
// Show if within 24h range
|
||||||
if (
|
if (
|
||||||
nowHour >= workingHoursStart &&
|
nowHour >= 0 &&
|
||||||
nowHour < workingHoursEnd
|
nowHour < 24
|
||||||
) {
|
) {
|
||||||
const minutesSinceStart =
|
const minutesSinceStart =
|
||||||
(nowHour - workingHoursStart) * 60 + nowMinute;
|
(nowHour - workingHoursStart) * 60 + nowMinute;
|
||||||
@ -6325,10 +6438,10 @@ export default function WeeklyView() {
|
|||||||
const eventStartHour = eventStart.getHours();
|
const eventStartHour = eventStart.getHours();
|
||||||
const eventStartMinute = eventStart.getMinutes();
|
const eventStartMinute = eventStart.getMinutes();
|
||||||
|
|
||||||
// Only show if event is within visible time range
|
// Show if within 24h range
|
||||||
if (
|
if (
|
||||||
eventStartHour < workingHoursStart ||
|
eventStartHour < 0 ||
|
||||||
eventStartHour >= workingHoursEnd
|
eventStartHour >= 24
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -12543,6 +12656,196 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Theme Import/Export */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--weekly-settings-item-bg)",
|
||||||
|
padding: "16px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
border: "1px solid var(--weekly-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--weekly-settings-title)",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Terminal Color Theme
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "var(--weekly-settings-label)", marginBottom: "16px" }}>
|
||||||
|
Import or Export standard Terminal 16-color JSON themes (e.g. Gogh, terminal.sexy) to completely change the app colors.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
||||||
|
{/* Light Theme */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.03)", borderRadius: "6px" }}>
|
||||||
|
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>☀️ Light Mode Theme</label>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||||||
|
Import JSON
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (evt) => {
|
||||||
|
try {
|
||||||
|
const text = evt.target?.result as string;
|
||||||
|
let json = JSON.parse(text);
|
||||||
|
|
||||||
|
// Normalizing standard terminal formats to a consistent internal format
|
||||||
|
const normalized: any = {
|
||||||
|
background: json.background,
|
||||||
|
foreground: json.foreground,
|
||||||
|
cursorColor: json.cursorColor || json.cursor,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Tabby-style colors array
|
||||||
|
if (Array.isArray(json.colors)) {
|
||||||
|
json.colors.forEach((c: string, i: number) => {
|
||||||
|
normalized[`color${i}`] = c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle Gogh/terminal.sexy color0...color15 keys
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfile((p: any) => ({ ...p, lightTheme: normalized }));
|
||||||
|
alert("Light Theme imported! Save changes to apply.");
|
||||||
|
} catch (err) {
|
||||||
|
alert("Invalid JSON format.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="weekly-btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
// Fix: access profile from state properly
|
||||||
|
const currentTheme = (profile as any).lightTheme || {};
|
||||||
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = dataStr;
|
||||||
|
anchor.download = "light-theme.json";
|
||||||
|
anchor.click();
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{((profile as any).lightTheme) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setProfile((p: any) => ({ ...p, lightTheme: null }))}
|
||||||
|
style={{ fontSize: "0.75rem", color: "#dc2626", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||||||
|
>
|
||||||
|
Clear Light Theme
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Color 16-grid Preview */}
|
||||||
|
{(profile as any).lightTheme && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||||||
|
{[...Array(16)].map((_, i) => (
|
||||||
|
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).lightTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dark Theme */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "12px", background: "rgba(0,0,0,0.2)", borderRadius: "6px" }}>
|
||||||
|
<label style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--weekly-settings-title)" }}>🌙 Dark Mode Theme</label>
|
||||||
|
<div style={{ display: "flex", gap: "8px" }}>
|
||||||
|
<label className="weekly-btn-secondary" style={{ flex: 1, textAlign: "center", cursor: "pointer", fontSize: "0.8rem", padding: "6px" }}>
|
||||||
|
Import JSON
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (evt) => {
|
||||||
|
try {
|
||||||
|
const text = evt.target?.result as string;
|
||||||
|
let json = JSON.parse(text);
|
||||||
|
|
||||||
|
const normalized: any = {
|
||||||
|
background: json.background,
|
||||||
|
foreground: json.foreground,
|
||||||
|
cursorColor: json.cursorColor || json.cursor,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(json.colors)) {
|
||||||
|
json.colors.forEach((c: string, i: number) => {
|
||||||
|
normalized[`color${i}`] = c;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
if (json[`color${i}`]) normalized[`color${i}`] = json[`color${i}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfile((p: any) => ({ ...p, darkTheme: normalized }));
|
||||||
|
alert("Dark Theme imported! Save changes to apply.");
|
||||||
|
} catch (err) {
|
||||||
|
alert("Invalid JSON format.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="weekly-btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentTheme = (profile as any).darkTheme || {};
|
||||||
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(currentTheme, null, 2));
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = dataStr;
|
||||||
|
anchor.download = "dark-theme.json";
|
||||||
|
anchor.click();
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, fontSize: "0.8rem", padding: "6px" }}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{((profile as any).darkTheme) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setProfile((p: any) => ({ ...p, darkTheme: null }))}
|
||||||
|
style={{ fontSize: "0.75rem", color: "#ef4444", background: "none", border: "none", cursor: "pointer", textAlign: "left", marginTop: "4px" }}
|
||||||
|
>
|
||||||
|
Clear Dark Theme
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Color 16-grid Preview */}
|
||||||
|
{(profile as any).darkTheme && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px", marginTop: "8px" }}>
|
||||||
|
{[...Array(16)].map((_, i) => (
|
||||||
|
<div key={i} title={`color${i}`} style={{ width: "100%", height: "12px", background: (profile as any).darkTheme[`color${i}`] || "#ccc", borderRadius: "2px" }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Element Colors */}
|
{/* Element Colors */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -16,6 +16,16 @@ export interface AppleCalendarEvent {
|
|||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Date to local YYYY-MM-DD string without timezone shift.
|
||||||
|
*/
|
||||||
|
function formatDateToLocalISO(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppleCalendar {
|
export interface AppleCalendar {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -165,8 +175,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
|
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
|
||||||
title: exEvent.summary || 'Untitled Event',
|
title: exEvent.summary || 'Untitled Event',
|
||||||
startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
|
startDate: exIsAllDay ? formatDateToLocalISO(exStart) : exStart.toISOString(),
|
||||||
endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
|
endDate: exIsAllDay ? formatDateToLocalISO(exEnd) : exEnd.toISOString(),
|
||||||
description: exEvent.description,
|
description: exEvent.description,
|
||||||
location: exEvent.location,
|
location: exEvent.location,
|
||||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
@ -200,8 +210,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
|
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
|
||||||
title: event.summary || 'Untitled Event',
|
title: event.summary || 'Untitled Event',
|
||||||
startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
|
startDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occStart.toISOString(),
|
||||||
endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
|
endDate: isAllDayRecurring ? formatDateToLocalISO(occEnd) : occEnd.toISOString(),
|
||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
@ -223,8 +233,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
|
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
|
||||||
title: event.summary || 'Untitled Event',
|
title: event.summary || 'Untitled Event',
|
||||||
startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
|
startDate: isAllDay ? formatDateToLocalISO(start) : start.toISOString(),
|
||||||
endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
|
endDate: isAllDay ? formatDateToLocalISO(end) : end.toISOString(),
|
||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
|
|||||||
@ -639,6 +639,7 @@ export const createCalendarEvent = async (
|
|||||||
end: event.end,
|
end: event.end,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
...(rrule ? { recurrence: [rrule] } : {}),
|
...(rrule ? { recurrence: [rrule] } : {}),
|
||||||
|
...(event.allDay ? { allDay: true } : {}),
|
||||||
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
...(event.url ? { source: { url: event.url, title: event.url } } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -699,8 +700,28 @@ export const createCalendarEvent = async (
|
|||||||
// Our 'event' arg is Partial<CalendarEvent>, which matches well.
|
// Our 'event' arg is Partial<CalendarEvent>, which matches well.
|
||||||
// However, event.start and event.end might be undefined in Partial, so we need checks.
|
// However, event.start and event.end might be undefined in Partial, so we need checks.
|
||||||
|
|
||||||
if (!event.title) throw new Error('Event title is required');
|
// Handle all-day event normalization for Apple (requires 'date' property, not 'dateTime')
|
||||||
if (!event.start || !event.end) throw new Error('Event start and end times are required');
|
// AND exclusive end date (add 1 day if needed)
|
||||||
|
const start = { ...event.start! };
|
||||||
|
let end = { ...event.end! };
|
||||||
|
|
||||||
|
if (event.allDay) {
|
||||||
|
if (start.dateTime && !start.date) {
|
||||||
|
start.date = start.dateTime.split('T')[0];
|
||||||
|
delete start.dateTime;
|
||||||
|
}
|
||||||
|
if (end.dateTime && !end.date) {
|
||||||
|
end.date = end.dateTime.split('T')[0];
|
||||||
|
delete end.dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple/iCloud requires DTEND to be the day AFTER the last day of the event
|
||||||
|
if (start.date && end.date && start.date === end.date) {
|
||||||
|
const d = new Date(end.date);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
end.date = d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createdEvent = await import('./apple-calendar').then(m =>
|
const createdEvent = await import('./apple-calendar').then(m =>
|
||||||
m.createEvent(email, appPassword, calendarId, {
|
m.createEvent(email, appPassword, calendarId, {
|
||||||
@ -709,8 +730,8 @@ export const createCalendarEvent = async (
|
|||||||
location: event.location,
|
location: event.location,
|
||||||
url: event.url,
|
url: event.url,
|
||||||
recurrence: event.recurrence,
|
recurrence: event.recurrence,
|
||||||
start: event.start!,
|
start,
|
||||||
end: event.end!
|
end
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -851,6 +872,24 @@ export const updateCalendarEvent = async (
|
|||||||
} else if (connection.provider === 'apple') {
|
} else if (connection.provider === 'apple') {
|
||||||
const [email, appPassword] = connection.accessToken.split(':');
|
const [email, appPassword] = connection.accessToken.split(':');
|
||||||
|
|
||||||
|
// Normalize for Apple updates too
|
||||||
|
if (event.allDay) {
|
||||||
|
if (event.start && event.start.dateTime && !event.start.date) {
|
||||||
|
event.start.date = event.start.dateTime.split('T')[0];
|
||||||
|
delete event.start.dateTime;
|
||||||
|
}
|
||||||
|
if (event.end && event.end.dateTime && !event.end.date) {
|
||||||
|
event.end.date = event.end.dateTime.split('T')[0];
|
||||||
|
delete event.end.dateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.start && event.end && event.start.date && event.end.date && event.start.date === event.end.date) {
|
||||||
|
const d = new Date(event.end.date);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
event.end.date = d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedEvent = await import('./apple-calendar').then(m =>
|
const updatedEvent = await import('./apple-calendar').then(m =>
|
||||||
m.updateEvent(email, appPassword, calendarId, eventId, {
|
m.updateEvent(email, appPassword, calendarId, eventId, {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
|
|||||||
@ -13,6 +13,16 @@ export interface SynologyCalendarEvent {
|
|||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Date to local YYYY-MM-DD string without timezone shift.
|
||||||
|
*/
|
||||||
|
function formatDateToLocalISO(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SynologyCalendar {
|
export interface SynologyCalendar {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -196,8 +206,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
|
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
|
||||||
title: exEvent.summary || 'Untitled Event',
|
title: exEvent.summary || 'Untitled Event',
|
||||||
startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
|
startDate: exIsAllDay ? formatDateToLocalISO(exStart) : exStart.toISOString(),
|
||||||
endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
|
endDate: exIsAllDay ? formatDateToLocalISO(exEnd) : exEnd.toISOString(),
|
||||||
description: exEvent.description,
|
description: exEvent.description,
|
||||||
location: exEvent.location,
|
location: exEvent.location,
|
||||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
@ -225,8 +235,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
|
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
|
||||||
title: event.summary || 'Untitled Event',
|
title: event.summary || 'Untitled Event',
|
||||||
startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
|
startDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occStart.toISOString(),
|
||||||
endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
|
endDate: isAllDayRecurring ? formatDateToLocalISO(occStart) : occEnd.toISOString(),
|
||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
@ -247,8 +257,8 @@ export const getUpcomingEvents = async (
|
|||||||
parsedEvents.push({
|
parsedEvents.push({
|
||||||
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
|
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
|
||||||
title: event.summary || 'Untitled Event',
|
title: event.summary || 'Untitled Event',
|
||||||
startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
|
startDate: isAllDay ? formatDateToLocalISO(start) : start.toISOString(),
|
||||||
endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
|
endDate: isAllDay ? formatDateToLocalISO(end) : end.toISOString(),
|
||||||
description: event.description,
|
description: event.description,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user