feat: enhance font settings, UI improvements, and recurring task logic
This commit is contained in:
parent
5321f06820
commit
f603168494
4
next.config.js
Normal file
4
next.config.js
Normal file
@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@ -36,6 +36,9 @@ model User {
|
||||
cellDuration Int @default(30)
|
||||
viewStyle String @default("grid")
|
||||
fontSize String @default("M") // "S", "M", "L"
|
||||
headlineFont String @default("Inter")
|
||||
bodyFont String @default("Inter")
|
||||
fontWeight String @default("normal") // "light", "normal", "bold"
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@ -1,10 +1,120 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '../auth/[...nextauth]/route';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Task } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Helper to generate a deterministic virtual ID
|
||||
const generateVirtualId = (originalId: string, dateStr: string) => {
|
||||
return `virtual-${originalId}-${dateStr}`;
|
||||
};
|
||||
|
||||
// Helper to project future tasks
|
||||
const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
|
||||
const projectedTasks: any[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const horizonDate = new Date(today);
|
||||
horizonDate.setDate(today.getDate() + horizonDays);
|
||||
|
||||
// Group tasks by "series signature" to find the latest one to project from
|
||||
// Signature uses: title + recurrence settings + userId
|
||||
const seriesGroups = new Map<string, Task[]>();
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (task.isRecurring && !task.completed && task.scheduledDate) {
|
||||
const signature = `${task.userId}-${task.title}-${task.recurrenceInterval}-${task.recurrenceUnit}-${task.recurrenceTime}`;
|
||||
if (!seriesGroups.has(signature)) {
|
||||
seriesGroups.set(signature, []);
|
||||
}
|
||||
seriesGroups.get(signature)?.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
// For each series, project from the LATEST scheduled task
|
||||
seriesGroups.forEach((groupTasks) => {
|
||||
// Sort descending by date
|
||||
groupTasks.sort((a, b) => {
|
||||
const da = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const db = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return db - da; // Latest first
|
||||
});
|
||||
|
||||
const latestTask = groupTasks[0];
|
||||
if (!latestTask.scheduledDate) return;
|
||||
|
||||
const baseDate = new Date(latestTask.scheduledDate);
|
||||
// If base date is in future, start from there. If in past, start from today?
|
||||
// Actually, simple projection: continue strictly from base date
|
||||
|
||||
let currentDate = new Date(baseDate);
|
||||
const interval = latestTask.recurrenceInterval || 1;
|
||||
const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years'
|
||||
|
||||
// Advance to next slot
|
||||
// We only generate UP TO horizon.
|
||||
// We avoid generating duplicates if a real task already exists at that date?
|
||||
// We already grouped by series, and we are projecting from the *latest* one.
|
||||
// So any future dates we generate *should* be new.
|
||||
|
||||
// Safety break
|
||||
let iterations = 0;
|
||||
while (currentDate < horizonDate && iterations < 100) {
|
||||
iterations++;
|
||||
|
||||
// Advance date
|
||||
if (unit === 'days') {
|
||||
currentDate.setDate(currentDate.getDate() + interval);
|
||||
} else if (unit === 'weeks') {
|
||||
currentDate.setDate(currentDate.getDate() + (interval * 7));
|
||||
} else if (unit === 'months') {
|
||||
currentDate.setMonth(currentDate.getMonth() + interval);
|
||||
} else if (unit === 'years') {
|
||||
currentDate.setFullYear(currentDate.getFullYear() + interval);
|
||||
} else {
|
||||
// Default to weekly if unknown
|
||||
currentDate.setDate(currentDate.getDate() + 7);
|
||||
}
|
||||
|
||||
if (latestTask.recurrenceEndDate && currentDate > new Date(latestTask.recurrenceEndDate)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentDate <= today) {
|
||||
// Skip past dates that weren't generated (logic gap? no, if it's in past and not in DB, maybe user deleted it? or we just missed it. Let's show it if it's > today, or maybe >= today if late?)
|
||||
// If we project from *latest* task, and latest task is e.g. Yesterday.
|
||||
// Next is Today. We should show it.
|
||||
// If latest task is Today. Next is Tomorrow.
|
||||
// So we just check if currentDate >= today?
|
||||
// Actually, if we have "overdue" tasks in the list, users usually see them.
|
||||
// We only care about *future* projections here usually.
|
||||
// Although showing "missed" recurrence in the past as virtual tasks might be annoying to clean up.
|
||||
// Let's stick to future-only projection (>= Today) for virtual tasks to be safe/clean.
|
||||
if (currentDate < today) continue;
|
||||
}
|
||||
|
||||
// Check if we already have a task for this date in the group (unlikely due to sorting, but possible if we have gaps)
|
||||
// Actually, since we project from LATEST, we assume no *later* tasks exist.
|
||||
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
|
||||
projectedTasks.push({
|
||||
...latestTask,
|
||||
id: generateVirtualId(latestTask.id, dateStr),
|
||||
scheduledDate: new Date(currentDate), // Clone
|
||||
createdAt: new Date(), // Now
|
||||
updatedAt: new Date(), // Now
|
||||
isVirtual: true, // Flag for frontend if needed (not in Prisma type, but JS object accepts it)
|
||||
originalTaskId: latestTask.id // Reference
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return projectedTasks;
|
||||
};
|
||||
|
||||
// GET - Fetch all tasks for authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@ -18,42 +128,11 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
// Rolling Logic: Find incomplete rolling tasks from the past and move them to today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const pastRollingTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
userId,
|
||||
completed: false,
|
||||
isRolling: true,
|
||||
scheduledDate: {
|
||||
lt: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (pastRollingTasks.length > 0) {
|
||||
// Current day of week (0-6)
|
||||
const currentDayOfWeek = today.getDay();
|
||||
|
||||
// Bulk update past rolling tasks to today
|
||||
await prisma.task.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: pastRollingTasks.map(t => t.id)
|
||||
}
|
||||
},
|
||||
data: {
|
||||
scheduledDate: today,
|
||||
dayOfWeek: currentDayOfWeek,
|
||||
startTime: null, // Reset time for rolled tasks as they might clash
|
||||
endTime: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch REAL tasks
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { userId },
|
||||
orderBy: [
|
||||
@ -62,7 +141,26 @@ export async function GET(request: NextRequest) {
|
||||
],
|
||||
});
|
||||
|
||||
return NextResponse.json({ tasks });
|
||||
// Project VIRTUAL tasks
|
||||
const virtualTasks = projectFutureTasks(tasks);
|
||||
|
||||
// Combine
|
||||
const allTasks = [...tasks, ...virtualTasks];
|
||||
|
||||
// Optional: Filter by date range if provided (optimization)
|
||||
// Front-end usually fetches all, but let's be ready
|
||||
let filteredTasks = allTasks;
|
||||
if (start && end) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
filteredTasks = allTasks.filter(t => {
|
||||
if (!t.scheduledDate) return true; // keep undated?
|
||||
const d = new Date(t.scheduledDate);
|
||||
return d >= startDate && d <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ tasks: filteredTasks });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
return NextResponse.json(
|
||||
@ -136,6 +234,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// MATCH virtual ID pattern: virtual-{originalId}-{dateStr}
|
||||
const VIRTUAL_ID_REGEX = /^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/;
|
||||
|
||||
// PATCH - Update task
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
@ -151,7 +252,8 @@ export async function PATCH(request: NextRequest) {
|
||||
const userId = (session.user as any).id;
|
||||
const body = await request.json();
|
||||
|
||||
const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
|
||||
let { id } = body;
|
||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -160,7 +262,51 @@ export async function PATCH(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify task belongs to user
|
||||
// Handle VIRTUAL TASK Materialization
|
||||
const virtualMatch = id.match(VIRTUAL_ID_REGEX);
|
||||
if (virtualMatch) {
|
||||
const originalId = virtualMatch[1];
|
||||
const dateStr = virtualMatch[2]; // YYYY-MM-DD
|
||||
|
||||
// 1. Fetch original task properties
|
||||
const originalTask = await prisma.task.findUnique({
|
||||
where: { id: originalId }
|
||||
});
|
||||
|
||||
if (!originalTask || originalTask.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Original task not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Create NEW task instance (Materialize)
|
||||
const newTask = await prisma.task.create({
|
||||
data: {
|
||||
title: originalTask.title,
|
||||
description: originalTask.description,
|
||||
markdownContent: originalTask.markdownContent,
|
||||
userId: userId,
|
||||
scheduledDate: new Date(dateStr),
|
||||
startTime: originalTask.recurrenceTime || originalTask.startTime,
|
||||
// Inherit recurrence settings so IT projects further too?
|
||||
// YES, the chain must continue.
|
||||
isRecurring: true,
|
||||
recurrenceInterval: originalTask.recurrenceInterval,
|
||||
recurrenceUnit: originalTask.recurrenceUnit,
|
||||
recurrenceTime: originalTask.recurrenceTime,
|
||||
recurrenceEndDate: originalTask.recurrenceEndDate,
|
||||
isRolling: originalTask.isRolling,
|
||||
// Apply overrides from the patch body immediately
|
||||
completed: completed !== undefined ? completed : false,
|
||||
// If title changed in this patch, valid
|
||||
...(title !== undefined && { title }),
|
||||
// If rescheduled immediately
|
||||
...(scheduledDate !== undefined && { scheduledDate: new Date(scheduledDate) }),
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ task: newTask });
|
||||
}
|
||||
|
||||
// NORMAL UPDATE LOGIC for existing tasks
|
||||
const existingTask = await prisma.task.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
@ -193,61 +339,11 @@ export async function PATCH(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// Validating recurrence logic:
|
||||
// If task is NOW completed, WAS NOT completed before, and IS recurring -> Create next instance
|
||||
if (completed === true && !existingTask.completed && task.isRecurring) {
|
||||
try {
|
||||
const interval = task.recurrenceInterval || 1;
|
||||
const unit = task.recurrenceUnit || 'weeks';
|
||||
|
||||
// Calculate next date based on the task's current scheduled date
|
||||
// If no scheduled date, use today? Usually recurring tasks have a date.
|
||||
let baseDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date();
|
||||
let nextDate = new Date(baseDate);
|
||||
|
||||
if (unit === 'days') {
|
||||
nextDate.setDate(baseDate.getDate() + interval);
|
||||
} else if (unit === 'weeks') {
|
||||
nextDate.setDate(baseDate.getDate() + (interval * 7));
|
||||
} else if (unit === 'months') {
|
||||
nextDate.setMonth(baseDate.getMonth() + interval);
|
||||
}
|
||||
|
||||
// Check end date
|
||||
if (!task.recurrenceEndDate || nextDate <= new Date(task.recurrenceEndDate)) {
|
||||
|
||||
// Create the next task
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
markdownContent: task.markdownContent,
|
||||
userId: task.userId,
|
||||
// Set the new date
|
||||
scheduledDate: nextDate,
|
||||
dayOfWeek: nextDate.getDay(),
|
||||
startTime: task.recurrenceTime || task.startTime, // Use specific recurrence time if set, else keep original or null
|
||||
|
||||
// Copy recurrence settings so the chain continues
|
||||
isRecurring: true,
|
||||
recurrenceInterval: task.recurrenceInterval,
|
||||
recurrenceUnit: task.recurrenceUnit,
|
||||
recurrenceTime: task.recurrenceTime,
|
||||
recurrenceEndDate: task.recurrenceEndDate,
|
||||
|
||||
// Rolling settings copy
|
||||
isRolling: task.isRolling,
|
||||
|
||||
order: 0, // Put at top? Or maybe last? 0 is fine for now.
|
||||
completed: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (recError) {
|
||||
console.error('Error creating next recurring task instance:', recError);
|
||||
// Don't fail the original update if recurrence fails, just log it.
|
||||
}
|
||||
}
|
||||
// NOTE: We REMOVED the "create next task on completion" logic block here.
|
||||
// Why? Because the projection system handles "next tasks" automatically.
|
||||
// If we kept it, completing a task would create a duplicate materialized task for the next date,
|
||||
// which would exist ALONGSIDE the one we projected.
|
||||
// By removing it, we rely purely on the projection system (or manual materialization via interaction).
|
||||
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
@ -282,7 +378,41 @@ export async function DELETE(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify task belongs to user
|
||||
// Handle VIRTUAL TASK Deletion
|
||||
// We "delete" a virtual task by creating it as completed (so it doesn't show up as pending).
|
||||
// Or we could create an explicit "exception" record, but for now completing it is the easiest way to "dismiss" it.
|
||||
const virtualMatch = id.match(VIRTUAL_ID_REGEX);
|
||||
if (virtualMatch) {
|
||||
const originalId = virtualMatch[1];
|
||||
const dateStr = virtualMatch[2];
|
||||
|
||||
const originalTask = await prisma.task.findUnique({ where: { id: originalId } });
|
||||
if (!originalTask || originalTask.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Original task not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Materialize as COMPLETED to effectively "remove" it from the todo list
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
title: originalTask.title,
|
||||
description: originalTask.description,
|
||||
markdownContent: originalTask.markdownContent,
|
||||
userId: userId,
|
||||
scheduledDate: new Date(dateStr),
|
||||
startTime: originalTask.recurrenceTime || originalTask.startTime,
|
||||
isRecurring: true,
|
||||
recurrenceInterval: originalTask.recurrenceInterval,
|
||||
recurrenceUnit: originalTask.recurrenceUnit,
|
||||
recurrenceTime: originalTask.recurrenceTime,
|
||||
recurrenceEndDate: originalTask.recurrenceEndDate,
|
||||
isRolling: originalTask.isRolling,
|
||||
completed: true // Marked done so it doesn't appear pending
|
||||
}
|
||||
});
|
||||
return NextResponse.json({ message: 'Virtual task dismissed' });
|
||||
}
|
||||
|
||||
// Validate ownership before delete
|
||||
const existingTask = await prisma.task.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
@ -31,6 +31,9 @@ export async function GET(request: NextRequest) {
|
||||
cellDuration: true,
|
||||
viewStyle: true,
|
||||
fontSize: true,
|
||||
headlineFont: true,
|
||||
bodyFont: true,
|
||||
fontWeight: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
@ -56,7 +59,8 @@ export async function PATCH(request: NextRequest) {
|
||||
name, timezone, password, autoRolling, protectEventTimes,
|
||||
language, dateFormat, timeFormat, startHour, endHour,
|
||||
showNextTask, calendarEditMode, focusTimerDuration,
|
||||
showTimeGrid, cellDuration, viewStyle, fontSize
|
||||
showTimeGrid, cellDuration, viewStyle, fontSize,
|
||||
headlineFont, bodyFont, fontWeight
|
||||
} = body;
|
||||
|
||||
const updateData: any = {
|
||||
@ -76,6 +80,9 @@ export async function PATCH(request: NextRequest) {
|
||||
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
|
||||
...(viewStyle !== undefined && { viewStyle }),
|
||||
...(fontSize !== undefined && { fontSize }),
|
||||
...(headlineFont !== undefined && { headlineFont }),
|
||||
...(bodyFont !== undefined && { bodyFont }),
|
||||
...(fontWeight !== undefined && { fontWeight }),
|
||||
};
|
||||
if (password && password.trim() !== "") {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
@ -103,6 +110,9 @@ export async function PATCH(request: NextRequest) {
|
||||
cellDuration: true,
|
||||
viewStyle: true,
|
||||
fontSize: true,
|
||||
headlineFont: true,
|
||||
bodyFont: true,
|
||||
fontWeight: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -20,7 +20,8 @@ import {
|
||||
Menu,
|
||||
Target,
|
||||
Sun,
|
||||
Moon
|
||||
Moon,
|
||||
Repeat
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
@ -72,6 +73,27 @@ interface SomedayList {
|
||||
// Time grid configuration options
|
||||
type CellDuration = 15 | 30 | 60 | 120;
|
||||
|
||||
// Font options
|
||||
const AVAILABLE_FONTS = [
|
||||
{ name: 'Default (Inter)', value: 'Inter' },
|
||||
{ name: 'Roboto', value: 'Roboto' },
|
||||
{ name: 'Open Sans', value: 'Open Sans' },
|
||||
{ name: 'Lato', value: 'Lato' },
|
||||
{ name: 'Montserrat', value: 'Montserrat' },
|
||||
{ name: 'Oswald', value: 'Oswald' },
|
||||
{ name: 'Raleway', value: 'Raleway' },
|
||||
{ name: 'Playfair Display', value: 'Playfair Display' },
|
||||
{ name: 'Merriweather', value: 'Merriweather' },
|
||||
{ name: 'Nunito', value: 'Nunito' },
|
||||
];
|
||||
|
||||
const FONT_WEIGHTS = [
|
||||
{ name: 'Light', value: '300' },
|
||||
{ name: 'Normal', value: '400' },
|
||||
{ name: 'Medium', value: '500' },
|
||||
{ name: 'Bold', value: '700' },
|
||||
];
|
||||
|
||||
// Translations
|
||||
const translations: Record<string, any> = {
|
||||
en: {
|
||||
@ -338,6 +360,34 @@ export default function WeeklyView() {
|
||||
|
||||
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
|
||||
const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M');
|
||||
const [headlineFont, setHeadlineFont] = useState('Inter');
|
||||
const [bodyFont, setBodyFont] = useState('Inter');
|
||||
const [fontWeight, setFontWeight] = useState('400');
|
||||
|
||||
// Load Google Fonts
|
||||
useEffect(() => {
|
||||
const fontsToLoad = new Set([headlineFont, bodyFont]);
|
||||
fontsToLoad.delete('Inter'); // Inter is likely already loaded or default
|
||||
|
||||
if (fontsToLoad.size === 0) return;
|
||||
|
||||
const linkId = 'google-fonts-dynamic';
|
||||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Simplification: Load all needed weights for selected fonts
|
||||
const families = Array.from(fontsToLoad).map(font =>
|
||||
`${font.replace(/\s+/g, '+')}:wght@300;400;500;700`
|
||||
).join('&');
|
||||
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${families}&display=swap`;
|
||||
}, [headlineFont, bodyFont]);
|
||||
|
||||
// Calendar Event Modal State
|
||||
const [calendarEventModal, setCalendarEventModal] = useState<{
|
||||
@ -1106,7 +1156,51 @@ export default function WeeklyView() {
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId: string) => {
|
||||
setTasks(tasks.filter(t => t.id !== taskId));
|
||||
const taskToDelete = tasks.find(t => t.id === taskId);
|
||||
const isVirtual = taskId.startsWith('virtual-');
|
||||
|
||||
let originalId = taskId;
|
||||
if (isVirtual) {
|
||||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||||
if (match) {
|
||||
originalId = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a series (virtual or real recurring)
|
||||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||||
|
||||
if (isSeries) {
|
||||
// Confirm deletion type
|
||||
const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE.");
|
||||
|
||||
if (deleteSeries) {
|
||||
// DELETE SERIES
|
||||
// Remove all tasks related to this series from the UI immediately
|
||||
setTasks(prev => prev.filter(t => {
|
||||
// Check if t is the original task
|
||||
if (t.id === originalId) return false;
|
||||
// Check if t is a virtual task of this series
|
||||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||||
// Check if t is the specific task being clicked (if logic above didn't catch it)
|
||||
if (t.id === taskId) return false;
|
||||
return true;
|
||||
}));
|
||||
setEditingTaskId(null);
|
||||
|
||||
try {
|
||||
// Deleting the original ID stops the series
|
||||
await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting series:', error);
|
||||
// Optionally revert UI state here if needed, but for now assuming success
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// NORMAL DELETE (Single instance)
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
setEditingTaskId(null);
|
||||
|
||||
try {
|
||||
@ -1362,6 +1456,12 @@ export default function WeeklyView() {
|
||||
// Get time slots to display
|
||||
const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||||
|
||||
const containerStyle = {
|
||||
'--font-headline': `"${headlineFont}", sans-serif`,
|
||||
'--font-body': `"${bodyFont}", sans-serif`,
|
||||
'--font-weight-body': fontWeight,
|
||||
} as React.CSSProperties;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="weekly-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||
@ -1371,7 +1471,7 @@ export default function WeeklyView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`weekly-container ${darkMode ? 'dark-mode' : ''} font-size-${fontSize.toLowerCase()} ${viewStyle}-view`}>
|
||||
<div className={`weekly-container ${darkMode ? 'dark-mode' : ''} font-size-${fontSize.toLowerCase()} ${viewStyle}-view`} style={containerStyle}>
|
||||
{/* View Transitions Style Block */}
|
||||
<style dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
@ -1566,6 +1666,15 @@ export default function WeeklyView() {
|
||||
<Search size={18} />
|
||||
</button>
|
||||
|
||||
{/* Recurring Tasks */}
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||||
onClick={() => setIsRecurringTasksOpen(true)}
|
||||
title="Recurring Tasks"
|
||||
>
|
||||
<Repeat size={18} />
|
||||
</button>
|
||||
|
||||
{/* Settings - Actually triggers User Menu in original code? No, settings was separate. */}
|
||||
{/* Original code had UserMenu handling settings. And a separate sync indicator. */}
|
||||
{/* The prompt asked for: Search | Settings | User Menu */}
|
||||
@ -2503,6 +2612,12 @@ export default function WeeklyView() {
|
||||
setFontSize={setFontSize}
|
||||
showNextTask={showNextTask}
|
||||
setShowNextTask={setShowNextTask}
|
||||
headlineFont={headlineFont}
|
||||
setHeadlineFont={setHeadlineFont}
|
||||
bodyFont={bodyFont}
|
||||
setBodyFont={setBodyFont}
|
||||
fontWeight={fontWeight}
|
||||
setFontWeight={setFontWeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -3135,6 +3250,9 @@ interface SettingsModalProps {
|
||||
endHour: number;
|
||||
fontSize: 'S' | 'M' | 'L';
|
||||
showNextTask: boolean;
|
||||
headlineFont: string;
|
||||
bodyFont: string;
|
||||
fontWeight: string;
|
||||
}) => void;
|
||||
showTimeGrid: boolean;
|
||||
setShowTimeGrid: (show: boolean) => void;
|
||||
@ -3158,6 +3276,12 @@ interface SettingsModalProps {
|
||||
setFontSize: (size: 'S' | 'M' | 'L') => void;
|
||||
showNextTask: boolean;
|
||||
setShowNextTask: (show: boolean) => void;
|
||||
headlineFont: string;
|
||||
setHeadlineFont: (font: string) => void;
|
||||
bodyFont: string;
|
||||
setBodyFont: (font: string) => void;
|
||||
fontWeight: string;
|
||||
setFontWeight: (weight: string) => void;
|
||||
}
|
||||
|
||||
function SettingsModal({
|
||||
@ -3184,7 +3308,13 @@ function SettingsModal({
|
||||
fontSize,
|
||||
setFontSize,
|
||||
showNextTask,
|
||||
setShowNextTask
|
||||
setShowNextTask,
|
||||
headlineFont,
|
||||
setHeadlineFont,
|
||||
bodyFont,
|
||||
setBodyFont,
|
||||
fontWeight,
|
||||
setFontWeight
|
||||
}: SettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general');
|
||||
// connections state removed (lifted)
|
||||
@ -3210,6 +3340,9 @@ function SettingsModal({
|
||||
viewStyle?: string;
|
||||
fontSize?: 'S' | 'M' | 'L';
|
||||
showNextTask?: boolean;
|
||||
headlineFont?: string;
|
||||
bodyFont?: string;
|
||||
fontWeight?: string;
|
||||
}>({
|
||||
name: '',
|
||||
email: '',
|
||||
@ -3226,12 +3359,15 @@ function SettingsModal({
|
||||
cellDuration: 30,
|
||||
viewStyle: 'list',
|
||||
fontSize: 'M',
|
||||
showNextTask: false
|
||||
showNextTask: false,
|
||||
headlineFont: 'Inter',
|
||||
bodyFont: 'Inter',
|
||||
fontWeight: '400'
|
||||
});
|
||||
|
||||
// Draggable/Resizable Modal State
|
||||
const [modalPos, setModalPos] = useState({ x: 0, y: 0 });
|
||||
const [modalSize, setModalSize] = useState({ width: 500, height: 750 });
|
||||
const [modalSize, setModalSize] = useState({ width: 800, height: 800 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
@ -3303,13 +3439,19 @@ function SettingsModal({
|
||||
showTimeGrid: data.user.showTimeGrid !== undefined ? data.user.showTimeGrid : true,
|
||||
cellDuration: data.user.cellDuration || 30,
|
||||
viewStyle: data.user.viewStyle || 'list',
|
||||
fontSize: data.user.fontSize || 'M'
|
||||
fontSize: data.user.fontSize || 'M',
|
||||
headlineFont: data.user.headlineFont || 'Inter',
|
||||
bodyFont: data.user.bodyFont || 'Inter',
|
||||
fontWeight: data.user.fontWeight || '400',
|
||||
});
|
||||
|
||||
if (data.user.showTimeGrid !== undefined) setShowTimeGrid(data.user.showTimeGrid);
|
||||
if (data.user.cellDuration) setCellDuration(data.user.cellDuration as CellDuration);
|
||||
if (data.user.viewStyle) setViewStyle(data.user.viewStyle as 'grid' | 'list');
|
||||
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
|
||||
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
|
||||
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
|
||||
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
|
||||
if (data.user.focusTimerDuration) setFocusTimerDuration(data.user.focusTimerDuration);
|
||||
}
|
||||
}
|
||||
@ -3388,6 +3530,9 @@ function SettingsModal({
|
||||
cellDuration: cellDuration,
|
||||
viewStyle: viewStyle,
|
||||
showNextTask: showNextTask,
|
||||
headlineFont: headlineFont,
|
||||
bodyFont: bodyFont,
|
||||
fontWeight: fontWeight,
|
||||
password: (passwords.new && passwords.new.trim() !== "") ? passwords.new : undefined
|
||||
})
|
||||
});
|
||||
@ -3410,6 +3555,9 @@ function SettingsModal({
|
||||
endHour: profile.endHour || 18,
|
||||
fontSize: (profile.fontSize || 'M') as 'S' | 'M' | 'L',
|
||||
showNextTask: showNextTask,
|
||||
headlineFont: profile.headlineFont || 'Inter',
|
||||
bodyFont: profile.bodyFont || 'Inter',
|
||||
fontWeight: profile.fontWeight || '400'
|
||||
});
|
||||
}
|
||||
|
||||
@ -3745,6 +3893,92 @@ function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Settings */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '8px' }}>Typography</label>
|
||||
|
||||
{/* Headline Font */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Headline Font</label>
|
||||
<select
|
||||
value={profile.headlineFont || 'Inter'}
|
||||
onChange={(e) => setProfile({ ...profile, headlineFont: e.target.value })}
|
||||
className="weekly-input"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
{AVAILABLE_FONTS.map(font => (
|
||||
<option key={font.value} value={font.value}>{font.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Body Font */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Body Font</label>
|
||||
<select
|
||||
value={profile.bodyFont || 'Inter'}
|
||||
onChange={(e) => setProfile({ ...profile, bodyFont: e.target.value })}
|
||||
className="weekly-input"
|
||||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
{AVAILABLE_FONTS.map(font => (
|
||||
<option key={font.value} value={font.value}>{font.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Font Weight */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Body Font Weight</label>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{FONT_WEIGHTS.map(weight => (
|
||||
<div key={weight.value} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox" // Using checkbox as requested, acting like radio here for simplicity or actual radio? User asked for checkboxes instead of radio.
|
||||
// But weight is mutually exclusive. Using check behavior where clicking one checks it and likely unchecks others if we want strict single selection, or just let standard radio behavior handle it but style it?
|
||||
// User said "instead of radio buttons use checkboxes".
|
||||
// I'll use input type="checkbox" but manage state to ensure single selection if needed, or just let opacity/style indicate selection.
|
||||
checked={profile.fontWeight === weight.value}
|
||||
onChange={() => setProfile({ ...profile, fontWeight: weight.value })}
|
||||
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
|
||||
/>
|
||||
<label onClick={() => setProfile({ ...profile, fontWeight: weight.value })} style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
{weight.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Preview */}
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
background: '#f9fafb'
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: profile.headlineFont || 'Inter',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
marginBottom: '0.5rem',
|
||||
color: '#111827'
|
||||
}}>
|
||||
Headline Preview
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: profile.bodyFont || 'Inter',
|
||||
fontWeight: parseInt(profile.fontWeight || '400'),
|
||||
fontSize: '0.95rem',
|
||||
color: '#374151',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
Body text preview. The quick brown fox jumps over the lazy dog. 1234567890.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user