Fix header layout, add day/night switch, refine night mode styling

This commit is contained in:
mARTin 2026-02-12 20:14:12 +01:00
parent 7e4ca22e5b
commit e9a0a706b7
32 changed files with 4746 additions and 623 deletions

10
package-lock.json generated
View File

@ -15,6 +15,7 @@
"bcryptjs": "^3.0.3",
"date-fns": "^2.30.0",
"googleapis": "^170.1.0",
"lucide-react": "^0.563.0",
"next": "^14.0.0",
"next-auth": "^4.24.13",
"postcss-cli": "^11.0.1",
@ -7256,6 +7257,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
"integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",

View File

@ -27,6 +27,7 @@
"bcryptjs": "^3.0.3",
"date-fns": "^2.30.0",
"googleapis": "^170.1.0",
"lucide-react": "^0.563.0",
"next": "^14.0.0",
"next-auth": "^4.24.13",
"postcss-cli": "^11.0.1",

View File

@ -0,0 +1,40 @@
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isRolling" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "recurrenceEndDate" TIMESTAMP(3),
ADD COLUMN "recurrenceInterval" INTEGER,
ADD COLUMN "recurrenceTime" TEXT,
ADD COLUMN "recurrenceUnit" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "autoRolling" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "calendarEditMode" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "dateFormat" TEXT NOT NULL DEFAULT 'yyyy-MM-dd',
ADD COLUMN "endHour" INTEGER NOT NULL DEFAULT 22,
ADD COLUMN "focusTimerDuration" INTEGER NOT NULL DEFAULT 25,
ADD COLUMN "language" TEXT NOT NULL DEFAULT 'de',
ADD COLUMN "protectEventTimes" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "showNextTask" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "startHour" INTEGER NOT NULL DEFAULT 8,
ADD COLUMN "timeFormat" TEXT NOT NULL DEFAULT '24h';
-- CreateTable
CREATE TABLE "SomedayList" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SomedayList_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SomedayList_userId_idx" ON "SomedayList"("userId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SomedayList" ADD CONSTRAINT "SomedayList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -24,11 +24,18 @@ model User {
timezone String @default("UTC")
autoRolling Boolean @default(false)
protectEventTimes Boolean @default(false)
language String @default("en")
dateFormat String @default("MM/dd/yyyy")
timeFormat String @default("12h")
startHour Int @default(6)
endHour Int @default(22)
language String @default("de")
dateFormat String @default("yyyy-MM-dd")
timeFormat String @default("24h")
startHour Int @default(8)
endHour Int @default(18)
showNextTask Boolean @default(false)
calendarEditMode Boolean @default(false)
focusTimerDuration Int @default(25)
showTimeGrid Boolean @default(true)
cellDuration Int @default(30)
viewStyle String @default("grid")
fontSize String @default("M") // "S", "M", "L"
accounts Account[]
sessions Session[]
@ -87,6 +94,11 @@ model Task {
originalDate DateTime?
startTime String?
endTime String?
isRecurring Boolean @default(false)
recurrenceInterval Int? // Number of units between occurrences
recurrenceUnit String? // "days" or "weeks"
recurrenceTime String? // e.g. "09:00" - time for the recurring task
recurrenceEndDate DateTime? // Optional end date for recurrence
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../auth/[...nextauth]/route';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@ -98,7 +98,7 @@ export async function PATCH(request: NextRequest) {
// Delete a calendar connection
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession();
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json(

View File

@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { PrismaClient } from '@prisma/client';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';
const prisma = new PrismaClient();
// Helper to find connection by calendarId
async function findConnectionForCalendar(userId: string, calendarId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { calendarConnections: true }
});
if (!user) return null;
for (const conn of user.calendarConnections) {
if (conn.calendars && Array.isArray(conn.calendars)) {
const calendars = conn.calendars as any[];
if (calendars.some(c => c.id === calendarId)) {
return {
id: conn.id,
provider: conn.provider as 'google' | 'apple',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken || undefined,
expiresAt: conn.expiresAt || undefined,
calendars: conn.calendars
} as CalendarConnection;
}
}
}
return null;
}
// POST - Create event
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { calendarId, title, description, start, end, location } = body;
console.log('[API] Creating event:', { calendarId, title, start, end });
if (!calendarId || !title || !start || !end) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const userId = (session.user as any).id;
const connection = await findConnectionForCalendar(userId, calendarId);
if (!connection) {
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
}
const event = await createCalendarEvent(connection, calendarId, {
title,
description,
start,
end,
location
});
return NextResponse.json({ event });
} catch (error: any) {
console.error('Error creating event:', error);
return NextResponse.json({ error: error.message || 'Failed to create event' }, { status: 500 });
}
}
// PATCH - Update event
export async function PATCH(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { calendarId, eventId, title, description, start, end, location } = body;
console.log('[API] Updating event:', { calendarId, eventId, title });
if (!calendarId || !eventId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const userId = (session.user as any).id;
const connection = await findConnectionForCalendar(userId, calendarId);
if (!connection) {
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
}
const event = await updateCalendarEvent(connection, calendarId, eventId, {
title,
description,
start,
end,
location
});
return NextResponse.json({ event });
} catch (error: any) {
console.error('Error updating event:', error);
return NextResponse.json({ error: error.message || 'Failed to update event' }, { status: 500 });
}
}
// DELETE - Delete event
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const calendarId = searchParams.get('calendarId');
const eventId = searchParams.get('eventId');
console.log('[API] Deleting event:', { calendarId, eventId });
if (!calendarId || !eventId) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const userId = (session.user as any).id;
const connection = await findConnectionForCalendar(userId, calendarId);
if (!connection) {
return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 });
}
await deleteCalendarEvent(connection, calendarId, eventId);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Error deleting event:', error);
return NextResponse.json({ error: error.message || 'Failed to delete event' }, { status: 500 });
}
}

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { google } from 'googleapis';
import { PrismaClient } from '@prisma/client';
@ -8,7 +9,7 @@ const prisma = new PrismaClient();
// Google Calendar OAuth callback endpoint
export async function GET(request: NextRequest) {
try {
const session = await getServerSession();
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

View File

@ -1,11 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { google } from 'googleapis';
// Initiate Google Calendar OAuth flow
export async function GET(request: NextRequest) {
try {
const session = await getServerSession();
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url));
@ -28,8 +29,8 @@ export async function GET(request: NextRequest) {
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/calendar.events',
],
prompt: 'consent',
state: session.user.email, // Pass user email to identify in callback

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
import { getTokens, getUserCalendars } from '@/lib/outlook-calendar';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const error = searchParams.get('error');
if (error) {
console.error('Outlook OAuth error:', error);
return NextResponse.redirect(new URL('/?error=outlook_auth_failed', request.url));
}
if (!code) {
return NextResponse.redirect(new URL('/?error=no_code', request.url));
}
// Exchange code for tokens
const tokenData = await getTokens(code);
const accessToken = tokenData.access_token;
const refreshToken = tokenData.refresh_token;
const expiresIn = tokenData.expires_in;
// Fetch user's calendars to store initial list
const calendars = await getUserCalendars(accessToken);
const user = await prisma.user.findUnique({
where: { email: session.user.email }
});
if (!user) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
// Calculate expiry date
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
// Check for existing connection
const existingConnection = await prisma.calendarConnection.findFirst({
where: {
userId: user.id,
provider: 'outlook'
}
});
const calendarData = calendars.map(cal => ({
id: cal.id,
title: cal.name,
isPrimary: cal.isDefaultCalendar,
selected: true,
editable: cal.canEdit
}));
if (existingConnection) {
await prisma.calendarConnection.update({
where: { id: existingConnection.id },
data: {
accessToken,
refreshToken,
expiresAt,
calendars: calendarData,
updatedAt: new Date()
}
});
} else {
// Create new connection
await prisma.calendarConnection.create({
data: {
userId: user.id,
provider: 'outlook',
accessToken,
refreshToken,
expiresAt,
calendars: calendarData,
}
});
}
return NextResponse.redirect(new URL('/tasks?calendar=connected', request.url));
} catch (error) {
console.error('Error in Outlook callback:', error);
return NextResponse.redirect(new URL('/auth/login?error=outlook_callback_failed', request.url));
}
}

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { getAuthUrl } from '@/lib/outlook-calendar';
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url));
}
const authUrl = getAuthUrl();
return NextResponse.redirect(authUrl);
} catch (error) {
console.error('Error initiating Outlook OAuth:', error);
return NextResponse.json(
{ error: 'Failed to initiate Outlook Calendar connection. Check server logs.' },
{ status: 500 }
);
}
}

View File

@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
// Map to CalendarConnection interface
const calendarConnections: CalendarConnection[] = connections.map(conn => ({
id: conn.id,
provider: conn.provider as 'google' | 'apple',
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken || undefined,
expiresAt: conn.expiresAt || undefined,

View File

@ -87,8 +87,8 @@ 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, startTime, scheduledDate } = body;
let { isRolling } = body;
const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
let { isRolling, isRecurring } = body;
if (!title) {
return NextResponse.json(
@ -117,7 +117,12 @@ export async function POST(request: NextRequest) {
userId,
startTime: startTime || null,
scheduledDate: scheduledDate ? new Date(scheduledDate) : null,
isRolling: isRolling || false
isRolling: isRolling || false,
isRecurring: isRecurring || false,
recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null,
recurrenceUnit,
recurrenceTime,
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null
},
});
@ -146,7 +151,7 @@ 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 } = body;
const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
if (!id) {
return NextResponse.json(
@ -178,10 +183,72 @@ export async function PATCH(request: NextRequest) {
...(markdownContent !== undefined && { markdownContent }),
...(scheduledDate !== undefined && { scheduledDate: scheduledDate ? new Date(scheduledDate) : null }),
...(startTime !== undefined && { startTime }),
...(body.isRolling !== undefined && { isRolling: body.isRolling })
...(body.isRolling !== undefined && { isRolling: body.isRolling }),
...(somedayListId !== undefined && { somedayListId: somedayListId || null }),
...(isRecurring !== undefined && { isRecurring }),
...(recurrenceInterval !== undefined && { recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null }),
...(recurrenceUnit !== undefined && { recurrenceUnit }),
...(recurrenceTime !== undefined && { recurrenceTime }),
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null })
},
});
// 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.
}
}
return NextResponse.json({ task });
} catch (error) {
console.error('Error updating task:', error);

View File

@ -1,51 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '../../auth/[...nextauth]/route';
import { PrismaClient } from '@prisma/client';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(request.url);
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
if (!session || !session.user?.email) {
return new NextResponse('Unauthorized', { status: 401 });
}
try {
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
tasks: true,
calendarConnections: true,
accounts: true
}
});
if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
if (!user) {
return new NextResponse('User not found', { status: 404 });
}
// Sanitize
const exportData = {
profile: {
name: user.name,
email: user.email,
joined: user.createdAt,
timezone: user.timezone
},
tasks: user.tasks,
connections: user.calendarConnections.map(c => ({
provider: c.provider,
connectedAt: c.createdAt,
calendars: c.calendars
}))
const where: any = {
userId: user.id,
completed: true,
};
// Return as file download
return new NextResponse(JSON.stringify(exportData, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="data-export-${new Date().toISOString().split('T')[0]}.json"`
if (startDate || endDate) {
where.updatedAt = {};
if (startDate) where.updatedAt.gte = new Date(startDate);
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
where.updatedAt.lte = end;
}
}
// Fetch filtered tasks
const tasks = await prisma.task.findMany({
where,
orderBy: {
updatedAt: 'desc',
},
});
// Generate CSV
const headers = ['Title', 'Description', 'Completed Date', 'Created Date'];
const rows = tasks.map((task: any) => [
task.title,
task.description || '',
task.updatedAt.toISOString(),
task.createdAt.toISOString(),
]);
const csvContent = [
headers.join(','),
...rows.map((row: string[]) => row.map((cell: string) => `"${(cell || '').replace(/"/g, '""')}"`).join(','))
].join('\n');
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="completed_tasks_${new Date().toISOString().split('T')[0]}.csv"`,
},
});
} catch (error) {
console.error('Export failed:', error);
return NextResponse.json({ error: 'Export failed' }, { status: 500 });
console.error('Export error:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@ -24,6 +24,13 @@ export async function GET(request: NextRequest) {
timeFormat: true,
startHour: true,
endHour: true,
showNextTask: true,
calendarEditMode: true,
focusTimerDuration: true,
showTimeGrid: true,
cellDuration: true,
viewStyle: true,
fontSize: true,
createdAt: true
}
});
@ -45,7 +52,12 @@ export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { name, timezone, password, autoRolling, protectEventTimes, language, dateFormat, timeFormat, startHour, endHour } = body;
const {
name, timezone, password, autoRolling, protectEventTimes,
language, dateFormat, timeFormat, startHour, endHour,
showNextTask, calendarEditMode, focusTimerDuration,
showTimeGrid, cellDuration, viewStyle, fontSize
} = body;
const updateData: any = {
...(name !== undefined && { name }),
@ -55,10 +67,17 @@ export async function PATCH(request: NextRequest) {
...(language !== undefined && { language }),
...(dateFormat !== undefined && { dateFormat }),
...(timeFormat !== undefined && { timeFormat }),
...(startHour !== undefined && { startHour }),
...(endHour !== undefined && { endHour }),
...(startHour !== undefined && !isNaN(startHour) && { startHour }),
...(endHour !== undefined && !isNaN(endHour) && { endHour }),
...(showNextTask !== undefined && { showNextTask }),
...(calendarEditMode !== undefined && { calendarEditMode }),
...(focusTimerDuration !== undefined && !isNaN(focusTimerDuration) && { focusTimerDuration }),
...(showTimeGrid !== undefined && { showTimeGrid }),
...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }),
...(viewStyle !== undefined && { viewStyle }),
...(fontSize !== undefined && { fontSize }),
};
if (password) {
if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10);
}
@ -77,6 +96,13 @@ export async function PATCH(request: NextRequest) {
timeFormat: true,
startHour: true,
endHour: true,
showNextTask: true,
calendarEditMode: true,
focusTimerDuration: true,
showTimeGrid: true,
cellDuration: true,
viewStyle: true,
fontSize: true,
}
});

View File

@ -671,7 +671,7 @@ h3 {
.weekly-task-text {
flex: 1;
font-size: 0.9375rem;
font-size: var(--base-font-size);
line-height: 1.5;
color: var(--weekly-text);
border: none;
@ -903,63 +903,285 @@ h3 {
color: var(--weekly-text);
}
/* Someday Lists Container */
/* Someday Lists Container */
.weekly-someday-lists {
/* Someday Lists Container - Horizontal Scroll */
.weekly-someday-lists-grid {
display: flex;
flex-wrap: nowrap; /* Prevent wrapping */
gap: 0;
max-height: 400px; /* Keep height constraint */
overflow-x: auto; /* Enable horizontal scrolling */
overflow-y: hidden; /* Hide vertical scroll on container */
border-top: 1px dashed #ccc;
padding-bottom: 1rem; /* Space for scrollbar */
flex-direction: row;
flex-wrap: nowrap;
gap: 1rem;
border-top: 1px solid var(--weekly-border);
width: 100%;
overflow-x: auto;
overflow-y: hidden; /* Prevent vertical Scrollbar on container */
padding-bottom: 0.5rem; /* Space for scrollbar */
align-items: flex-start;
-webkit-overflow-scrolling: touch;
}
/* Scrollbar Styling for Someday Container */
.weekly-someday-lists-grid::-webkit-scrollbar {
height: 8px;
}
.weekly-someday-lists-grid::-webkit-scrollbar-track {
background: transparent;
}
.weekly-someday-lists-grid::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.weekly-someday-lists-grid::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.2);
}
/* Remove grid column classes as we are using flex now */
.weekly-someday-lists-grid.cols-1,
.weekly-someday-lists-grid.cols-2,
.weekly-someday-lists-grid.cols-3,
.weekly-someday-lists-grid.cols-4,
.weekly-someday-lists-grid.cols-5,
.weekly-someday-lists-grid.cols-6,
.weekly-someday-lists-grid.cols-7 {
/* No specfic grid columns, let flex handle it */
grid-template-columns: none;
}
/* Old container class - deprecated or unused now? Keeping just in case */
.weekly-someday-lists {
display: none;
}
/* Ruled paper lines for someday tasks */
.weekly-someday-list {
border-right: 1px dashed #ccc;
padding: 1rem;
/* border-right: 1px solid var(--weekly-border); Removed for cleaner look */
padding: 0;
min-height: 200px;
width: 260px; /* Fixed width for columns */
min-width: 260px;
flex-shrink: 0;
flex: 0 0 280px; /* Fixed width for horizontal scrolling */
width: 280px;
max-width: 100%;
display: flex;
flex-direction: column;
overflow-y: auto; /* Allow individual list scrolling if needed */
overflow-y: auto;
max-height: 380px;
background-image: repeating-linear-gradient(
transparent,
transparent 31px,
var(--weekly-border) 31px,
var(--weekly-border) 32px
);
background-attachment: local;
background-position: 0 40px; /* Offset for the header */
}
/* Placeholder styling */
.weekly-someday-list.placeholder-list {
background-image: repeating-linear-gradient(
transparent,
transparent 31px,
#f5f5f5 31px,
#f5f5f5 32px
);
}
.weekly-someday-list:last-child {
border-right: 1px dashed #ccc; /* Keep border for consistency */
border-right: none;
}
.weekly-someday-list-title {
font-size: 0.9rem;
font-weight: 700;
text-transform: uppercase;
color: #d12028; /* TeuxDeux Red-ish */
margin-bottom: 0.75rem;
cursor: pointer;
border-bottom: 2px solid transparent;
display: inline-block;
.weekly-someday-list.is-dragging {
opacity: 0.4;
background-color: #f0f0f0;
}
.weekly-someday-list-title:hover {
border-bottom-color: #eee;
.weekly-someday-list .weekly-task-item {
border-bottom: 1px solid transparent;
height: 32px;
padding: 0 1rem;
display: flex;
align-items: center;
}
.weekly-someday-list .weekly-task-text {
font-size: var(--base-font-size);
line-height: 32px;
max-height: 32px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0;
}
.weekly-someday-list-title-header {
padding: 0.75rem 1rem 0.25rem;
height: 40px;
display: flex;
align-items: center;
}
.weekly-someday-list-title-input {
font-size: 0.9rem;
font-size: 1rem;
font-weight: 700;
text-transform: uppercase;
color: #d12028;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
color: var(--weekly-text, #333);
background: transparent;
border: none;
border-bottom: 1px solid #d12028;
border-bottom: 1px solid transparent;
width: 100%;
padding: 0;
padding: 2px 0;
outline: none;
transition: border-color 0.15s ease;
}
.weekly-someday .weekly-task-item {
padding-bottom: 0;
margin-bottom: 0;
}
/* Someday task item divider lines */
.weekly-someday .weekly-task-item {
padding-bottom: 0;
margin-bottom: 0;
}
/* Someday add task button */
.someday-add-task-btn {
background: none;
border: none;
color: #aaa;
cursor: pointer;
font-size: 0.85rem;
padding: 4px 0;
text-align: left;
width: 100%;
transition: color 0.15s ease;
}
.someday-add-task-btn:hover {
color: #666;
}
/* Preferences Slide-in Panel */
.preferences-panel {
position: fixed;
top: 0;
right: 0;
width: 280px;
height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
z-index: 2000;
transform: translateX(100%);
transition: transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1);
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
.preferences-panel.open {
transform: translateX(0);
}
.preferences-panel-content {
flex: 1;
overflow-y: auto;
padding: 2rem 1.5rem;
}
.preferences-overlay {
position: fixed;
inset: 0;
z-index: 1999;
background: rgba(0,0,0,0.15);
}
.pref-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.pref-label {
font-size: 0.9rem;
color: #ccc;
}
.pref-options {
display: flex;
gap: 4px;
align-items: center;
}
.pref-option-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #aaa;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.pref-option-btn:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.pref-option-btn.active {
background: rgba(255,255,255,0.2);
color: #fff;
border-color: rgba(255,255,255,0.3);
}
.pref-toggle {
background: none;
border: none;
color: #666;
font-size: 1.2rem;
cursor: pointer;
padding: 2px 6px;
transition: color 0.15s ease;
}
.pref-toggle.active {
color: #4dd0e1;
}
.pref-full-settings-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #ccc;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
width: 100%;
text-align: center;
transition: all 0.15s ease;
}
.pref-full-settings-btn:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.preferences-close-btn {
background: none;
border: none;
color: #888;
font-size: 1.2rem;
padding: 1rem;
cursor: pointer;
text-align: center;
transition: color 0.15s ease;
}
.preferences-close-btn:hover {
color: #fff;
}
/* Weekly Footer */
@ -1132,23 +1354,7 @@ h3 {
TIME GRID STYLES
============================================ */
.time-grid-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1.5rem;
background: var(--weekly-bg);
border-bottom: 1px solid var(--weekly-border);
font-size: 0.875rem;
}
.time-grid-controls label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--weekly-text-light);
cursor: pointer;
}
/* .time-grid-controls removed, using Tailwind classes */
.time-grid-controls input[type="checkbox"] {
accent-color: var(--weekly-teal);
@ -1222,7 +1428,8 @@ h3 {
.time-slot {
position: relative;
transition: background-color 0.15s ease;
/* Transition for theme switching */
transition: background-color 0.3s ease, color 0.3s ease;
padding: 0px 8px;
display: flex;
flex-direction: column;
@ -1598,6 +1805,42 @@ h3 {
padding: 4px;
}
/* Clickable lock/unlock button on calendar events */
.event-unlock-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 2px;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.event-unlock-btn:hover {
opacity: 1;
transform: scale(1.15);
}
/* All-day chevron positioning */
.all-day-chevron {
position: absolute;
top: 0.25rem;
right: 1rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
color: #666;
padding: 0.25rem;
transition: color 0.15s ease;
}
.all-day-chevron:hover {
color: var(--weekly-text);
}
/* ============================================
AUTH PAGES STYLES (Weekly-style)
============================================ */
@ -2015,28 +2258,38 @@ h3 {
}
::view-transition-group(week-grid) {
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1);
}
/* Next Week: Old slides Left, New enters from Right */
/* Next (forward): Old slides Left, New enters from Right */
[data-transition-direction="next"]::view-transition-old(week-grid) {
animation: slideOutToLeft 0.5s ease-in-out both;
animation: slideOutToLeft 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both;
mix-blend-mode: normal;
}
[data-transition-direction="next"]::view-transition-new(week-grid) {
animation: slideInFromRight 0.5s ease-in-out both;
animation: slideInFromRight 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both;
mix-blend-mode: normal;
}
/* Prev Week: Old slides Right, New enters from Left */
/* Prev (backward): Old slides Right, New enters from Left */
[data-transition-direction="prev"]::view-transition-old(week-grid) {
animation: slideOutToRight 0.5s ease-in-out both;
animation: slideOutToRight 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both;
mix-blend-mode: normal;
}
[data-transition-direction="prev"]::view-transition-new(week-grid) {
animation: slideInFromLeft 0.5s ease-in-out both;
animation: slideInFromLeft 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both;
mix-blend-mode: normal;
}
}/* Smooth View Transitions */
::view-transition-group(root) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root),
::view-transition-new(root) {
/* Ensure they mix/cross-fade or slide as expected */
/* Default is usually fine, but duration control is key */
}

View File

@ -0,0 +1,11 @@
/* Smooth View Transitions */
::view-transition-group(root) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(root),
::view-transition-new(root) {
/* Ensure they mix/cross-fade or slide as expected */
/* Default is usually fine, but duration control is key */
}

View File

@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
interface CalendarEventModalProps {
event?: any; // Existing event if editing
initialDate?: Date; // If creating new
initialStartTime?: string; // If creating new from slot
connections: any[]; // To select calendar
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string) => Promise<void>;
}
export default function CalendarEventModal({
event,
initialDate,
initialStartTime,
connections,
onClose,
onSave,
onDelete
}: CalendarEventModalProps) {
// Flatten calendars from connections to get selectable options
const availableCalendars = connections
.flatMap(conn => conn.calendars || [])
.filter((cal: any) => cal.editable); // Only editable calendars
const [title, setTitle] = useState(event?.title || '');
const [description, setDescription] = useState(event?.description || '');
const [location, setLocation] = useState(event?.location || '');
const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : ''));
// Date/Time State
// If event exists, use its start/end.
// If new, use initialDate + initialStartTime.
// Default duration: 1 hour.
const getInitialStart = () => {
if (event?.start?.dateTime) return new Date(event.start.dateTime);
if (initialDate) {
const d = new Date(initialDate);
if (initialStartTime) {
const [h, m] = initialStartTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
} else {
// Default to next hour if no time specified (though usually slot click gives time)
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
}
return d;
}
return new Date();
};
const getInitialEnd = () => {
if (event?.end?.dateTime) return new Date(event.end.dateTime);
const start = getInitialStart();
return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour
};
const [startDate, setStartDate] = useState(getInitialStart());
const [endDate, setEndDate] = useState(getInitialEnd());
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!title.trim()) {
setError('Title is required');
return;
}
if (!calendarId) {
setError('Please select a calendar');
return;
}
if (endDate <= startDate) {
setError('End time must be after start time');
return;
}
setIsSaving(true);
setError('');
try {
await onSave({
id: event?.id,
title,
description,
location,
calendarId,
start: { dateTime: startDate.toISOString() },
end: { dateTime: endDate.toISOString() }
});
onClose();
} catch (err: any) {
console.error(err);
setError(err.message || 'Failed to save event');
setIsSaving(false);
}
};
const [isDeleteConfirming, setIsDeleteConfirming] = useState(false);
const handleDelete = async () => {
if (!event?.id || !onDelete) return;
if (!isDeleteConfirming) {
setIsDeleteConfirming(true);
setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds
return;
}
setIsSaving(true);
try {
await onDelete(event.id, event.calendarId);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to delete event');
setIsSaving(false);
setIsDeleteConfirming(false);
}
};
// Helper to format date for input type="datetime-local"
// Format: YYYY-MM-DDThh:mm
const toLocalISOString = (date: Date) => {
const offset = date.getTimezoneOffset() * 60000;
const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16);
return localISOTime;
};
const handleStartDateChange = (val: string) => {
const newStart = new Date(val);
setStartDate(newStart);
// Auto-adjust end date if it becomes before start
if (endDate <= newStart) {
setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000));
}
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '500px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>
{event ? 'Edit Event' : 'New Event'}
</h3>
{error && <div style={{ color: 'red', marginBottom: '1rem' }}>{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{/* Title */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Title</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Event Title"
autoFocus
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }}
/>
</div>
{/* Calendar Selection */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Calendar</label>
<select
value={calendarId}
onChange={e => setCalendarId(e.target.value)}
disabled={!!event} // Usually can't move events between calendars easily in basic implementation
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{availableCalendars.length === 0 && <option value="">No editable calendars</option>}
{availableCalendars.map((cal: any) => (
<option key={cal.id} value={cal.id}>{cal.summary || cal.title}</option>
))}
</select>
</div>
{/* Date/Time */}
<div style={{ display: 'flex', gap: '15px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Start</label>
<input
type="datetime-local"
value={toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>End</label>
<input
type="datetime-local"
value={toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
</div>
{/* Location */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Location</label>
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
placeholder="Location (optional)"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{/* Description */}
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '0.9rem', color: '#666' }}>Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Notes..."
rows={3}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', resize: 'vertical' }}
/>
</div>
</div>
<div className="weekly-modal-actions" style={{ marginTop: '2rem', display: 'flex', justifyContent: 'space-between' }}>
<div>
{event && onDelete && (
<button
onClick={handleDelete}
disabled={isSaving}
style={{
padding: '10px 20px',
background: isDeleteConfirming ? '#d32f2f' : 'transparent',
color: isDeleteConfirming ? 'white' : '#d32f2f',
border: '1px solid #d32f2f',
borderRadius: '4px',
fontSize: '1rem',
cursor: 'pointer',
transition: 'all 0.2s',
width: isDeleteConfirming ? 'auto' : 'initial' // Expand if needed
}}
>
{isDeleteConfirming ? 'Click again to confirm delete' : 'Delete'}
</button>
)}
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="weekly-btn weekly-btn-secondary" onClick={onClose} disabled={isSaving}>Cancel</button>
<button className="weekly-btn weekly-btn-primary" onClick={handleSubmit} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,338 @@
import React, { useState, useEffect, useRef } from 'react';
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isSameMonth,
isSameDay,
setMonth,
setYear,
getYear,
getMonth
} from 'date-fns';
import { enUS, de } from 'date-fns/locale';
interface DatePickerProps {
selected: Date;
onSelect: (date: Date) => void;
onClose: () => void;
language?: string;
}
export default function DatePicker({ selected, onSelect, onClose, language = 'en' }: DatePickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date(selected));
const [view, setView] = useState<'calendar' | 'month-year'>('calendar');
const modalRef = useRef<HTMLDivElement>(null);
const locale = language === 'de' ? de : enUS;
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
// Calendar Header
const renderHeader = () => {
return (
<div className="datepicker-header">
<button onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div
className="datepicker-current-month"
onClick={() => setView(view === 'calendar' ? 'month-year' : 'calendar')}
>
{format(currentMonth, 'MMMM yyyy', { locale })}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '6px', opacity: 0.5 }}>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<button onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
);
};
// Days of Week Header
const renderDays = () => {
const days = [];
let startDate = startOfWeek(currentMonth, { weekStartsOn: 1 }); // Monday start
for (let i = 0; i < 7; i++) {
days.push(
<div className="datepicker-day-name" key={i}>
{format(addDays(startDate, i), 'EEEEEE', { locale })}
</div>
);
}
return <div className="datepicker-days-row">{days}</div>;
};
// Calendar Cells
const renderCells = () => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const dateFormat = 'd';
const rows = [];
let days = [];
let day = startDate;
let formattedDate = '';
while (day <= endDate) {
for (let i = 0; i < 7; i++) {
formattedDate = format(day, dateFormat);
const cloneDay = day;
days.push(
<div
className={`datepicker-cell ${!isSameMonth(day, monthStart)
? 'disabled'
: isSameDay(day, selected)
? 'selected'
: ''
} ${isSameDay(day, new Date()) ? 'today' : ''}`}
key={day.toString()}
onClick={() => {
onSelect(cloneDay);
onClose();
}}
>
<span className="number">{formattedDate}</span>
</div>
);
day = addDays(day, 1);
}
rows.push(
<div className="datepicker-row" key={day.toString()}>
{days}
</div>
);
days = [];
}
return <div className="datepicker-body">{rows}</div>;
};
// Month/Year Selection View
const renderMonthYearSelector = () => {
const years = [];
const currentYear = getYear(new Date());
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y);
}
const months = Array.from({ length: 12 }, (_, i) => {
return format(setMonth(new Date(), i), 'MMM', { locale });
});
return (
<div className="datepicker-month-year-view">
<div className="datepicker-years">
{years.map(year => (
<div
key={year}
className={`datepicker-year-option ${getYear(currentMonth) === year ? 'selected' : ''}`}
onClick={() => setCurrentMonth(setYear(currentMonth, year))}
>
{year}
</div>
))}
</div>
<div className="datepicker-months">
{months.map((month, index) => (
<div
key={month}
className={`datepicker-month-option ${getMonth(currentMonth) === index ? 'selected' : ''}`}
onClick={() => {
setCurrentMonth(setMonth(currentMonth, index));
setView('calendar');
}}
>
{month}
</div>
))}
</div>
</div>
);
};
return (
<div className="datepicker-modal" ref={modalRef}>
{renderHeader()}
{view === 'calendar' ? (
<>
{renderDays()}
{renderCells()}
</>
) : (
renderMonthYearSelector()
)}
<style jsx>{`
.datepicker-modal {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: white;
border-radius: 8px;
border-bottom: 5px solid #00ceb0; /* TeuxDeuxish Teal/Green */
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
padding: 20px;
z-index: 1000;
width: 320px;
font-family: var(--font-inter, sans-serif);
user-select: none;
animation: fadeIn 0.2s ease-out;
}
.datepicker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.datepicker-header button {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: #999;
padding: 0 8px;
line-height: 1;
font-weight: 300;
}
.datepicker-header button:hover {
color: #333;
}
.datepicker-current-month {
font-weight: 800;
font-size: 1rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.datepicker-days-row {
display: grid;
grid-template-columns: repeat(7, 1fr); /* Force 7 columns */
margin-bottom: 12px;
justify-items: center; /* Center content */
}
.datepicker-day-name {
width: 40px;
text-align: center;
font-size: 0.85rem;
color: #000;
font-weight: 700;
}
.datepicker-body {
display: flex; /* Still flex col for rows, OR better yet, just one grid */
flex-direction: column;
gap: 4px;
}
/* Alternative: Use one big grid for body cells? */
/* But current implementation renders rows. Let's keep rows but make them grids. */
.datepicker-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0; /* Gap handled by padding or just empty space? Current had space-between */
justify-items: center;
}
/* Actually, space-between in flex meant they spread out. Grid 1fr means equal width. */
/* This is much better for alignment. */
.datepicker-cell {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
transition: all 0.1s ease;
}
.datepicker-cell:hover:not(.disabled) {
background-color: #f5f5f5;
}
.datepicker-cell.selected {
background-color: #f0f0f0;
color: #000;
font-weight: 900;
}
.datepicker-cell.today {
color: #00ceb0;
}
.datepicker-cell.disabled {
color: #e0e0e0;
pointer-events: none;
}
.datepicker-month-year-view {
display: flex;
flex-direction: column;
gap: 10px;
height: 280px;
}
.datepicker-years {
display: flex;
overflow-x: auto;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.datepicker-year-option {
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
font-weight: 500;
}
.datepicker-year-option.selected {
background: #000;
color: white;
}
.datepicker-months {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.datepicker-month-option {
padding: 12px;
text-align: center;
cursor: pointer;
border-radius: 4px;
font-weight: 600;
}
.datepicker-month-option:hover {
background: #f0f0f0;
}
.datepicker-month-option.selected {
background: #000;
color: white;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div >
);
}

View File

@ -0,0 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
interface Task {
id: string;
title: string;
completed: boolean;
}
interface FocusModeOverlayProps {
task: Task | null;
duration: number; // in minutes
onClose: () => void;
onComplete: (taskId: string) => void;
}
export default function FocusModeOverlay({ task, duration, onClose, onComplete }: FocusModeOverlayProps) {
const [timeLeft, setTimeLeft] = useState(duration * 60);
const [isActive, setIsActive] = useState(false);
const [isCompleting, setIsCompleting] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isActive && timeLeft > 0) {
timerRef.current = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
} else if (timeLeft === 0) {
if (timerRef.current) clearInterval(timerRef.current);
setIsActive(false);
// Play sound?
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isActive, timeLeft]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const handleToggleTimer = () => {
setIsActive(!isActive);
};
const handleReset = () => {
setIsActive(false);
setTimeLeft(duration * 60);
};
const handleComplete = async () => {
if (!task) return;
setIsCompleting(true);
// Small delay for animation
await new Promise(resolve => setTimeout(resolve, 500));
onComplete(task.id);
setIsCompleting(false);
handleReset(); // Reset timer for next task
};
// Calculate progress for circle
// Circumference = 2 * PI * r
// r = 120
const circumference = 2 * Math.PI * 120;
const progress = timeLeft / (duration * 60);
const dashoffset = circumference * (1 - progress);
return (
<div className="focus-overlay">
<button className="close-btn" onClick={onClose} title="Exit Focus Mode">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div className="focus-content">
<h2 className="focus-header">DO THIS NOW</h2>
{task ? (
<div className={`focus-task ${isCompleting ? 'completing' : ''}`}>
{task.title}
</div>
) : (
<div className="focus-task empty">
No tasks scheduled for today!
<div style={{ fontSize: '1rem', marginTop: '1rem', opacity: 0.6 }}>Time to relax or plan ahead.</div>
</div>
)}
<div className="timer-container">
<svg className="timer-svg" width="260" height="260">
<circle
className="timer-circle-bg"
stroke="#333"
strokeWidth="8"
fill="transparent"
r="120"
cx="130"
cy="130"
/>
<circle
className="timer-circle-fg"
stroke="white"
strokeWidth="8"
fill="transparent"
r="120"
cx="130"
cy="130"
style={{
strokeDasharray: circumference,
strokeDashoffset: dashoffset,
transition: 'stroke-dashoffset 1s linear'
}}
/>
</svg>
<div className="timer-text">{formatTime(timeLeft)}</div>
</div>
<div className="focus-controls">
<button className="focus-btn" onClick={handleToggleTimer}>
{isActive ? 'PAUSE' : 'START'}
</button>
<button className="focus-btn secondary" onClick={handleReset}>
RESET
</button>
{task && (
<button className="focus-btn success" onClick={handleComplete} disabled={isCompleting}>
{isCompleting ? 'COMPLETING...' : 'COMPLETE TASK'}
</button>
)}
</div>
</div>
<style jsx>{`
.focus-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a1a;
color: white;
z-index: 2000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-inter, sans-serif);
animation: fadeIn 0.3s ease-out;
}
.close-btn {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 10px;
border-radius: 50%;
transition: all 0.2s;
}
.close-btn:hover {
color: white;
background: rgba(255,255,255,0.1);
}
.focus-content {
text-align: center;
max-width: 600px;
width: 100%;
padding: 20px;
}
.focus-header {
font-size: 1rem;
letter-spacing: 0.2em;
color: #666;
margin-bottom: 2rem;
font-weight: 600;
}
.focus-task {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 3rem;
line-height: 1.2;
transition: all 0.5s ease;
}
.focus-task.empty {
font-size: 1.5rem;
font-weight: 500;
color: #999;
}
.focus-task.completing {
opacity: 0;
transform: scale(0.9);
}
.timer-container {
position: relative;
width: 260px;
height: 260px;
margin: 0 auto 3rem;
}
.timer-svg {
transform: rotate(-90deg);
}
.timer-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3rem;
font-family: monospace;
font-weight: 600;
}
.focus-controls {
display: flex;
justify-content: center;
gap: 15px;
}
.focus-btn {
background: white;
color: black;
border: none;
padding: 12px 24px;
font-size: 0.9rem;
font-weight: 600;
border-radius: 30px;
cursor: pointer;
min-width: 100px;
transition: transform 0.1s;
letter-spacing: 0.05em;
}
.focus-btn:hover {
transform: scale(1.05);
}
.focus-btn:active {
transform: scale(0.95);
}
.focus-btn.secondary {
background: transparent;
color: white;
border: 1px solid #666;
}
.focus-btn.secondary:hover {
border-color: white;
}
.focus-btn.success {
background: #009a9a; /* Weekly Teal */
color: white;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,106 @@
import React, { useState } from 'react';
interface RecurrenceModalProps {
task: any;
onClose: () => void;
onSave: (taskId: string, recurrence: any) => Promise<void>;
}
export default function RecurrenceModal({ task, onClose, onSave }: RecurrenceModalProps) {
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
const [interval, setInterval] = useState(task.recurrenceInterval || 1);
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
const [endDate, setEndDate] = useState(task.recurrenceEndDate ? new Date(task.recurrenceEndDate).toISOString().split('T')[0] : '');
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave(task.id, {
isRecurring,
recurrenceInterval: isRecurring ? interval : null,
recurrenceUnit: isRecurring ? unit : null,
recurrenceEndDate: isRecurring && endDate ? new Date(endDate) : null
});
onClose();
} catch (error) {
console.error('Failed to save recurrence', error);
setIsSaving(false);
}
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>Recurring Task</h3>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
<input
type="checkbox"
id="isRecurring"
checked={isRecurring}
onChange={e => setIsRecurring(e.target.checked)}
style={{ width: '18px', height: '18px', marginRight: '10px' }}
/>
<label htmlFor="isRecurring" style={{ fontSize: '1rem', fontWeight: 500 }}>Enable Recurrence</label>
</div>
{isRecurring && (
<div style={{ paddingLeft: '28px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>Repeat every</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="number"
min="1"
value={interval}
onChange={e => setInterval(parseInt(e.target.value) || 1)}
style={{ width: '60px', padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
<select
value={unit}
onChange={e => setUnit(e.target.value)}
style={{ flex: 1, padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '0.9rem', color: '#666' }}>End Date (Optional)</label>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
style={{ width: '100%', padding: '6px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
</div>
)}
</div>
<div className="weekly-modal-actions">
<button
className="weekly-btn weekly-btn-primary"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
className="weekly-btn weekly-btn-secondary"
onClick={onClose}
disabled={isSaving}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,98 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
interface RecurringTasksManagerProps {
isOpen: boolean;
onClose: () => void;
tasks: any[];
}
export default function RecurringTasksManager({ isOpen, onClose, tasks }: RecurringTasksManagerProps) {
if (!isOpen) return null;
// Filter tasks that are recurring
const recurringTasks = tasks.filter(t => t.isRecurring);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-2xl w-[800px] max-w-full h-[600px] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 flex items-center justify-between border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Recurring to-dos</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 font-bold text-xl">
&times;
</button>
</div>
<div className="flex-1 overflow-y-auto p-8">
{recurringTasks.length === 0 ? (
<div className="flex flex-col items-start max-w-lg">
<p className="text-gray-600 mb-6">
You don&apos;t have any recurring to-dos yet.
</p>
<button className="bg-[#333] text-white px-4 py-2 rounded text-sm font-medium flex items-center gap-2 hover:bg-black transition-colors mb-8">
<span>+</span> Recurring to-do
</button>
<p className="text-gray-800 font-medium mb-4">Or, type a frequency to the end of a to-do.</p>
<div className="bg-gray-100 p-8 rounded mb-8 w-full flex items-center justify-center">
<div className="bg-white p-6 shadow-sm rounded text-center w-64">
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">OCT 5, 2023</div>
<div className="text-xl font-bold text-gray-900 uppercase tracking-wider mb-4">THURSDAY</div>
<div className="h-6 w-1 bg-black animate-pulse mx-auto"></div>
</div>
</div>
<div className="grid grid-cols-2 gap-8 text-sm w-full">
<div>
<h4 className="italic text-gray-500 mb-2">Frequencies:</h4>
<ul className="text-gray-500 italic space-y-1">
<li>every day</li>
<li>every week</li>
<li>every other week</li>
<li>every month</li>
<li>every year</li>
</ul>
</div>
<div>
<h4 className="italic text-gray-500 mb-2">Examples:</h4>
<ul className="text-gray-500 italic space-y-1">
<li>Brush teeth every day</li>
<li>Management meeting every week</li>
<li>Piano lessons every other week</li>
<li>Pay rent every month</li>
<li>Garret&apos;s birthday every year</li>
</ul>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{recurringTasks.map(task => (
<div key={task.id} className="flex items-center justify-between p-4 bg-gray-50 rounded border border-gray-100">
<div>
<div className="font-medium text-gray-900">{task.title}</div>
<div className="text-xs text-gray-500 mt-1">
Repeats every {task.recurrenceInterval} {task.recurrenceUnit}
</div>
</div>
<button className="text-red-500 text-sm hover:underline">Stop Details</button>
</div>
))}
</div>
)}
</div>
<div className="p-4 border-t border-gray-100 flex justify-end">
<button
onClick={onClose}
className="w-10 h-10 bg-[#333] rounded-full flex items-center justify-center text-white hover:bg-black transition-colors text-xl font-bold"
>
+
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, useRef } from 'react';
import { format } from 'date-fns';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
tasks: any[];
events: any[];
onSelectTask: (date: Date) => void;
}
export default function SearchModal({ isOpen, onClose, tasks, events, onSelectTask }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<{ type: 'task' | 'event', item: any }[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const lowerQuery = query.toLowerCase();
const filteredTasks = tasks.filter(t =>
t.title.toLowerCase().includes(lowerQuery) && !t.somedayListId
).map(t => ({ type: 'task' as const, item: t }));
const filteredEvents = events.filter(e =>
e.title.toLowerCase().includes(lowerQuery)
).map(e => ({ type: 'event' as const, item: e }));
setResults([...filteredTasks, ...filteredEvents].slice(0, 10));
}, [query, tasks, events]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20" onClick={onClose}>
<div className="bg-white rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
ref={inputRef}
type="text"
placeholder="Search tasks and events..."
className="flex-1 text-lg outline-none text-gray-700 placeholder-gray-400"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Escape' && onClose()}
/>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{results.length > 0 ? (
<div className="py-2">
{results.map((result, idx) => (
<div
key={`${result.type}-${result.item.id}-${idx}`}
className="px-4 py-3 hover:bg-gray-50 cursor-pointer flex items-center gap-3 border-b border-gray-50 last:border-0"
onClick={() => {
const date = result.type === 'task'
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
: (new Date(result.item.startTime));
onSelectTask(date);
onClose();
}}
>
<div className={`p-2 rounded-full ${result.type === 'task' ? 'bg-blue-100 text-blue-600' : 'bg-teal-100 text-teal-600'}`}>
{result.type === 'task' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
)}
</div>
<div className="flex-1">
<div className="font-medium text-gray-800">{result.item.title}</div>
<div className="text-xs text-gray-500">
{format(
result.type === 'task'
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
: new Date(result.item.startTime),
'PPP'
)}
</div>
</div>
{result.type === 'task' && result.item.completed && (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">Completed</span>
)}
</div>
))}
</div>
) : query.trim() ? (
<div className="p-8 text-center text-gray-400">
No Item found.
</div>
) : (
<div className="p-8 text-center text-gray-400">
Start typing to search...
</div>
)}
</div>
</div>
<style jsx>{`
.slide-in-from-top-2 { animation: slideIn 0.2s ease-out; }
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}

View File

@ -0,0 +1,157 @@
import React, { useState, useEffect, useRef } from 'react';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay } from 'date-fns';
import { enUS, de } from 'date-fns/locale';
interface SimpleDatePickerProps {
selected: Date;
onSelect: (date: Date) => void;
onClose: () => void;
language?: string;
}
export default function SimpleDatePicker({ selected, onSelect, onClose, language = 'en' }: SimpleDatePickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date(selected));
const modalRef = useRef<HTMLDivElement>(null);
const locale = language === 'de' ? de : enUS;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
const renderHeader = () => (
<div className="datepicker-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', padding: '0 0.5rem' }}>
<button onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} style={{ padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', transition: 'color 0.2s' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div style={{ fontWeight: 'bold', fontSize: '0.875rem', letterSpacing: '0.05em', textTransform: 'uppercase', color: '#374151' }}>
{format(currentMonth, 'MMMM yyyy', { locale })}
</div>
<button onClick={() => setCurrentMonth(addMonths(currentMonth, 1))} style={{ padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', transition: 'color 0.2s' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
);
const renderDays = () => {
const days = [];
const startDate = startOfWeek(currentMonth, { weekStartsOn: 1 });
for (let i = 0; i < 7; i++) {
days.push(
<div key={i} style={{ textAlign: 'center', fontSize: '0.75rem', fontWeight: 'bold', color: '#9ca3af', padding: '0.5rem 0' }}>
{format(addDays(startDate, i), 'EEEEEE', { locale })}
</div>
);
}
return <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: '0.5rem' }}>{days}</div>;
};
const renderCells = () => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const cells = [];
let day = startDate;
while (day <= endDate) {
const row = [];
for (let i = 0; i < 7; i++) {
const cloneDay = day;
const isSelected = isSameDay(day, selected);
const isToday = isSameDay(day, new Date());
const isCurrentMonth = isSameMonth(day, monthStart);
const isDaySelected = isSelected;
row.push(
<div
key={day.toString()}
onClick={() => {
onSelect(cloneDay);
onClose();
}}
style={{
height: '2rem',
width: '2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
borderRadius: '50%',
cursor: 'pointer',
transition: 'all 0.2s',
margin: '0 auto',
color: !isCurrentMonth ? '#d1d5db' : isDaySelected ? 'white' : '#374151',
background: isDaySelected ? 'black' : 'transparent',
fontWeight: isDaySelected ? 'bold' : 'normal',
boxShadow: isDaySelected ? '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' : 'none',
transform: isDaySelected ? 'scale(1.1)' : 'none',
...(isToday && !isDaySelected ? { color: '#ef4444', fontWeight: 'bold' } : {})
}}
onMouseEnter={(e) => {
if (!isDaySelected) e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseLeave={(e) => {
if (!isDaySelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{format(day, 'd')}
</div>
);
day = addDays(day, 1);
}
cells.push(row);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '0.5rem 0' }}>
{cells.flat()}
</div>
);
};
return (
<div ref={modalRef} style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: '0.5rem',
backgroundColor: 'white',
borderRadius: '0.5rem',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
padding: '1rem',
zIndex: 50,
width: '18rem',
border: '1px solid #f3f4f6',
animation: 'fadeIn 0.15s ease-out'
}}>
{/* Decorative triangle */}
<div style={{
position: 'absolute',
top: '-0.3rem',
right: '1rem',
width: '0.75rem',
height: '0.75rem',
backgroundColor: 'white',
transform: 'rotate(45deg)',
borderTop: '1px solid #f3f4f6',
borderLeft: '1px solid #f3f4f6'
}}></div>
{renderHeader()}
{renderDays()}
{renderCells()}
<style jsx>{`
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}

View File

@ -16,9 +16,10 @@ interface TaskItemProps {
onDelete: (id: string) => void;
onEdit: (task: Task) => void;
onToggleRolling: (id: string) => void;
variant?: 'default' | 'minimal';
}
export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onToggleRolling }: TaskItemProps) {
export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onToggleRolling, variant = 'default' }: TaskItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState(task.title);
const [editedDescription, setEditedDescription] = useState(task.description || '');
@ -83,12 +84,15 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
);
}
const containerClass = variant === 'minimal'
? `flex items-center group py-1 border-b border-gray-100 hover:bg-gray-50 transition-colors ${isDeleting ? 'opacity-50' : ''}`
: `border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${isDeleting ? 'opacity-50' : 'hover:shadow-md'}`;
return (
<div className={`border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${isDeleting ? 'opacity-50' : 'hover:shadow-md'
}`}>
<div className="flex items-start">
<div className="flex items-center mr-3 mt-1 space-x-2">
{/* Rolling Toggle */}
<div className={containerClass}>
<div className={`flex items-start ${variant === 'minimal' ? 'w-full' : ''}`}>
<div className={`flex items-center mr-3 ${variant === 'minimal' ? '' : 'mt-1'} space-x-2`}>
{/* Rolling Toggle - hide in minimal unless hovered or active? Or keep it? TeuxDeux usually has verify simple. Keep it for now. */}
<button
onClick={() => onToggleRolling(task.id)}
className={`p-1 rounded-full hover:bg-gray-100 transition-colors ${task.isRolling ? 'text-blue-600' : 'text-gray-300'}`}
@ -108,9 +112,9 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
/>
</div>
<div className="flex-1">
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}>
<div className={`font-semibold ${variant === 'minimal' ? 'text-xs' : 'text-lg'} ${task.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}>
{task.title}
</h3>
</div>
{task.description && (
<p className={`mt-2 ${task.completed ? 'line-through text-gray-500' : 'text-gray-700'}`}>
{task.description}

View File

@ -0,0 +1,95 @@
import React, { useState, useRef, useEffect } from 'react';
import { signOut } from 'next-auth/react';
interface UserMenuProps {
userEmail?: string | null;
onOpenRecurring: () => void;
onOpenSettings: () => void;
trigger?: React.ReactNode;
}
export default function UserMenu({ userEmail, onOpenRecurring, onOpenSettings, trigger }: UserMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={menuRef}>
{trigger ? (
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
) : (
<button
onClick={() => setIsOpen(!isOpen)}
className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center hover:bg-gray-300 transition-colors"
title="User Menu"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-600">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</button>
)}
{isOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-xl border border-gray-100 py-1 z-50 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="px-4 py-2 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">Signed in as</p>
<p className="text-xs text-gray-500 truncate">{userEmail || 'User'}</p>
</div>
<button
onClick={() => { onOpenSettings(); setIsOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Settings
</button>
<button
onClick={() => { onOpenRecurring(); setIsOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Recurring To-Dos
</button>
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={() => signOut()}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-red-400">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Sign Out
</button>
</div>
)}
<style jsx>{`
.animate-in { animation: fadeIn 0.15s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
// Google Calendar OAuth wrapper
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents, initializeOAuth as initializeAppleOAuth } from './apple-calendar';
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@ -18,7 +19,7 @@ export interface CalendarEvent {
date?: string;
};
location?: string;
source: 'google' | 'apple';
source: 'google' | 'apple' | 'outlook';
calendarId: string;
calendarTitle: string;
backgroundColor?: string;
@ -45,7 +46,7 @@ function getGoogleEventColor(colorId: string): string {
export interface CalendarConnection {
id: string;
provider: 'google' | 'apple';
provider: 'google' | 'apple' | 'outlook';
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
@ -54,6 +55,7 @@ export interface CalendarConnection {
title: string;
isPrimary?: boolean;
selected?: boolean;
backgroundColor?: string;
}> | any; // Type it loosely for JSON compatibility
}
@ -98,6 +100,41 @@ async function refreshGoogleToken(connection: CalendarConnection): Promise<strin
return null;
}
/**
* Refresh the Outlook access token using the refresh token
*/
async function refreshOutlookToken(connection: CalendarConnection): Promise<string | null> {
if (!connection.refreshToken) {
console.log('[CALENDAR] No refresh token available for connection:', connection.id);
return null;
}
try {
console.log('[CALENDAR] Refreshing Outlook access token...');
const data = await refreshOutlookTokenAPI(connection.refreshToken);
if (data.access_token) {
// Update the token in the database
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in);
await prisma.calendarConnection.update({
where: { id: connection.id },
data: {
accessToken: data.access_token,
refreshToken: data.refresh_token || connection.refreshToken, // Update refresh token if provided
expiresAt
}
});
console.log('[CALENDAR] Outlook token refreshed successfully');
return data.access_token;
}
} catch (error) {
console.error('[CALENDAR] Failed to refresh Outlook token:', error);
}
return null;
}
/**
* Check if the token is expired or about to expire
*/
@ -233,6 +270,7 @@ export const getCalendarEvents = async (
}
}
} else if (connection.provider === 'apple') {
// ... Apple logic ...
// Initialize Apple OAuth client
const appleClient = initializeAppleOAuth(
process.env.APPLE_CLIENT_ID || '',
@ -260,6 +298,62 @@ export const getCalendarEvents = async (
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar'
})));
}
} else if (connection.provider === 'outlook') {
console.log('[CALENDAR] Processing Outlook connection:', connection.id);
if (isTokenExpired(connection.expiresAt)) {
console.log('[CALENDAR] Outlook token expired, attempting refresh...');
const newToken = await refreshOutlookToken(connection);
if (newToken) {
accessToken = newToken;
} else {
console.error('[CALENDAR] Failed to refresh Outlook token, skipping connection');
continue;
}
}
// Get calendars to fetch
let calendarIds: string[] = [];
let calendars: any[] = [];
if (connection.calendars && Array.isArray(connection.calendars)) {
calendars = connection.calendars as any[];
calendarIds = calendars
.filter((c: any) => c.selected !== false)
.map((c: any) => c.id);
}
if (calendarIds.length > 0) {
console.log('[CALENDAR] Fetching Outlook events from', calendarIds.length, 'calendars');
for (const calendarId of calendarIds) {
try {
const outlookEvents = await getOutlookEvents(
accessToken,
calendarId,
timeMin,
timeMax
);
const calendarData = calendars.find(c => c.id === calendarId);
events = events.concat(outlookEvents.map((event: any) => ({
id: event.id,
title: event.summary || '(No Title)',
description: event.description,
start: event.start,
end: event.end,
location: event.id === 'google' ? event.location : event.location,
source: 'outlook' as const,
calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar',
backgroundColor: '#0078d4' // Outlook Blue
})));
} catch (calError) {
console.error(`[CALENDAR] Error fetching Outlook events from ${calendarId}:`, calError);
}
}
}
}
console.log('[CALENDAR] Total events for connection:', events.length);
@ -358,4 +452,211 @@ export const determineEventVisibility = (
}
return true;
};
/**
* Create a new calendar event
*/
export const createCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
event: Partial<CalendarEvent>
): Promise<CalendarEvent> => {
if (connection.provider === 'google') {
// Check key fields
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
// Map to Google format
const googleEvent: any = {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
};
const createdEvent = await import('./google-calendar').then(m =>
m.createEvent(oauth2Client, accessToken, calendarId, googleEvent)
);
return {
id: createdEvent.id,
title: createdEvent.summary,
description: createdEvent.description,
start: createdEvent.start,
end: createdEvent.end,
location: createdEvent.location,
source: 'google',
calendarId,
calendarTitle: '', // We don't have this here, simpler to leave empty or fetch
} as CalendarEvent;
} else if (connection.provider === 'outlook') {
if (!event.title) throw new Error('Event title is required');
if (!event.start || !event.end) throw new Error('Event start and end times are required');
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const createdEvent = await createOutlookEvent(accessToken, calendarId, {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location
});
return {
id: createdEvent.id,
title: createdEvent.summary,
description: createdEvent.description,
start: createdEvent.start,
end: createdEvent.end,
location: createdEvent.location,
source: 'outlook',
calendarId,
calendarTitle: '',
} as CalendarEvent;
}
throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
};
/**
* Update an existing calendar event
*/
export const updateCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
eventId: string,
event: Partial<CalendarEvent>
): Promise<CalendarEvent> => {
if (connection.provider === 'google') {
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
// Map to Google format
const googleEvent: any = {};
if (event.title !== undefined) googleEvent.summary = event.title;
if (event.description !== undefined) googleEvent.description = event.description;
if (event.start !== undefined) googleEvent.start = event.start;
if (event.end !== undefined) googleEvent.end = event.end;
if (event.location !== undefined) googleEvent.location = event.location;
const updatedEvent = await import('./google-calendar').then(m =>
m.updateEvent(oauth2Client, accessToken, calendarId, eventId, googleEvent)
);
return {
id: updatedEvent.id,
title: updatedEvent.summary,
description: updatedEvent.description,
start: updatedEvent.start,
end: updatedEvent.end,
location: updatedEvent.location,
source: 'google',
calendarId,
calendarTitle: '',
} as CalendarEvent;
} else if (connection.provider === 'outlook') {
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const updatedEvent = await updateOutlookEvent(accessToken, calendarId, eventId, {
summary: event.title,
description: event.description,
start: event.start,
end: event.end,
location: event.location
});
return {
id: updatedEvent.id,
title: updatedEvent.summary,
description: updatedEvent.description,
start: updatedEvent.start,
end: updatedEvent.end,
location: updatedEvent.location,
source: 'outlook',
calendarId,
calendarTitle: '',
} as CalendarEvent;
}
throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
};
/**
* Delete a calendar event
*/
export const deleteCalendarEvent = async (
connection: CalendarConnection,
calendarId: string,
eventId: string
): Promise<void> => {
if (connection.provider === 'google') {
// Refresh token if needed
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshGoogleToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
const oauth2Client = initializeGoogleOAuth(
process.env.GOOGLE_CLIENT_ID || '',
process.env.GOOGLE_CLIENT_SECRET || '',
process.env.GOOGLE_REDIRECT_URI || ''
);
await import('./google-calendar').then(m =>
m.deleteEvent(oauth2Client, accessToken, calendarId, eventId)
);
return;
} else if (connection.provider === 'outlook') {
let accessToken = connection.accessToken;
if (isTokenExpired(connection.expiresAt)) {
const newToken = await refreshOutlookToken(connection);
if (newToken) accessToken = newToken;
else throw new Error('Failed to refresh token');
}
await deleteOutlookEvent(accessToken, calendarId, eventId);
return;
}
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
};

View File

@ -94,14 +94,8 @@ export const getUpcomingEvents = async (
id: item.id,
summary: item.summary,
description: item.description,
start: {
dateTime: item.start.dateTime,
date: item.start.date,
},
end: {
dateTime: item.end.dateTime,
date: item.end.date,
},
start: item.start,
end: item.end,
attendees: item.attendees,
location: item.location,
colorId: item.colorId,
@ -110,4 +104,86 @@ export const getUpcomingEvents = async (
console.error('Error fetching upcoming events:', error);
throw new Error('Failed to fetch upcoming events');
}
};
/**
* Create a new event
*/
export const createEvent = async (
oauth2Client: any,
accessToken: string,
calendarId: string,
event: Partial<GoogleCalendarEvent>
): Promise<GoogleCalendarEvent> => {
oauth2Client.setCredentials({ access_token: accessToken });
try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.insert({
calendarId,
requestBody: {
summary: event.summary,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
},
});
return response.data as any;
} catch (error) {
console.error('Error creating event:', error);
throw new Error('Failed to create event');
}
};
/**
* Update an existing event
*/
export const updateEvent = async (
oauth2Client: any,
accessToken: string,
calendarId: string,
eventId: string,
event: Partial<GoogleCalendarEvent>
): Promise<GoogleCalendarEvent> => {
oauth2Client.setCredentials({ access_token: accessToken });
try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.patch({
calendarId,
eventId,
requestBody: {
summary: event.summary,
description: event.description,
start: event.start,
end: event.end,
location: event.location,
},
});
return response.data as any;
} catch (error) {
console.error('Error updating event:', error);
throw new Error('Failed to update event');
}
};
/**
* Delete an event
*/
export const deleteEvent = async (
oauth2Client: any,
accessToken: string,
calendarId: string,
eventId: string
): Promise<void> => {
oauth2Client.setCredentials({ access_token: accessToken });
try {
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
await calendar.events.delete({
calendarId,
eventId,
});
} catch (error) {
console.error('Error deleting event:', error);
throw new Error('Failed to delete event');
}
};

295
src/lib/outlook-calendar.ts Normal file
View File

@ -0,0 +1,295 @@
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
// import { GoogleCalendarEvent as CalendarEvent } from './google-calendar';
export interface OutlookCalendar {
id: string;
name: string;
isDefaultCalendar: boolean;
canEdit: boolean;
owner: {
name: string;
address: string;
};
}
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || `${process.env.NEXTAUTH_URL}/api/calendar/outlook/callback`;
/**
* Generate OAuth2 Authorization URL
*/
export const getAuthUrl = () => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
if (!clientId) throw new Error('MICROSOFT_CLIENT_ID is not defined');
const scopes = [
'offline_access',
'user.read',
'Calendars.ReadWrite'
].join(' ');
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
response_mode: 'query',
scope: scopes,
state: 'outlook-auth' // Can be random for security
});
return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?${params.toString()}`;
};
/**
* Exchange Authorization Code for Tokens
*/
export const getTokens = async (code: string) => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('Microsoft credentials not defined');
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite',
code: code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
client_secret: clientSecret
});
const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
console.error('Error getting Outlook tokens:', error);
throw new Error(`Failed to get tokens: ${response.statusText}`);
}
return response.json();
};
/**
* Refresh Access Token
*/
export const refreshAccessToken = async (refreshToken: string) => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('Microsoft credentials not defined');
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite',
refresh_token: refreshToken,
redirect_uri: REDIRECT_URI,
grant_type: 'refresh_token',
client_secret: clientSecret
});
const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
console.error('Error refreshing Outlook token:', error);
throw new Error(`Failed to refresh token: ${response.statusText}`);
}
return response.json();
};
/**
* Get User's Calendars
*/
export const getUserCalendars = async (accessToken: string): Promise<OutlookCalendar[]> => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Prefer': 'outlook.timezone="UTC"'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch calendars: ${response.statusText}`);
}
const data = await response.json();
return data.value;
};
/**
* Get Upcoming Events
*/
export const getUpcomingEvents = async (
accessToken: string,
calendarId: string,
startDateTime: string,
endDateTime: string
) => {
const params = new URLSearchParams({
startDateTime: startDateTime,
endDateTime: endDateTime,
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay',
'$orderby': 'start/dateTime',
'$top': '50'
});
const response = await fetch(
`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/calendarView?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Prefer': 'outlook.timezone="UTC"'
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch events: ${response.statusText}`);
}
const data = await response.json();
return data.value.map((event: any) => ({
id: event.id,
summary: event.subject,
description: event.bodyPreview,
start: {
dateTime: event.start.dateTime,
timeZone: event.start.timeZone
},
end: {
dateTime: event.end.dateTime,
timeZone: event.end.timeZone
},
location: event.location?.displayName,
htmlLink: event.webLink,
allDay: event.isAllDay
}));
};
/**
* Create Outlook Event
*/
export const createEvent = async (
accessToken: string,
calendarId: string,
event: any
) => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${calendarId}/events`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: event.summary,
body: {
contentType: 'HTML',
content: event.description || ''
},
start: event.start,
end: event.end,
location: {
displayName: event.location || ''
}
})
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to create Outlook event: ${err}`);
}
const created = await response.json();
return {
id: created.id,
summary: created.subject,
description: created.bodyPreview,
start: created.start,
end: created.end,
location: created.location?.displayName
};
};
/**
* Update Outlook Event
*/
export const updateEvent = async (
accessToken: string,
calendarId: string, // Not strictly needed for Graph ID-based update but kept for interface consistency
eventId: string,
event: any
) => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: event.summary,
body: {
contentType: 'HTML',
content: event.description || ''
},
start: event.start,
end: event.end,
location: {
displayName: event.location || ''
}
})
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to update Outlook event: ${err}`);
}
const updated = await response.json();
return {
id: updated.id,
summary: updated.subject,
description: updated.bodyPreview,
start: updated.start,
end: updated.end,
location: updated.location?.displayName
};
};
/**
* Delete Outlook Event
*/
export const deleteEvent = async (
accessToken: string,
calendarId: string,
eventId: string
) => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/events/${eventId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete Outlook event: ${err}`);
}
};

7
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

19
tailwind.config.js Normal file
View File

@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: 'class',
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};

File diff suppressed because one or more lines are too long