Fix header layout, add day/night switch, refine night mode styling
This commit is contained in:
parent
7e4ca22e5b
commit
e9a0a706b7
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
140
src/app/api/calendar/events/route.ts
Normal file
140
src/app/api/calendar/events/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
95
src/app/api/calendar/outlook/callback/route.ts
Normal file
95
src/app/api/calendar/outlook/callback/route.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
23
src/app/api/calendar/outlook/start/route.ts
Normal file
23
src/app/api/calendar/outlook/start/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
11
src/app/view-transitions.css
Normal file
11
src/app/view-transitions.css
Normal 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 */
|
||||
}
|
||||
258
src/components/CalendarEventModal.tsx
Normal file
258
src/components/CalendarEventModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
src/components/DatePicker.tsx
Normal file
338
src/components/DatePicker.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
264
src/components/FocusModeOverlay.tsx
Normal file
264
src/components/FocusModeOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/RecurrenceModal.tsx
Normal file
106
src/components/RecurrenceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/RecurringTasksManager.tsx
Normal file
98
src/components/RecurringTasksManager.tsx
Normal 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">
|
||||
×
|
||||
</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'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'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>
|
||||
);
|
||||
}
|
||||
128
src/components/SearchModal.tsx
Normal file
128
src/components/SearchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/SimpleDatePicker.tsx
Normal file
157
src/components/SimpleDatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
95
src/components/UserMenu.tsx
Normal file
95
src/components/UserMenu.tsx
Normal 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
@ -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.`);
|
||||
};
|
||||
@ -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
295
src/lib/outlook-calendar.ts
Normal 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
7
src/lib/prisma.ts
Normal 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
19
tailwind.config.js
Normal 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
Loading…
Reference in New Issue
Block a user