feat: fix Docker deployment, OAuth redirects, event editing, and UI improvements
- Fix Docker build: resolve hooks-rules-of-hooks in GridTaskBlock, add Suspense boundary for useSearchParams, fix non-root user home dir and prisma binary path - Add comprehensive DB migrations for missing columns/tables (WeeklyGoal, CachedCalendarEvent, User settings, SomedayList sync fields, Task subtasks) - Fix Outlook OAuth redirect using NEXTAUTH_URL instead of request.url - Fix CalendarEventModal preserving existing event dates (support flat format) - Fix header z-index layering so goal text doesn't block hover controls - Convert font size selects to free-form text inputs, add dayHeaderGap setting - Add goal 2-line clamping with 500px max width - Fix weekend colors not applying to date elements v1.6.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
279d1a4c49
commit
f0672e5c68
36
Dockerfile
36
Dockerfile
@ -23,30 +23,40 @@ FROM node:18-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Install OpenSSL for Prisma and curl for healthcheck
|
||||
RUN apt-get update && apt-get install -y openssl curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
# Create non-root user with a writable home directory
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN adduser --system --uid 1001 --home /home/nextjs nextjs
|
||||
|
||||
# Copy built output and dependencies
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
|
||||
# Create logs directory
|
||||
# Set correct permissions
|
||||
RUN mkdir -p /app/logs && chown -R nextjs:nodejs /app
|
||||
|
||||
# Copy built output
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Copy Prisma CLI and generated client from builder so npx doesn't re-download
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/api/auth/session || exit 1
|
||||
CMD curl -f http://localhost:3000/api/auth/session || exit 1
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@ -2,6 +2,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: my-weekly-todo:latest
|
||||
ports:
|
||||
- "13000:3000"
|
||||
@ -10,7 +11,7 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- app_logs:/app/logs
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: version,
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
117
prisma/migrations/20260224_add_missing_columns/migration.sql
Normal file
117
prisma/migrations/20260224_add_missing_columns/migration.sql
Normal file
@ -0,0 +1,117 @@
|
||||
-- AlterTable: Add missing User settings columns
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalFallbackType" TEXT NOT NULL DEFAULT 'quote';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "goalDefaultSentence" TEXT NOT NULL DEFAULT 'goal of the week';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateLayout" TEXT NOT NULL DEFAULT 'right';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dateAlignment" TEXT NOT NULL DEFAULT 'center';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "hourLabelFormat" TEXT NOT NULL DEFAULT 'short';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showSubHourSlots" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "allDayPosition" TEXT NOT NULL DEFAULT 'below';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwColor" TEXT DEFAULT '#333333';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontFamily" TEXT DEFAULT 'Inter';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontSize" TEXT DEFAULT '1.125rem';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "cwFontWeight" TEXT DEFAULT '700';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearColor" TEXT DEFAULT '#333333';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontFamily" TEXT DEFAULT 'Inter';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontSize" TEXT DEFAULT '1.125rem';
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "yearFontWeight" TEXT DEFAULT '700';
|
||||
|
||||
-- AlterTable: Add missing SomedayList columns
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: Add missing Task columns
|
||||
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
|
||||
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable: WeeklyGoal
|
||||
CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"weekStart" TIMESTAMP(3) NOT NULL,
|
||||
"text" TEXT NOT NULL DEFAULT 'your goal of this week',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: CachedCalendarEvent
|
||||
CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"calendarId" TEXT NOT NULL,
|
||||
"calendarTitle" TEXT NOT NULL,
|
||||
"calendarColor" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"location" TEXT,
|
||||
"startDateTime" TIMESTAMP(3),
|
||||
"startDate" TEXT,
|
||||
"endDateTime" TIMESTAMP(3),
|
||||
"endDate" TEXT,
|
||||
"weekStart" TIMESTAMP(3) NOT NULL,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndexes for WeeklyGoal
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
|
||||
CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
|
||||
|
||||
-- CreateIndexes for CachedCalendarEvent
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
|
||||
|
||||
-- CreateIndex for Task
|
||||
CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
|
||||
CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
|
||||
|
||||
-- AddForeignKeys (idempotent)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Task_parentTaskId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'WeeklyGoal_userId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Fix Task_somedayListId_fkey to use SET NULL on delete (idempotent)
|
||||
ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
100
prisma/migrations/20260224_add_missing_tables/migration.sql
Normal file
100
prisma/migrations/20260224_add_missing_tables/migration.sql
Normal file
@ -0,0 +1,100 @@
|
||||
-- AlterTable: Add missing SomedayList columns
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalId" TEXT;
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "externalProvider" TEXT;
|
||||
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: Add missing Task columns
|
||||
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "parentTaskId" TEXT;
|
||||
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable: WeeklyGoal
|
||||
CREATE TABLE IF NOT EXISTS "WeeklyGoal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"weekStart" TIMESTAMP(3) NOT NULL,
|
||||
"text" TEXT NOT NULL DEFAULT 'your goal of this week',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WeeklyGoal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: CachedCalendarEvent
|
||||
CREATE TABLE IF NOT EXISTS "CachedCalendarEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"calendarId" TEXT NOT NULL,
|
||||
"calendarTitle" TEXT NOT NULL,
|
||||
"calendarColor" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"location" TEXT,
|
||||
"startDateTime" TIMESTAMP(3),
|
||||
"startDate" TEXT,
|
||||
"endDateTime" TIMESTAMP(3),
|
||||
"endDate" TEXT,
|
||||
"weekStart" TIMESTAMP(3) NOT NULL,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CachedCalendarEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndexes for WeeklyGoal
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "WeeklyGoal_userId_weekStart_key" ON "WeeklyGoal"("userId", "weekStart");
|
||||
CREATE INDEX IF NOT EXISTS "WeeklyGoal_userId_idx" ON "WeeklyGoal"("userId");
|
||||
|
||||
-- CreateIndexes for CachedCalendarEvent
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_externalId_provider_key" ON "CachedCalendarEvent"("userId", "externalId", "provider");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDateTime_idx" ON "CachedCalendarEvent"("userId", "startDateTime");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_userId_startDate_idx" ON "CachedCalendarEvent"("userId", "startDate");
|
||||
CREATE INDEX IF NOT EXISTS "CachedCalendarEvent_connectionId_weekStart_idx" ON "CachedCalendarEvent"("connectionId", "weekStart");
|
||||
|
||||
-- CreateIndexes for Task
|
||||
CREATE INDEX IF NOT EXISTS "Task_parentTaskId_idx" ON "Task"("parentTaskId");
|
||||
CREATE INDEX IF NOT EXISTS "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
|
||||
|
||||
-- AddForeignKeys (idempotent)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'Task_parentTaskId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_parentTaskId_fkey" FOREIGN KEY ("parentTaskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'WeeklyGoal_userId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "WeeklyGoal" ADD CONSTRAINT "WeeklyGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'CachedCalendarEvent_connectionId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "CalendarConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'CachedCalendarEvent_userId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "CachedCalendarEvent" ADD CONSTRAINT "CachedCalendarEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Fix Task_somedayListId_fkey to use SET NULL on delete
|
||||
ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
10
scripts/docker-entrypoint.sh
Executable file
10
scripts/docker-entrypoint.sh
Executable file
@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run migrations using the local prisma binary
|
||||
echo "Running database migrations..."
|
||||
node node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
# Start the application
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
@ -5,11 +5,13 @@ import { prisma } from '@/lib/prisma';
|
||||
import { getTokens, getUserCalendars } from '@/lib/outlook-calendar';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
@ -18,11 +20,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (error) {
|
||||
console.error('Outlook OAuth error:', error);
|
||||
return NextResponse.redirect(new URL('/?error=outlook_auth_failed', request.url));
|
||||
return NextResponse.redirect(new URL('/?error=outlook_auth_failed', baseUrl));
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(new URL('/?error=no_code', request.url));
|
||||
return NextResponse.redirect(new URL('/?error=no_code', baseUrl));
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
@ -39,7 +41,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
}
|
||||
|
||||
// Calculate expiry date
|
||||
@ -89,13 +91,13 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Redirect to a client-side page that re-establishes the session
|
||||
// Direct redirects from Microsoft OAuth may lose the session cookie (SameSite policy)
|
||||
const redirectUrl = new URL('/auth/oauth-complete', request.url);
|
||||
const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
|
||||
redirectUrl.searchParams.set('provider', 'outlook');
|
||||
redirectUrl.searchParams.set('status', 'connected');
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error('Error in Outlook callback:', error);
|
||||
const redirectUrl = new URL('/auth/oauth-complete', request.url);
|
||||
const redirectUrl = new URL('/auth/oauth-complete', baseUrl);
|
||||
redirectUrl.searchParams.set('provider', 'outlook');
|
||||
redirectUrl.searchParams.set('status', 'error');
|
||||
redirectUrl.searchParams.set('message', 'outlook_callback_failed');
|
||||
|
||||
@ -8,7 +8,8 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||
const baseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
}
|
||||
|
||||
const authUrl = getAuthUrl();
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function OAuthCompletePage() {
|
||||
function OAuthCompleteContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { update } = useSession();
|
||||
@ -52,3 +52,21 @@ export default function OAuthCompletePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthCompletePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
}}>
|
||||
<p style={{ color: "#6b7280" }}>Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<OAuthCompleteContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -645,13 +645,11 @@ h3 {
|
||||
}
|
||||
|
||||
/* Weekend Styling */
|
||||
.weekly-day-column.is-sat .weekly-day-name,
|
||||
.weekly-day-column.is-sat .weekly-day-date {
|
||||
.weekly-day-column.is-sat .weekly-day-name {
|
||||
color: var(--weekly-weekend-sat, #666);
|
||||
}
|
||||
|
||||
.weekly-day-column.is-sun .weekly-day-name,
|
||||
.weekly-day-column.is-sun .weekly-day-date {
|
||||
.weekly-day-column.is-sun .weekly-day-name {
|
||||
color: var(--weekly-weekend-sun, #dc2626);
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,9 @@ export default function CalendarEventModal({
|
||||
// Default duration: 1 hour.
|
||||
|
||||
const getInitialStart = () => {
|
||||
// Support both nested (start.dateTime) and flat (startTime) event formats
|
||||
if (event?.start?.dateTime) return new Date(event.start.dateTime);
|
||||
if (event?.startTime) return new Date(event.startTime);
|
||||
if (initialDate) {
|
||||
const d = new Date(initialDate);
|
||||
if (initialStartTime) {
|
||||
@ -59,7 +61,9 @@ export default function CalendarEventModal({
|
||||
};
|
||||
|
||||
const getInitialEnd = () => {
|
||||
// Support both nested (end.dateTime) and flat (endTime) event formats
|
||||
if (event?.end?.dateTime) return new Date(event.end.dateTime);
|
||||
if (event?.endTime) return new Date(event.endTime);
|
||||
const start = getInitialStart();
|
||||
return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour
|
||||
};
|
||||
|
||||
@ -105,29 +105,8 @@ export function GridTaskBlock({
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (!task.startTime) return null;
|
||||
|
||||
const [startHour, startMinute] = task.startTime.split(":").map(Number);
|
||||
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
|
||||
|
||||
// Calculate top offset
|
||||
// Calculate dimensions (needed by resize useEffect, must be before early return)
|
||||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||||
const topOffset = startMinutes * pixelsPerMinute;
|
||||
|
||||
// Calculate height
|
||||
const duration = task.duration || 15; // default 15m if not set
|
||||
const baseHeight = duration * pixelsPerMinute;
|
||||
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
|
||||
|
||||
// Handlers for resizing
|
||||
const onResizeStart = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
resizeStartY.current = e.clientY;
|
||||
resizeStartHeight.current = baseHeight;
|
||||
document.body.style.cursor = "ns-resize";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResizeMove = (e: MouseEvent) => {
|
||||
@ -139,7 +118,7 @@ export function GridTaskBlock({
|
||||
setResizeHeight(newHeight);
|
||||
};
|
||||
|
||||
const onResizeEnd = (e: MouseEvent) => {
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing) return;
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
@ -161,6 +140,29 @@ export function GridTaskBlock({
|
||||
};
|
||||
}, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration]);
|
||||
|
||||
if (!task.startTime) return null;
|
||||
|
||||
const [startHour, startMinute] = task.startTime.split(":").map(Number);
|
||||
const startMinutes = (startHour - workingHoursStart) * 60 + startMinute;
|
||||
|
||||
// Calculate top offset
|
||||
const topOffset = startMinutes * pixelsPerMinute;
|
||||
|
||||
// Calculate height
|
||||
const duration = task.duration || 15; // default 15m if not set
|
||||
const baseHeight = duration * pixelsPerMinute;
|
||||
const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight;
|
||||
|
||||
// Handlers for resizing
|
||||
const onResizeStart = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
resizeStartY.current = e.clientY;
|
||||
resizeStartHeight.current = baseHeight;
|
||||
document.body.style.cursor = "ns-resize";
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -607,6 +607,7 @@ export default function WeeklyView() {
|
||||
yearFontSize?: string;
|
||||
yearFontWeight?: string;
|
||||
yearColor?: string;
|
||||
dayHeaderGap?: string;
|
||||
quoteSourceUrls?: string[];
|
||||
}>({
|
||||
name: session?.user?.name || "",
|
||||
@ -3414,7 +3415,7 @@ export default function WeeklyView() {
|
||||
{/* Refactored Header: Left, Center, Right */}
|
||||
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200">
|
||||
{/* LEFT SECTION: Slot Duration & Days to Show */}
|
||||
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||
{/* Slot Duration */}
|
||||
{showTimeGrid && (
|
||||
<div
|
||||
@ -3504,7 +3505,7 @@ export default function WeeklyView() {
|
||||
</div>
|
||||
|
||||
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
||||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group">
|
||||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group" style={{ zIndex: 0 }}>
|
||||
{/* Week & Year */}
|
||||
<div className="whitespace-nowrap flex items-center gap-2">
|
||||
{syncError ? (
|
||||
@ -3582,7 +3583,13 @@ export default function WeeklyView() {
|
||||
fontSize: profile.goalFontSize || undefined,
|
||||
fontWeight: profile.goalFontWeight || undefined,
|
||||
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
|
||||
filter: "brightness(var(--weekly-goal-brightness, 1))"
|
||||
filter: "brightness(var(--weekly-goal-brightness, 1))",
|
||||
maxWidth: "500px",
|
||||
textAlign: "center" as const,
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical" as const,
|
||||
}}
|
||||
>
|
||||
{showNextTask
|
||||
@ -3622,7 +3629,7 @@ export default function WeeklyView() {
|
||||
</div>
|
||||
|
||||
{/* RIGHT SECTION: Navigation & Tools */}
|
||||
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||
{/* Undo/Redo */}
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
@ -3931,7 +3938,7 @@ export default function WeeklyView() {
|
||||
: profile.dateLayout === "below"
|
||||
? "column"
|
||||
: "row",
|
||||
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
|
||||
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
|
||||
}}
|
||||
>
|
||||
{profile.dateLayout === "left" && (
|
||||
@ -6273,6 +6280,7 @@ interface SettingsSidebarProps {
|
||||
yearFontSize?: string;
|
||||
yearFontWeight?: string;
|
||||
yearColor?: string;
|
||||
dayHeaderGap?: string;
|
||||
quoteSourceUrls: string[];
|
||||
}) => void;
|
||||
quoteSourceUrls?: string[];
|
||||
@ -6627,6 +6635,7 @@ function SettingsSidebar({
|
||||
yearFontSize?: string;
|
||||
yearFontWeight?: string;
|
||||
yearColor?: string;
|
||||
dayHeaderGap?: string;
|
||||
quoteSourceUrls?: string[];
|
||||
}>({
|
||||
name: "",
|
||||
@ -6751,6 +6760,18 @@ function SettingsSidebar({
|
||||
goalScope: profile.goalScope,
|
||||
dateLayout: profile.dateLayout,
|
||||
dateAlignment: profile.dateAlignment,
|
||||
goalFontFamily: profile.goalFontFamily,
|
||||
goalFontSize: profile.goalFontSize,
|
||||
goalFontWeight: profile.goalFontWeight,
|
||||
cwColor: profile.cwColor,
|
||||
cwFontFamily: profile.cwFontFamily,
|
||||
cwFontSize: profile.cwFontSize,
|
||||
cwFontWeight: profile.cwFontWeight,
|
||||
yearColor: profile.yearColor,
|
||||
yearFontFamily: profile.yearFontFamily,
|
||||
yearFontSize: profile.yearFontSize,
|
||||
yearFontWeight: profile.yearFontWeight,
|
||||
dayHeaderGap: profile.dayHeaderGap,
|
||||
} as any);
|
||||
}, [profile, showTimeGrid, cellDuration, viewStyle, fontSize, showNextTask, showSomeday, showAllDay, showSchedule]);
|
||||
|
||||
@ -8399,7 +8420,8 @@ function SettingsSidebar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.headlineFontSize || "1.25rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({
|
||||
@ -8407,6 +8429,7 @@ function SettingsSidebar({
|
||||
headlineFontSize: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="1.25rem"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
@ -8417,13 +8440,7 @@ function SettingsSidebar({
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
<option value="1rem">Small 16px</option>
|
||||
<option value="1.25rem">Normal 20px</option>
|
||||
<option value="1.5rem">Large 24px</option>
|
||||
<option value="1.75rem">XL 28px</option>
|
||||
<option value="2rem">Huge 32px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.headlineFontWeight || "900"}
|
||||
onChange={(e) =>
|
||||
@ -8516,11 +8533,13 @@ function SettingsSidebar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.dateFontSize || "0.65rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, dateFontSize: e.target.value })
|
||||
}
|
||||
placeholder="0.65rem"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
@ -8531,13 +8550,7 @@ function SettingsSidebar({
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
<option value="0.55rem">XS 9px</option>
|
||||
<option value="0.65rem">Normal 10px</option>
|
||||
<option value="0.75rem">Small 12px</option>
|
||||
<option value="0.85rem">Medium 14px</option>
|
||||
<option value="1rem">Large 16px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.dateFontWeight || "400"}
|
||||
onChange={(e) =>
|
||||
@ -8630,7 +8643,8 @@ function SettingsSidebar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.taskFontSize || "0.9rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({
|
||||
@ -8639,6 +8653,7 @@ function SettingsSidebar({
|
||||
timeTaskFontSize: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="0.9rem"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
@ -8649,13 +8664,7 @@ function SettingsSidebar({
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
<option value="0.75rem">Small 12px</option>
|
||||
<option value="0.9rem">Normal 14px</option>
|
||||
<option value="1rem">Medium 16px</option>
|
||||
<option value="1.1rem">Large 18px</option>
|
||||
<option value="1.25rem">XL 20px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.taskFontWeight || "400"}
|
||||
onChange={(e) =>
|
||||
@ -8743,7 +8752,8 @@ function SettingsSidebar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.eventFontSize || "0.85rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({
|
||||
@ -8751,6 +8761,7 @@ function SettingsSidebar({
|
||||
eventFontSize: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="0.85rem"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
@ -8761,12 +8772,7 @@ function SettingsSidebar({
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
<option value="0.75rem">Small 12px</option>
|
||||
<option value="0.85rem">Normal 14px</option>
|
||||
<option value="0.95rem">Medium 16px</option>
|
||||
<option value="1.05rem">Large 18px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.eventFontWeight || "400"}
|
||||
onChange={(e) =>
|
||||
@ -8853,11 +8859,13 @@ function SettingsSidebar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.goalFontSize || "1rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, goalFontSize: e.target.value })
|
||||
}
|
||||
placeholder="1rem"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
@ -8868,12 +8876,7 @@ function SettingsSidebar({
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
>
|
||||
<option value="0.85rem">Small 14px</option>
|
||||
<option value="1rem">Default 16px</option>
|
||||
<option value="1.15rem">Large 18px</option>
|
||||
<option value="1.3rem">Huge 20px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.goalFontWeight || "400"}
|
||||
onChange={(e) =>
|
||||
@ -8902,6 +8905,46 @@ function SettingsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day / Weekday Gap */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--weekly-settings-item-bg)",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "12px",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--weekly-settings-label)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Day / Weekday Gap
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.dayHeaderGap || "0.35em"}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, dayHeaderGap: e.target.value })
|
||||
}
|
||||
placeholder="0.35em"
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
fontSize: "0.9rem",
|
||||
border: "1px solid var(--weekly-settings-input-border)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--weekly-settings-input-bg)",
|
||||
color: "var(--weekly-settings-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Calendar Week Font */}
|
||||
<div
|
||||
style={{
|
||||
@ -8948,21 +8991,16 @@ function SettingsSidebar({
|
||||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.cwFontSize || "1.125rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, cwFontSize: e.target.value })
|
||||
}
|
||||
placeholder="1.125rem"
|
||||
className="weekly-input"
|
||||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||||
>
|
||||
<option value="0.75rem">Small 12px</option>
|
||||
<option value="0.875rem">Normal 14px</option>
|
||||
<option value="1rem">Medium 16px</option>
|
||||
<option value="1.125rem">Large 18px</option>
|
||||
<option value="1.25rem">XL 20px</option>
|
||||
<option value="1.5rem">Huge 24px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.cwFontWeight || "700"}
|
||||
onChange={(e) =>
|
||||
@ -9027,21 +9065,16 @@ function SettingsSidebar({
|
||||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
<input
|
||||
type="text"
|
||||
value={profile.yearFontSize || "1.125rem"}
|
||||
onChange={(e) =>
|
||||
setProfile({ ...profile, yearFontSize: e.target.value })
|
||||
}
|
||||
placeholder="1.125rem"
|
||||
className="weekly-input"
|
||||
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
|
||||
>
|
||||
<option value="0.75rem">Small 12px</option>
|
||||
<option value="0.875rem">Normal 14px</option>
|
||||
<option value="1rem">Medium 16px</option>
|
||||
<option value="1.125rem">Large 18px</option>
|
||||
<option value="1.25rem">XL 20px</option>
|
||||
<option value="1.5rem">Huge 24px</option>
|
||||
</select>
|
||||
/>
|
||||
<select
|
||||
value={profile.yearFontWeight || "700"}
|
||||
onChange={(e) =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user