diff --git a/package.json b/package.json index 818f19e..17140b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.8.7", + "version": "1.9.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/prisma/migrations/20260301_add_account_projects_cache_settings/migration.sql b/prisma/migrations/20260301_add_account_projects_cache_settings/migration.sql new file mode 100644 index 0000000..b8208f1 --- /dev/null +++ b/prisma/migrations/20260301_add_account_projects_cache_settings/migration.sql @@ -0,0 +1,62 @@ +-- User: add accountNumber (auto-incrementing unique identifier) +CREATE SEQUENCE IF NOT EXISTS "User_accountNumber_seq"; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "accountNumber" INTEGER NOT NULL DEFAULT nextval('"User_accountNumber_seq"'); +ALTER SEQUENCE "User_accountNumber_seq" OWNED BY "User"."accountNumber"; +-- Populate existing rows with sequential numbers +DO $$ +DECLARE + r RECORD; + counter INTEGER := 1; +BEGIN + FOR r IN SELECT id FROM "User" ORDER BY "createdAt" ASC LOOP + UPDATE "User" SET "accountNumber" = counter WHERE id = r.id; + counter := counter + 1; + END LOOP; + -- Set sequence to continue after the highest assigned number + PERFORM setval('"User_accountNumber_seq"', COALESCE((SELECT MAX("accountNumber") FROM "User"), 0)); +END $$; +CREATE UNIQUE INDEX IF NOT EXISTS "User_accountNumber_key" ON "User"("accountNumber"); + +-- User: add quick settings preferences +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showCompletedTasks" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showLines" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "startDayOffset" INTEGER NOT NULL DEFAULT -1; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "quoteSourceUrls" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- CachedCalendarEvent: add url, recurringEventId, isRecurring +ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "url" TEXT; +ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "recurringEventId" TEXT; +ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "isRecurring" BOOLEAN NOT NULL DEFAULT false; + +-- Task: add projectId +ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "projectId" TEXT; + +-- Project model +CREATE TABLE IF NOT EXISTS "Project" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "icon" TEXT, + "color" TEXT, + "description" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- Indexes +CREATE INDEX IF NOT EXISTS "Project_userId_idx" ON "Project"("userId"); +CREATE INDEX IF NOT EXISTS "Task_userId_projectId_idx" ON "Task"("userId", "projectId"); + +-- Foreign keys +DO $$ BEGIN + ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce83fef..07c47ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,7 @@ datasource db { model User { id String @id @default(cuid()) + accountNumber Int @unique @default(autoincrement()) email String @unique passwordHash String? name String? @@ -87,8 +88,13 @@ model User { yearFontSize String? @default("1.5rem") yearFontWeight String? @default("700") showTaskCheckboxes Boolean @default(false) + showCompletedTasks Boolean @default(true) + showLines Boolean @default(true) + startDayOffset Int @default(-1) + quoteSourceUrls String[] @default([]) emailVerificationCode String? accounts Account[] + projects Project[] cachedCalendarEvents CachedCalendarEvent[] calendarConnections CalendarConnection[] sessions Session[] @@ -159,10 +165,12 @@ model Task { lastSyncedAt DateTime? deletedAt DateTime? parentTaskId String? + projectId String? somedaySlotIndex Int? parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade) subTasks Task[] @relation("SubTasks") somedayList SomedayList? @relation(fields: [somedayListId], references: [id]) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, dayOfWeek]) @@ -170,6 +178,7 @@ model Task { @@index([userId, somedayListId]) @@index([userId, externalId]) @@index([userId, deletedAt]) + @@index([userId, projectId]) @@index([parentTaskId]) } @@ -213,9 +222,12 @@ model CachedCalendarEvent { calendarTitle String calendarColor String? title String - description String? - location String? - startDateTime DateTime? + description String? + location String? + url String? + recurringEventId String? + isRecurring Boolean @default(false) + startDateTime DateTime? startDate String? endDateTime DateTime? endDate String? @@ -244,3 +256,19 @@ model WeeklyGoal { @@unique([userId, weekStart]) @@index([userId]) } + +model Project { + id String @id @default(cuid()) + userId String + name String + icon String? + color String? + description String? + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tasks Task[] + + @@index([userId]) +} diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index c05f0bb..670f21b 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -14,7 +14,7 @@ export async function GET(request: NextRequest) { const session = await getServerSession(authOptions); const { searchParams } = new URL(request.url); const code = searchParams.get('code'); - const state = searchParams.get('state'); // User email passed from start route + const state = searchParams.get('state'); // User CUID passed from start route const appBaseUrl = process.env.NEXTAUTH_URL || request.url; @@ -22,16 +22,16 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl)); } - // Get user from session or state parameter - const userEmail = session?.user?.email || state; + // Get user by session ID or state parameter (which now contains CUID, not email) + const userId = (session?.user as any)?.id || state; - if (!userEmail) { + if (!userId) { return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl)); } - // Find user in database + // Find user in database by ID (works regardless of signup email) const user = await prisma.user.findUnique({ - where: { email: userEmail } + where: { id: userId } }); if (!user) { @@ -145,7 +145,7 @@ export async function GET(request: NextRequest) { userId: user.id, type: 'oauth', provider: 'google', - providerAccountId: userEmail, + providerAccountId: user.id, access_token: tokens.access_token || '', refresh_token: tokens.refresh_token || null, expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null, diff --git a/src/app/api/calendar/google/start/route.ts b/src/app/api/calendar/google/start/route.ts index 2fb8554..4ae812c 100644 --- a/src/app/api/calendar/google/start/route.ts +++ b/src/app/api/calendar/google/start/route.ts @@ -10,7 +10,8 @@ export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session?.user?.email) { + const userId = (session?.user as any)?.id; + if (!userId) { const baseUrl = process.env.NEXTAUTH_URL || request.url; return NextResponse.redirect(new URL('/auth/login', baseUrl)); } @@ -42,7 +43,7 @@ export async function GET(request: NextRequest) { 'https://www.googleapis.com/auth/tasks', ], prompt: 'consent', - state: session.user.email, // Pass user email to identify in callback + state: userId, // Pass user CUID to identify in callback (not email) }); // Redirect the user to Google's consent screen diff --git a/src/app/api/calendar/outlook/callback/route.ts b/src/app/api/calendar/outlook/callback/route.ts index d2df28d..b519c2f 100644 --- a/src/app/api/calendar/outlook/callback/route.ts +++ b/src/app/api/calendar/outlook/callback/route.ts @@ -36,9 +36,10 @@ export async function GET(request: NextRequest) { // Fetch user's calendars to store initial list const calendars = await getUserCalendars(accessToken); - const user = await prisma.user.findUnique({ - where: { email: session.user.email } - }); + const userId = (session.user as any).id; + const user = userId + ? await prisma.user.findUnique({ where: { id: userId } }) + : await prisma.user.findUnique({ where: { email: session.user.email } }); if (!user) { return NextResponse.redirect(new URL('/auth/login', baseUrl)); diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts index 2f844e2..f6fe96b 100644 --- a/src/app/api/calendar/sync/route.ts +++ b/src/app/api/calendar/sync/route.ts @@ -7,7 +7,8 @@ import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConn export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session?.user?.email) { + const userId = (session?.user as any)?.id; + if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -18,7 +19,7 @@ export async function POST(request: NextRequest) { const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000); const user = await prisma.user.findUnique({ - where: { email: session.user.email }, + where: { id: userId }, include: { calendarConnections: true }, }); diff --git a/src/app/api/fonts/route.ts b/src/app/api/fonts/route.ts new file mode 100644 index 0000000..773fc6f --- /dev/null +++ b/src/app/api/fonts/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from 'next/server'; + +const POPULAR_FONTS = [ + { name: "Inter", value: "Inter", category: "sans-serif" }, + { name: "Roboto", value: "Roboto", category: "sans-serif" }, + { name: "Open Sans", value: "Open Sans", category: "sans-serif" }, + { name: "Lato", value: "Lato", category: "sans-serif" }, + { name: "Montserrat", value: "Montserrat", category: "sans-serif" }, + { name: "Oswald", value: "Oswald", category: "sans-serif" }, + { name: "Raleway", value: "Raleway", category: "sans-serif" }, + { name: "Playfair Display", value: "Playfair Display", category: "serif" }, + { name: "Merriweather", value: "Merriweather", category: "serif" }, + { name: "Nunito", value: "Nunito", category: "sans-serif" }, + { name: "Dancing Script", value: "Dancing Script", category: "handwriting" }, + { name: "Pacifico", value: "Pacifico", category: "handwriting" }, + { name: "Poppins", value: "Poppins", category: "sans-serif" }, + { name: "Source Sans Pro", value: "Source Sans Pro", category: "sans-serif" }, + { name: "Ubuntu", value: "Ubuntu", category: "sans-serif" }, + { name: "Rubik", value: "Rubik", category: "sans-serif" }, + { name: "Work Sans", value: "Work Sans", category: "sans-serif" }, + { name: "Quicksand", value: "Quicksand", category: "sans-serif" }, + { name: "Josefin Sans", value: "Josefin Sans", category: "sans-serif" }, + { name: "Libre Baskerville", value: "Libre Baskerville", category: "serif" }, + { name: "Crimson Text", value: "Crimson Text", category: "serif" }, + { name: "Bitter", value: "Bitter", category: "serif" }, + { name: "Archivo", value: "Archivo", category: "sans-serif" }, + { name: "DM Sans", value: "DM Sans", category: "sans-serif" }, + { name: "Space Grotesk", value: "Space Grotesk", category: "sans-serif" }, + { name: "Outfit", value: "Outfit", category: "sans-serif" }, + { name: "Sora", value: "Sora", category: "sans-serif" }, + { name: "Caveat", value: "Caveat", category: "handwriting" }, + { name: "Comfortaa", value: "Comfortaa", category: "display" }, + { name: "Barlow", value: "Barlow", category: "sans-serif" }, + { name: "Karla", value: "Karla", category: "sans-serif" }, + { name: "Manrope", value: "Manrope", category: "sans-serif" }, + { name: "Lexend", value: "Lexend", category: "sans-serif" }, + { name: "Roboto Slab", value: "Roboto Slab", category: "serif" }, + { name: "PT Serif", value: "PT Serif", category: "serif" }, + { name: "Noto Sans", value: "Noto Sans", category: "sans-serif" }, + { name: "Fira Sans", value: "Fira Sans", category: "sans-serif" }, + { name: "IBM Plex Sans", value: "IBM Plex Sans", category: "sans-serif" }, + { name: "Cabin", value: "Cabin", category: "sans-serif" }, + { name: "Inconsolata", value: "Inconsolata", category: "monospace" }, +]; + +let cachedGoogleFonts: any[] | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q') || ''; + const apiKey = process.env.GOOGLE_FONTS_API_KEY; + + let fonts = POPULAR_FONTS; + + // Try Google Fonts API if key is configured + if (apiKey && (!cachedGoogleFonts || Date.now() - cacheTimestamp > CACHE_TTL)) { + try { + const res = await fetch( + `https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`, + { signal: AbortSignal.timeout(5000) } + ); + if (res.ok) { + const data = await res.json(); + cachedGoogleFonts = (data.items || []).map((f: any) => ({ + name: f.family, + value: f.family, + category: f.category, + })); + cacheTimestamp = Date.now(); + } + } catch { + // Keep fallback + } + } + + if (cachedGoogleFonts) { + fonts = cachedGoogleFonts; + } + + if (query) { + fonts = fonts.filter((f) => + f.name.toLowerCase().includes(query.toLowerCase()) + ); + } + + return NextResponse.json({ fonts: fonts.slice(0, 100) }); +} diff --git a/src/app/api/goal/route.ts b/src/app/api/goal/route.ts index 6f1dd57..cb09f41 100644 --- a/src/app/api/goal/route.ts +++ b/src/app/api/goal/route.ts @@ -109,6 +109,11 @@ export async function GET(req: Request) { } } +// POST delegates to PUT for client compatibility +export async function POST(req: Request) { + return PUT(req); +} + export async function PUT(req: Request) { try { const session = await getServerSession(authOptions); diff --git a/src/app/api/tasks/projects/route.ts b/src/app/api/tasks/projects/route.ts new file mode 100644 index 0000000..ea62be9 --- /dev/null +++ b/src/app/api/tasks/projects/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// GET - List all projects for authenticated user +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + + const projects = await prisma.project.findMany({ + where: { userId }, + orderBy: { order: 'asc' }, + include: { + _count: { + select: { tasks: true }, + }, + }, + }); + + return NextResponse.json({ projects }); + } catch (error) { + console.error('Error fetching projects:', error); + return NextResponse.json( + { error: 'Failed to fetch projects' }, + { status: 500 } + ); + } +} + +// POST - Create a new project +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const body = await request.json(); + + const { name, icon, color, description } = body; + + if (!name) { + return NextResponse.json( + { error: 'Project name is required' }, + { status: 400 } + ); + } + + // Set order to be after the last project + const lastProject = await prisma.project.findFirst({ + where: { userId }, + orderBy: { order: 'desc' }, + select: { order: true }, + }); + + const project = await prisma.project.create({ + data: { + name, + icon: icon || null, + color: color || null, + description: description || null, + order: (lastProject?.order ?? -1) + 1, + userId, + }, + }); + + return NextResponse.json({ project }); + } catch (error) { + console.error('Error creating project:', error); + return NextResponse.json( + { error: 'Failed to create project' }, + { status: 500 } + ); + } +} + +// PATCH - Update a project +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'Project ID is required' }, + { status: 400 } + ); + } + + // Validate ownership + const existingProject = await prisma.project.findFirst({ + where: { id, userId }, + }); + + if (!existingProject) { + return NextResponse.json( + { error: 'Project not found' }, + { status: 404 } + ); + } + + const body = await request.json(); + const { name, icon, color, description, order } = body; + + const project = await prisma.project.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(icon !== undefined && { icon: icon || null }), + ...(color !== undefined && { color: color || null }), + ...(description !== undefined && { description: description || null }), + ...(order !== undefined && { order: parseInt(order) }), + }, + }); + + return NextResponse.json({ project }); + } catch (error) { + console.error('Error updating project:', error); + return NextResponse.json( + { error: 'Failed to update project' }, + { status: 500 } + ); + } +} + +// DELETE - Delete a project (tasks remain, their projectId becomes null) +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'Project ID is required' }, + { status: 400 } + ); + } + + // Validate ownership + const existingProject = await prisma.project.findFirst({ + where: { id, userId }, + }); + + if (!existingProject) { + return NextResponse.json( + { error: 'Project not found' }, + { status: 404 } + ); + } + + // Nullify projectId on all tasks belonging to this project + await prisma.task.updateMany({ + where: { projectId: id }, + data: { projectId: null }, + }); + + await prisma.project.delete({ + where: { id }, + }); + + return NextResponse.json({ message: 'Project deleted' }); + } catch (error) { + console.error('Error deleting project:', error); + return NextResponse.json( + { error: 'Failed to delete project' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index dac5072..fb2478d 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -144,6 +144,9 @@ export async function GET(request: NextRequest) { where: includeDeleted ? {} : { deletedAt: null }, orderBy: { order: 'asc' }, }, + project: { + select: { id: true, name: true, icon: true, color: true }, + }, }, orderBy: [ { dayOfWeek: 'asc' }, @@ -195,7 +198,7 @@ export async function POST(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId, projectId } = body; let { isRolling } = body; const { isRecurring } = body; @@ -234,6 +237,7 @@ export async function POST(request: NextRequest) { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null, somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null, parentTaskId: parentTaskId || null, + ...(projectId !== undefined && { projectId: projectId || null }), }, }); @@ -266,7 +270,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json(); const { id } = body; - const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body; + const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId, projectId } = body; if (!id) { return NextResponse.json( @@ -351,7 +355,8 @@ export async function PATCH(request: NextRequest) { ...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }), ...(restore === true && { deletedAt: null }), ...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }), - ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }) + ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }), + ...(projectId !== undefined && { projectId: projectId || null }), }, }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index d3a9122..da76de1 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -78,6 +78,11 @@ export async function GET(request: NextRequest) { yearFontWeight: true, yearColor: true, dayHeaderGap: true, + showCompletedTasks: true, + showLines: true, + startDayOffset: true, + quoteSourceUrls: true, + accountNumber: true, createdAt: true } }); @@ -118,7 +123,8 @@ export async function PATCH(request: NextRequest) { hourLabelFormat, showSubHourSlots, allDayPosition, cwFontFamily, cwFontSize, cwFontWeight, cwColor, yearFontFamily, yearFontSize, yearFontWeight, yearColor, - showTaskCheckboxes, dayHeaderGap + showTaskCheckboxes, dayHeaderGap, + showCompletedTasks, showLines, startDayOffset, quoteSourceUrls } = body; const updateData: any = { @@ -187,6 +193,10 @@ export async function PATCH(request: NextRequest) { ...(yearFontWeight !== undefined && { yearFontWeight }), ...(yearColor !== undefined && { yearColor }), ...(dayHeaderGap !== undefined && { dayHeaderGap }), + ...(showCompletedTasks !== undefined && { showCompletedTasks }), + ...(showLines !== undefined && { showLines }), + ...(startDayOffset !== undefined && { startDayOffset }), + ...(quoteSourceUrls !== undefined && { quoteSourceUrls }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -263,6 +273,11 @@ export async function PATCH(request: NextRequest) { yearFontWeight: true, yearColor: true, dayHeaderGap: true, + showCompletedTasks: true, + showLines: true, + startDayOffset: true, + quoteSourceUrls: true, + accountNumber: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index 54412ff..4a5b55f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3263,6 +3263,12 @@ h3 { flex-shrink: 0; } + /* CRITICAL: Make all header sections visible on mobile (no hover on touch devices) */ + .weekly-header-controls { + opacity: 1 !important; + transition: none !important; + } + /* Fix overlap on mobile by removing absolute positioning */ .weekly-header .absolute { position: static !important; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5608dca..2cac93f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,13 +1,25 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { Providers } from './providers'; const inter = Inter({ subsets: ['latin'] }); +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + export const metadata: Metadata = { title: 'My Weekly To Do List', description: 'A simple, designy to-do app.', + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'My Weekly To Do List', + }, }; export default function RootLayout({ diff --git a/src/components/FontPicker.tsx b/src/components/FontPicker.tsx new file mode 100644 index 0000000..87bdcee --- /dev/null +++ b/src/components/FontPicker.tsx @@ -0,0 +1,190 @@ +"use client"; +import { useState, useEffect, useRef } from "react"; +import { ChevronDown, Search } from "lucide-react"; + +const POPULAR_FONTS = [ + "Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Oswald", + "Raleway", "Playfair Display", "Merriweather", "Nunito", + "Dancing Script", "Pacifico", "Poppins", "Source Sans Pro", + "Ubuntu", "Rubik", "Work Sans", "Quicksand", "Josefin Sans", + "Libre Baskerville", "Crimson Text", "Bitter", "Archivo", + "DM Sans", "Space Grotesk", "Outfit", "Sora", "Caveat", + "Comfortaa", "Barlow", "Karla", "Manrope", "Lexend", + "Roboto Slab", "PT Serif", "Noto Sans", "Fira Sans", + "IBM Plex Sans", "Cabin", "Inconsolata", +]; + +interface FontPickerProps { + value: string; + onChange: (fontName: string) => void; + darkMode?: boolean; +} + +export default function FontPicker({ value, onChange, darkMode }: FontPickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [allFonts, setAllFonts] = useState(POPULAR_FONTS); + const [loadedFonts, setLoadedFonts] = useState>(new Set(["Inter"])); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Try to fetch from Google Fonts API for extended list + useEffect(() => { + const fetchFonts = async () => { + try { + const res = await fetch("/api/fonts"); + if (res.ok) { + const data = await res.json(); + if (data.fonts?.length > 0) { + setAllFonts(data.fonts.map((f: any) => f.value || f.name || f)); + } + } + } catch { + // Keep popular fonts as fallback + } + }; + fetchFonts(); + }, []); + + // Load font for preview + const loadFont = (fontName: string) => { + if (loadedFonts.has(fontName) || fontName === "Inter") return; + const id = `font-preview-${fontName.replace(/\s+/g, "-")}`; + if (!document.getElementById(id)) { + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, "+")}:wght@400;700&display=swap`; + document.head.appendChild(link); + } + setLoadedFonts((prev) => new Set(prev).add(fontName)); + }; + + // Load selected font + useEffect(() => { + if (value) loadFont(value); + }, [value]); + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Focus input on open + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + const filtered = query + ? allFonts.filter((f) => f.toLowerCase().includes(query.toLowerCase())) + : allFonts; + + const bg = darkMode ? "#1f2937" : "#fff"; + const border = darkMode ? "#374151" : "#e5e7eb"; + const text = darkMode ? "#e5e7eb" : "#333"; + const hoverBg = darkMode ? "#374151" : "#f0f9ff"; + + return ( +
+ + {isOpen && ( +
+
+ + setQuery(e.target.value)} + style={{ + padding: "4px", + width: "100%", + border: "none", + outline: "none", + background: "transparent", + color: text, + fontSize: "0.8rem", + }} + /> +
+
+ {filtered.slice(0, 50).map((font) => { + loadFont(font); + return ( +
{ + onChange(font); + setIsOpen(false); + setQuery(""); + }} + onMouseEnter={() => loadFont(font)} + style={{ + padding: "6px 12px", + cursor: "pointer", + fontFamily: font, + fontSize: "0.85rem", + color: text, + background: font === value ? hoverBg : "transparent", + borderLeft: font === value ? "3px solid #0ea5e9" : "3px solid transparent", + }} + > + {font} +
+ ); + })} + {filtered.length === 0 && ( +
+ No fonts found +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/QuickSettingsSidebar.tsx b/src/components/QuickSettingsSidebar.tsx new file mode 100644 index 0000000..f7ca401 --- /dev/null +++ b/src/components/QuickSettingsSidebar.tsx @@ -0,0 +1,205 @@ +"use client"; +import { X, Type, Space, CheckSquare, Calendar, Minus } from "lucide-react"; + +interface QuickSettingsProps { + fontSize: string; + onFontSizeChange: (size: string) => void; + spacing: string; + onSpacingChange: (spacing: string) => void; + showCompleted: boolean; + onShowCompletedChange: (show: boolean) => void; + startDayOffset: number; + onStartDayOffsetChange: (offset: number) => void; + showLines: boolean; + onShowLinesChange: (show: boolean) => void; + isOpen: boolean; + onClose: () => void; + darkMode?: boolean; +} + +export default function QuickSettingsSidebar({ + fontSize, + onFontSizeChange, + spacing, + onSpacingChange, + showCompleted, + onShowCompletedChange, + startDayOffset, + onStartDayOffsetChange, + showLines, + onShowLinesChange, + isOpen, + onClose, + darkMode, +}: QuickSettingsProps) { + const bg = darkMode ? "#1f2937" : "#ffffff"; + const text = darkMode ? "#e5e7eb" : "#333333"; + const border = darkMode ? "#374151" : "#e5e7eb"; + const accent = "#0ea5e9"; + const btnBg = darkMode ? "#374151" : "#f3f4f6"; + const btnActiveBg = accent; + const btnActiveText = "#ffffff"; + const labelColor = darkMode ? "#9ca3af" : "#6b7280"; + + const sizes = ["S", "M", "L"]; + + const SegmentedButton = ({ + options, + value, + onChange, + }: { + options: string[]; + value: string; + onChange: (v: string) => void; + }) => ( +
+ {options.map((opt) => ( + + ))} +
+ ); + + const Toggle = ({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) => ( + + ); + + return ( + <> + {/* Backdrop */} + {isOpen && ( +
+ )} + {/* Panel */} +
+
+ {/* Header */} +
+ Quick Settings + +
+ + {/* Font Size */} +
+
+ + Font Size +
+ +
+ + {/* Spacing */} +
+
+ + Spacing +
+ +
+ + {/* Show Completed */} +
+
+ + Show Completed +
+ +
+ + {/* Start Day */} +
+
+ + Start View +
+ onStartDayOffsetChange(v === "Yesterday" ? -1 : 0)} + /> +
+ + {/* Show Lines */} +
+
+ + Show Lines +
+ +
+
+
+ + ); +} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index ae85436..f8c79a8 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1299,15 +1299,17 @@ export default function WeeklyView() { const saveGoal = async (newGoal: string) => { setGoal(newGoal); try { - await fetch("/api/goal", { - method: "POST", + const res = await fetch("/api/goal", { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weekStart: goalDateKey, - goal: newGoal, - scope: profile.goalScope || "week", + text: newGoal, }), }); + if (!res.ok) { + console.error("Goal save failed:", res.status); + } } catch (error) { console.error("Error saving goal:", error); } @@ -1434,8 +1436,13 @@ export default function WeeklyView() { setShowTimeGrid(data.user.showTimeGrid ?? true); } if (data.user.viewDays !== undefined) { - setViewDays(data.user.viewDays); savedViewDaysRef.current = data.user.viewDays; + // Re-apply responsive constraints after loading saved preference + const width = window.innerWidth; + if (width <= 480) setViewDays(1); + else if (width <= 768) setViewDays(3); + else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5)); + else setViewDays(data.user.viewDays); } if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration); @@ -2064,6 +2071,48 @@ export default function WeeklyView() { setCurrentWeekStart(d); }; + // Touch swipe navigation for mobile + useEffect(() => { + let touchStartX = 0; + let touchStartY = 0; + let touchEndX = 0; + let touchEndY = 0; + + const handleTouchStart = (e: TouchEvent) => { + touchStartX = e.changedTouches[0].screenX; + touchStartY = e.changedTouches[0].screenY; + }; + + const handleTouchEnd = (e: TouchEvent) => { + touchEndX = e.changedTouches[0].screenX; + touchEndY = e.changedTouches[0].screenY; + const diffX = touchEndX - touchStartX; + const diffY = touchEndY - touchStartY; + // Only trigger if horizontal swipe is dominant and > 80px + if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) { + if (diffX > 0) { + // Swipe right → go to previous day + goToPrevDay(); + } else { + // Swipe left → go to next day + goToNextDay(); + } + } + }; + + const container = document.querySelector('.weekly-container') as HTMLElement | null; + if (container) { + container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true }); + container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true }); + } + return () => { + if (container) { + container.removeEventListener('touchstart', handleTouchStart as EventListener); + container.removeEventListener('touchend', handleTouchEnd as EventListener); + } + }; + }, [currentWeekStart]); // Re-attach when week changes so closures are fresh + const executeImport = async (provider: "google" | "apple" | "outlook") => { setImportProvider(provider); setIsImportModalOpen(true); @@ -3593,7 +3642,7 @@ export default function WeeklyView() { {/* Refactored Header: Left, Center, Right */}
{/* LEFT SECTION: Slot Duration & Days to Show */} -
+
{/* Slot Duration */} {showTimeGrid && (
{/* RIGHT SECTION: Navigation & Tools */} -
+
{/* Undo/Redo */}