chore(release): merge dev to main for v1.9.0
This commit is contained in:
commit
99e2e9bac8
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.8.7",
|
||||
"version": "1.9.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
-- User: add accountNumber (auto-incrementing unique identifier)
|
||||
CREATE SEQUENCE IF NOT EXISTS "User_accountNumber_seq";
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "accountNumber" INTEGER NOT NULL DEFAULT nextval('"User_accountNumber_seq"');
|
||||
ALTER SEQUENCE "User_accountNumber_seq" OWNED BY "User"."accountNumber";
|
||||
-- Populate existing rows with sequential numbers
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
counter INTEGER := 1;
|
||||
BEGIN
|
||||
FOR r IN SELECT id FROM "User" ORDER BY "createdAt" ASC LOOP
|
||||
UPDATE "User" SET "accountNumber" = counter WHERE id = r.id;
|
||||
counter := counter + 1;
|
||||
END LOOP;
|
||||
-- Set sequence to continue after the highest assigned number
|
||||
PERFORM setval('"User_accountNumber_seq"', COALESCE((SELECT MAX("accountNumber") FROM "User"), 0));
|
||||
END $$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "User_accountNumber_key" ON "User"("accountNumber");
|
||||
|
||||
-- User: add quick settings preferences
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showCompletedTasks" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "showLines" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "startDayOffset" INTEGER NOT NULL DEFAULT -1;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "quoteSourceUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CachedCalendarEvent: add url, recurringEventId, isRecurring
|
||||
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "url" TEXT;
|
||||
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "recurringEventId" TEXT;
|
||||
ALTER TABLE "CachedCalendarEvent" ADD COLUMN IF NOT EXISTS "isRecurring" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Task: add projectId
|
||||
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "projectId" TEXT;
|
||||
|
||||
-- Project model
|
||||
CREATE TABLE IF NOT EXISTS "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"color" TEXT,
|
||||
"description" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS "Project_userId_idx" ON "Project"("userId");
|
||||
CREATE INDEX IF NOT EXISTS "Task_userId_projectId_idx" ON "Task"("userId", "projectId");
|
||||
|
||||
-- Foreign keys
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@ -10,6 +10,7 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
accountNumber Int @unique @default(autoincrement())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
name String?
|
||||
@ -87,8 +88,13 @@ model User {
|
||||
yearFontSize String? @default("1.5rem")
|
||||
yearFontWeight String? @default("700")
|
||||
showTaskCheckboxes Boolean @default(false)
|
||||
showCompletedTasks Boolean @default(true)
|
||||
showLines Boolean @default(true)
|
||||
startDayOffset Int @default(-1)
|
||||
quoteSourceUrls String[] @default([])
|
||||
emailVerificationCode String?
|
||||
accounts Account[]
|
||||
projects Project[]
|
||||
cachedCalendarEvents CachedCalendarEvent[]
|
||||
calendarConnections CalendarConnection[]
|
||||
sessions Session[]
|
||||
@ -159,10 +165,12 @@ model Task {
|
||||
lastSyncedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
parentTaskId String?
|
||||
projectId String?
|
||||
somedaySlotIndex Int?
|
||||
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
||||
subTasks Task[] @relation("SubTasks")
|
||||
somedayList SomedayList? @relation(fields: [somedayListId], references: [id])
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, dayOfWeek])
|
||||
@ -170,6 +178,7 @@ model Task {
|
||||
@@index([userId, somedayListId])
|
||||
@@index([userId, externalId])
|
||||
@@index([userId, deletedAt])
|
||||
@@index([userId, projectId])
|
||||
@@index([parentTaskId])
|
||||
}
|
||||
|
||||
@ -213,9 +222,12 @@ model CachedCalendarEvent {
|
||||
calendarTitle String
|
||||
calendarColor String?
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
startDateTime DateTime?
|
||||
description String?
|
||||
location String?
|
||||
url String?
|
||||
recurringEventId String?
|
||||
isRecurring Boolean @default(false)
|
||||
startDateTime DateTime?
|
||||
startDate String?
|
||||
endDateTime DateTime?
|
||||
endDate String?
|
||||
@ -244,3 +256,19 @@ model WeeklyGoal {
|
||||
@@unique([userId, weekStart])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
name String
|
||||
icon String?
|
||||
color String?
|
||||
description String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state'); // User email passed from start route
|
||||
const state = searchParams.get('state'); // User CUID passed from start route
|
||||
|
||||
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
|
||||
@ -22,16 +22,16 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
|
||||
}
|
||||
|
||||
// Get user from session or state parameter
|
||||
const userEmail = session?.user?.email || state;
|
||||
// Get user by session ID or state parameter (which now contains CUID, not email)
|
||||
const userId = (session?.user as any)?.id || state;
|
||||
|
||||
if (!userEmail) {
|
||||
if (!userId) {
|
||||
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
|
||||
}
|
||||
|
||||
// Find user in database
|
||||
// Find user in database by ID (works regardless of signup email)
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: userEmail }
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@ -145,7 +145,7 @@ export async function GET(request: NextRequest) {
|
||||
userId: user.id,
|
||||
type: 'oauth',
|
||||
provider: 'google',
|
||||
providerAccountId: userEmail,
|
||||
providerAccountId: user.id,
|
||||
access_token: tokens.access_token || '',
|
||||
refresh_token: tokens.refresh_token || null,
|
||||
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
|
||||
|
||||
@ -10,7 +10,8 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
const userId = (session?.user as any)?.id;
|
||||
if (!userId) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
}
|
||||
@ -42,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||
'https://www.googleapis.com/auth/tasks',
|
||||
],
|
||||
prompt: 'consent',
|
||||
state: session.user.email, // Pass user email to identify in callback
|
||||
state: userId, // Pass user CUID to identify in callback (not email)
|
||||
});
|
||||
|
||||
// Redirect the user to Google's consent screen
|
||||
|
||||
@ -36,9 +36,10 @@ export async function GET(request: NextRequest) {
|
||||
// Fetch user's calendars to store initial list
|
||||
const calendars = await getUserCalendars(accessToken);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
});
|
||||
const userId = (session.user as any).id;
|
||||
const user = userId
|
||||
? await prisma.user.findUnique({ where: { id: userId } })
|
||||
: await prisma.user.findUnique({ where: { email: session.user.email } });
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||
|
||||
@ -7,7 +7,8 @@ import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConn
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
const userId = (session?.user as any)?.id;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
@ -18,7 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
where: { id: userId },
|
||||
include: { calendarConnections: true },
|
||||
});
|
||||
|
||||
|
||||
89
src/app/api/fonts/route.ts
Normal file
89
src/app/api/fonts/route.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const POPULAR_FONTS = [
|
||||
{ name: "Inter", value: "Inter", category: "sans-serif" },
|
||||
{ name: "Roboto", value: "Roboto", category: "sans-serif" },
|
||||
{ name: "Open Sans", value: "Open Sans", category: "sans-serif" },
|
||||
{ name: "Lato", value: "Lato", category: "sans-serif" },
|
||||
{ name: "Montserrat", value: "Montserrat", category: "sans-serif" },
|
||||
{ name: "Oswald", value: "Oswald", category: "sans-serif" },
|
||||
{ name: "Raleway", value: "Raleway", category: "sans-serif" },
|
||||
{ name: "Playfair Display", value: "Playfair Display", category: "serif" },
|
||||
{ name: "Merriweather", value: "Merriweather", category: "serif" },
|
||||
{ name: "Nunito", value: "Nunito", category: "sans-serif" },
|
||||
{ name: "Dancing Script", value: "Dancing Script", category: "handwriting" },
|
||||
{ name: "Pacifico", value: "Pacifico", category: "handwriting" },
|
||||
{ name: "Poppins", value: "Poppins", category: "sans-serif" },
|
||||
{ name: "Source Sans Pro", value: "Source Sans Pro", category: "sans-serif" },
|
||||
{ name: "Ubuntu", value: "Ubuntu", category: "sans-serif" },
|
||||
{ name: "Rubik", value: "Rubik", category: "sans-serif" },
|
||||
{ name: "Work Sans", value: "Work Sans", category: "sans-serif" },
|
||||
{ name: "Quicksand", value: "Quicksand", category: "sans-serif" },
|
||||
{ name: "Josefin Sans", value: "Josefin Sans", category: "sans-serif" },
|
||||
{ name: "Libre Baskerville", value: "Libre Baskerville", category: "serif" },
|
||||
{ name: "Crimson Text", value: "Crimson Text", category: "serif" },
|
||||
{ name: "Bitter", value: "Bitter", category: "serif" },
|
||||
{ name: "Archivo", value: "Archivo", category: "sans-serif" },
|
||||
{ name: "DM Sans", value: "DM Sans", category: "sans-serif" },
|
||||
{ name: "Space Grotesk", value: "Space Grotesk", category: "sans-serif" },
|
||||
{ name: "Outfit", value: "Outfit", category: "sans-serif" },
|
||||
{ name: "Sora", value: "Sora", category: "sans-serif" },
|
||||
{ name: "Caveat", value: "Caveat", category: "handwriting" },
|
||||
{ name: "Comfortaa", value: "Comfortaa", category: "display" },
|
||||
{ name: "Barlow", value: "Barlow", category: "sans-serif" },
|
||||
{ name: "Karla", value: "Karla", category: "sans-serif" },
|
||||
{ name: "Manrope", value: "Manrope", category: "sans-serif" },
|
||||
{ name: "Lexend", value: "Lexend", category: "sans-serif" },
|
||||
{ name: "Roboto Slab", value: "Roboto Slab", category: "serif" },
|
||||
{ name: "PT Serif", value: "PT Serif", category: "serif" },
|
||||
{ name: "Noto Sans", value: "Noto Sans", category: "sans-serif" },
|
||||
{ name: "Fira Sans", value: "Fira Sans", category: "sans-serif" },
|
||||
{ name: "IBM Plex Sans", value: "IBM Plex Sans", category: "sans-serif" },
|
||||
{ name: "Cabin", value: "Cabin", category: "sans-serif" },
|
||||
{ name: "Inconsolata", value: "Inconsolata", category: "monospace" },
|
||||
];
|
||||
|
||||
let cachedGoogleFonts: any[] | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q') || '';
|
||||
const apiKey = process.env.GOOGLE_FONTS_API_KEY;
|
||||
|
||||
let fonts = POPULAR_FONTS;
|
||||
|
||||
// Try Google Fonts API if key is configured
|
||||
if (apiKey && (!cachedGoogleFonts || Date.now() - cacheTimestamp > CACHE_TTL)) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
cachedGoogleFonts = (data.items || []).map((f: any) => ({
|
||||
name: f.family,
|
||||
value: f.family,
|
||||
category: f.category,
|
||||
}));
|
||||
cacheTimestamp = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedGoogleFonts) {
|
||||
fonts = cachedGoogleFonts;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
fonts = fonts.filter((f) =>
|
||||
f.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ fonts: fonts.slice(0, 100) });
|
||||
}
|
||||
@ -109,6 +109,11 @@ export async function GET(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST delegates to PUT for client compatibility
|
||||
export async function POST(req: Request) {
|
||||
return PUT(req);
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
204
src/app/api/tasks/projects/route.ts
Normal file
204
src/app/api/tasks/projects/route.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// GET - List all projects for authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { tasks: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ projects });
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch projects' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create a new project
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const body = await request.json();
|
||||
|
||||
const { name, icon, color, description } = body;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project name is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set order to be after the last project
|
||||
const lastProject = await prisma.project.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true },
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name,
|
||||
icon: icon || null,
|
||||
color: color || null,
|
||||
description: description || null,
|
||||
order: (lastProject?.order ?? -1) + 1,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create project' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH - Update a project
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, icon, color, description, order } = body;
|
||||
|
||||
const project = await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(icon !== undefined && { icon: icon || null }),
|
||||
...(color !== undefined && { color: color || null }),
|
||||
...(description !== undefined && { description: description || null }),
|
||||
...(order !== undefined && { order: parseInt(order) }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ project });
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update project' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a project (tasks remain, their projectId becomes null)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
const existingProject = await prisma.project.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!existingProject) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Nullify projectId on all tasks belonging to this project
|
||||
await prisma.task.updateMany({
|
||||
where: { projectId: id },
|
||||
data: { projectId: null },
|
||||
});
|
||||
|
||||
await prisma.project.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Project deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete project' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -144,6 +144,9 @@ export async function GET(request: NextRequest) {
|
||||
where: includeDeleted ? {} : { deletedAt: null },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
project: {
|
||||
select: { id: true, name: true, icon: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ dayOfWeek: 'asc' },
|
||||
@ -195,7 +198,7 @@ export async function POST(request: NextRequest) {
|
||||
const userId = (session.user as any).id;
|
||||
const body = await request.json();
|
||||
|
||||
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body;
|
||||
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId, projectId } = body;
|
||||
let { isRolling } = body;
|
||||
const { isRecurring } = body;
|
||||
|
||||
@ -234,6 +237,7 @@ export async function POST(request: NextRequest) {
|
||||
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
|
||||
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
|
||||
parentTaskId: parentTaskId || null,
|
||||
...(projectId !== undefined && { projectId: projectId || null }),
|
||||
},
|
||||
});
|
||||
|
||||
@ -266,7 +270,7 @@ export async function PATCH(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
const { id } = body;
|
||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body;
|
||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId, projectId } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -351,7 +355,8 @@ export async function PATCH(request: NextRequest) {
|
||||
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
|
||||
...(restore === true && { deletedAt: null }),
|
||||
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
|
||||
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null })
|
||||
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
|
||||
...(projectId !== undefined && { projectId: projectId || null }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -78,6 +78,11 @@ export async function GET(request: NextRequest) {
|
||||
yearFontWeight: true,
|
||||
yearColor: true,
|
||||
dayHeaderGap: true,
|
||||
showCompletedTasks: true,
|
||||
showLines: true,
|
||||
startDayOffset: true,
|
||||
quoteSourceUrls: true,
|
||||
accountNumber: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
@ -118,7 +123,8 @@ export async function PATCH(request: NextRequest) {
|
||||
hourLabelFormat, showSubHourSlots, allDayPosition,
|
||||
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||
showTaskCheckboxes, dayHeaderGap
|
||||
showTaskCheckboxes, dayHeaderGap,
|
||||
showCompletedTasks, showLines, startDayOffset, quoteSourceUrls
|
||||
} = body;
|
||||
|
||||
const updateData: any = {
|
||||
@ -187,6 +193,10 @@ export async function PATCH(request: NextRequest) {
|
||||
...(yearFontWeight !== undefined && { yearFontWeight }),
|
||||
...(yearColor !== undefined && { yearColor }),
|
||||
...(dayHeaderGap !== undefined && { dayHeaderGap }),
|
||||
...(showCompletedTasks !== undefined && { showCompletedTasks }),
|
||||
...(showLines !== undefined && { showLines }),
|
||||
...(startDayOffset !== undefined && { startDayOffset }),
|
||||
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
|
||||
};
|
||||
if (password && password.trim() !== "") {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
@ -263,6 +273,11 @@ export async function PATCH(request: NextRequest) {
|
||||
yearFontWeight: true,
|
||||
yearColor: true,
|
||||
dayHeaderGap: true,
|
||||
showCompletedTasks: true,
|
||||
showLines: true,
|
||||
startDayOffset: true,
|
||||
quoteSourceUrls: true,
|
||||
accountNumber: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -3263,6 +3263,12 @@ h3 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* CRITICAL: Make all header sections visible on mobile (no hover on touch devices) */
|
||||
.weekly-header-controls {
|
||||
opacity: 1 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Fix overlap on mobile by removing absolute positioning */
|
||||
.weekly-header .absolute {
|
||||
position: static !important;
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'My Weekly To Do List',
|
||||
description: 'A simple, designy to-do app.',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'My Weekly To Do List',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
190
src/components/FontPicker.tsx
Normal file
190
src/components/FontPicker.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
|
||||
const POPULAR_FONTS = [
|
||||
"Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Oswald",
|
||||
"Raleway", "Playfair Display", "Merriweather", "Nunito",
|
||||
"Dancing Script", "Pacifico", "Poppins", "Source Sans Pro",
|
||||
"Ubuntu", "Rubik", "Work Sans", "Quicksand", "Josefin Sans",
|
||||
"Libre Baskerville", "Crimson Text", "Bitter", "Archivo",
|
||||
"DM Sans", "Space Grotesk", "Outfit", "Sora", "Caveat",
|
||||
"Comfortaa", "Barlow", "Karla", "Manrope", "Lexend",
|
||||
"Roboto Slab", "PT Serif", "Noto Sans", "Fira Sans",
|
||||
"IBM Plex Sans", "Cabin", "Inconsolata",
|
||||
];
|
||||
|
||||
interface FontPickerProps {
|
||||
value: string;
|
||||
onChange: (fontName: string) => void;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
|
||||
export default function FontPicker({ value, onChange, darkMode }: FontPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [allFonts, setAllFonts] = useState<string[]>(POPULAR_FONTS);
|
||||
const [loadedFonts, setLoadedFonts] = useState<Set<string>>(new Set(["Inter"]));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Try to fetch from Google Fonts API for extended list
|
||||
useEffect(() => {
|
||||
const fetchFonts = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/fonts");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.fonts?.length > 0) {
|
||||
setAllFonts(data.fonts.map((f: any) => f.value || f.name || f));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep popular fonts as fallback
|
||||
}
|
||||
};
|
||||
fetchFonts();
|
||||
}, []);
|
||||
|
||||
// Load font for preview
|
||||
const loadFont = (fontName: string) => {
|
||||
if (loadedFonts.has(fontName) || fontName === "Inter") return;
|
||||
const id = `font-preview-${fontName.replace(/\s+/g, "-")}`;
|
||||
if (!document.getElementById(id)) {
|
||||
const link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, "+")}:wght@400;700&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
setLoadedFonts((prev) => new Set(prev).add(fontName));
|
||||
};
|
||||
|
||||
// Load selected font
|
||||
useEffect(() => {
|
||||
if (value) loadFont(value);
|
||||
}, [value]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Focus input on open
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered = query
|
||||
? allFonts.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
|
||||
: allFonts;
|
||||
|
||||
const bg = darkMode ? "#1f2937" : "#fff";
|
||||
const border = darkMode ? "#374151" : "#e5e7eb";
|
||||
const text = darkMode ? "#e5e7eb" : "#333";
|
||||
const hoverBg = darkMode ? "#374151" : "#f0f9ff";
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
fontFamily: value,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 8px",
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: "6px",
|
||||
background: bg,
|
||||
color: text,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{value || "Select font"}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 1000,
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: "8px",
|
||||
width: "260px",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${border}`, padding: "6px 8px", gap: "6px" }}>
|
||||
<Search size={14} style={{ color: "#999", flexShrink: 0 }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search fonts..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
padding: "4px",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
color: text,
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxHeight: "250px", overflowY: "auto" }}>
|
||||
{filtered.slice(0, 50).map((font) => {
|
||||
loadFont(font);
|
||||
return (
|
||||
<div
|
||||
key={font}
|
||||
onClick={() => {
|
||||
onChange(font);
|
||||
setIsOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
onMouseEnter={() => loadFont(font)}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
cursor: "pointer",
|
||||
fontFamily: font,
|
||||
fontSize: "0.85rem",
|
||||
color: text,
|
||||
background: font === value ? hoverBg : "transparent",
|
||||
borderLeft: font === value ? "3px solid #0ea5e9" : "3px solid transparent",
|
||||
}}
|
||||
>
|
||||
{font}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: "12px", textAlign: "center", color: "#999", fontSize: "0.8rem" }}>
|
||||
No fonts found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/QuickSettingsSidebar.tsx
Normal file
205
src/components/QuickSettingsSidebar.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
import { X, Type, Space, CheckSquare, Calendar, Minus } from "lucide-react";
|
||||
|
||||
interface QuickSettingsProps {
|
||||
fontSize: string;
|
||||
onFontSizeChange: (size: string) => void;
|
||||
spacing: string;
|
||||
onSpacingChange: (spacing: string) => void;
|
||||
showCompleted: boolean;
|
||||
onShowCompletedChange: (show: boolean) => void;
|
||||
startDayOffset: number;
|
||||
onStartDayOffsetChange: (offset: number) => void;
|
||||
showLines: boolean;
|
||||
onShowLinesChange: (show: boolean) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
|
||||
export default function QuickSettingsSidebar({
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
spacing,
|
||||
onSpacingChange,
|
||||
showCompleted,
|
||||
onShowCompletedChange,
|
||||
startDayOffset,
|
||||
onStartDayOffsetChange,
|
||||
showLines,
|
||||
onShowLinesChange,
|
||||
isOpen,
|
||||
onClose,
|
||||
darkMode,
|
||||
}: QuickSettingsProps) {
|
||||
const bg = darkMode ? "#1f2937" : "#ffffff";
|
||||
const text = darkMode ? "#e5e7eb" : "#333333";
|
||||
const border = darkMode ? "#374151" : "#e5e7eb";
|
||||
const accent = "#0ea5e9";
|
||||
const btnBg = darkMode ? "#374151" : "#f3f4f6";
|
||||
const btnActiveBg = accent;
|
||||
const btnActiveText = "#ffffff";
|
||||
const labelColor = darkMode ? "#9ca3af" : "#6b7280";
|
||||
|
||||
const sizes = ["S", "M", "L"];
|
||||
|
||||
const SegmentedButton = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div style={{ display: "flex", gap: "2px", background: btnBg, borderRadius: "8px", padding: "2px" }}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => onChange(opt)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 10px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: value === opt ? 600 : 400,
|
||||
background: value === opt ? btnActiveBg : "transparent",
|
||||
color: value === opt ? btnActiveText : text,
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Toggle = ({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) => (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: "36px",
|
||||
height: "20px",
|
||||
borderRadius: "10px",
|
||||
border: "none",
|
||||
background: checked ? accent : (darkMode ? "#4b5563" : "#d1d5db"),
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "background 0.2s ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: checked ? "18px" : "2px",
|
||||
transition: "left 0.2s ease",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.1)",
|
||||
zIndex: 998,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Panel */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: isOpen ? "220px" : "0",
|
||||
overflow: "hidden",
|
||||
background: bg,
|
||||
borderLeft: isOpen ? `1px solid ${border}` : "none",
|
||||
transition: "width 0.2s ease",
|
||||
zIndex: 999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px", display: "flex", flexDirection: "column", gap: "20px", overflowY: "auto", flex: 1 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem", color: text }}>Quick Settings</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: labelColor, padding: "2px" }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
||||
<Type size={14} style={{ color: labelColor }} />
|
||||
<span style={{ fontSize: "0.75rem", color: labelColor }}>Font Size</span>
|
||||
</div>
|
||||
<SegmentedButton options={sizes} value={fontSize} onChange={onFontSizeChange} />
|
||||
</div>
|
||||
|
||||
{/* Spacing */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
||||
<Space size={14} style={{ color: labelColor }} />
|
||||
<span style={{ fontSize: "0.75rem", color: labelColor }}>Spacing</span>
|
||||
</div>
|
||||
<SegmentedButton options={sizes} value={spacing} onChange={onSpacingChange} />
|
||||
</div>
|
||||
|
||||
{/* Show Completed */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<CheckSquare size={14} style={{ color: labelColor }} />
|
||||
<span style={{ fontSize: "0.75rem", color: labelColor }}>Show Completed</span>
|
||||
</div>
|
||||
<Toggle checked={showCompleted} onChange={onShowCompletedChange} />
|
||||
</div>
|
||||
|
||||
{/* Start Day */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
||||
<Calendar size={14} style={{ color: labelColor }} />
|
||||
<span style={{ fontSize: "0.75rem", color: labelColor }}>Start View</span>
|
||||
</div>
|
||||
<SegmentedButton
|
||||
options={["Yesterday", "Today"]}
|
||||
value={startDayOffset === -1 ? "Yesterday" : "Today"}
|
||||
onChange={(v) => onStartDayOffsetChange(v === "Yesterday" ? -1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show Lines */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<Minus size={14} style={{ color: labelColor }} />
|
||||
<span style={{ fontSize: "0.75rem", color: labelColor }}>Show Lines</span>
|
||||
</div>
|
||||
<Toggle checked={showLines} onChange={onShowLinesChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1299,15 +1299,17 @@ export default function WeeklyView() {
|
||||
const saveGoal = async (newGoal: string) => {
|
||||
setGoal(newGoal);
|
||||
try {
|
||||
await fetch("/api/goal", {
|
||||
method: "POST",
|
||||
const res = await fetch("/api/goal", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
weekStart: goalDateKey,
|
||||
goal: newGoal,
|
||||
scope: profile.goalScope || "week",
|
||||
text: newGoal,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Goal save failed:", res.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving goal:", error);
|
||||
}
|
||||
@ -1434,8 +1436,13 @@ export default function WeeklyView() {
|
||||
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
||||
}
|
||||
if (data.user.viewDays !== undefined) {
|
||||
setViewDays(data.user.viewDays);
|
||||
savedViewDaysRef.current = data.user.viewDays;
|
||||
// Re-apply responsive constraints after loading saved preference
|
||||
const width = window.innerWidth;
|
||||
if (width <= 480) setViewDays(1);
|
||||
else if (width <= 768) setViewDays(3);
|
||||
else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5));
|
||||
else setViewDays(data.user.viewDays);
|
||||
}
|
||||
if (data.user.cellDuration !== undefined)
|
||||
setCellDuration(data.user.cellDuration as CellDuration);
|
||||
@ -2064,6 +2071,48 @@ export default function WeeklyView() {
|
||||
setCurrentWeekStart(d);
|
||||
};
|
||||
|
||||
// Touch swipe navigation for mobile
|
||||
useEffect(() => {
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
const diffX = touchEndX - touchStartX;
|
||||
const diffY = touchEndY - touchStartY;
|
||||
// Only trigger if horizontal swipe is dominant and > 80px
|
||||
if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) {
|
||||
if (diffX > 0) {
|
||||
// Swipe right → go to previous day
|
||||
goToPrevDay();
|
||||
} else {
|
||||
// Swipe left → go to next day
|
||||
goToNextDay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const container = document.querySelector('.weekly-container') as HTMLElement | null;
|
||||
if (container) {
|
||||
container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true });
|
||||
container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true });
|
||||
}
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('touchstart', handleTouchStart as EventListener);
|
||||
container.removeEventListener('touchend', handleTouchEnd as EventListener);
|
||||
}
|
||||
};
|
||||
}, [currentWeekStart]); // Re-attach when week changes so closures are fresh
|
||||
|
||||
const executeImport = async (provider: "google" | "apple" | "outlook") => {
|
||||
setImportProvider(provider);
|
||||
setIsImportModalOpen(true);
|
||||
@ -3593,7 +3642,7 @@ export default function WeeklyView() {
|
||||
{/* Refactored Header: Left, Center, Right */}
|
||||
<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" style={{ zIndex: 1 }}>
|
||||
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||
{/* Slot Duration */}
|
||||
{showTimeGrid && (
|
||||
<div
|
||||
@ -3808,7 +3857,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" style={{ zIndex: 1 }}>
|
||||
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||
{/* Undo/Redo */}
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
|
||||
@ -12,6 +12,8 @@ export interface AppleCalendarEvent {
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
recurringEventId?: string;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
|
||||
export interface AppleCalendar {
|
||||
@ -161,13 +163,15 @@ export const getUpcomingEvents = async (
|
||||
|
||||
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||
parsedEvents.push({
|
||||
id: `${exEvent.uid}-${exStart.toISOString()}`,
|
||||
id: `caldav::${eventObj.url}::${exEvent.uid}::${exStart.toISOString()}`,
|
||||
title: exEvent.summary || 'Untitled Event',
|
||||
startDate: exIsAllDay ? exStart.toISOString().slice(0, 10) : exStart.toISOString(),
|
||||
endDate: exIsAllDay ? exEnd.toISOString().slice(0, 10) : exEnd.toISOString(),
|
||||
description: exEvent.description,
|
||||
location: exEvent.location,
|
||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined
|
||||
url: exVevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: exEvent.uid,
|
||||
isRecurring: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -194,13 +198,15 @@ export const getUpcomingEvents = async (
|
||||
if (exceptionDates.has(occStart.toISOString())) continue;
|
||||
|
||||
parsedEvents.push({
|
||||
id: `${event.uid}-${occStart.toISOString()}`,
|
||||
id: `caldav::${eventObj.url}::${event.uid}::${occStart.toISOString()}`,
|
||||
title: event.summary || 'Untitled Event',
|
||||
startDate: isAllDayRecurring ? occStart.toISOString().slice(0, 10) : occStart.toISOString(),
|
||||
endDate: isAllDayRecurring ? occEnd.toISOString().slice(0, 10) : occEnd.toISOString(),
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
recurringEventId: event.uid,
|
||||
isRecurring: true,
|
||||
});
|
||||
}
|
||||
} catch (expandErr: any) {
|
||||
@ -215,13 +221,13 @@ export const getUpcomingEvents = async (
|
||||
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||
|
||||
parsedEvents.push({
|
||||
id: event.uid || eventObj.url,
|
||||
id: `caldav::${eventObj.url}::${event.uid || 'unknown'}`,
|
||||
title: event.summary || 'Untitled Event',
|
||||
startDate: isAllDay ? start.toISOString().slice(0, 10) : start.toISOString(),
|
||||
endDate: isAllDay ? end.toISOString().slice(0, 10) : end.toISOString(),
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined
|
||||
url: vevent.getFirstPropertyValue('url')?.toString() || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -372,116 +378,45 @@ export const updateEvent = async (
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
||||
let targetObject: any = null;
|
||||
|
||||
if (!targetCalendar) {
|
||||
throw new Error(`Calendar not found: ${calendarUrl}`);
|
||||
}
|
||||
|
||||
// Parse IDs - implementation specific
|
||||
// Our getUpcomingEvents returns ID as "UID-filename" or just UID if filename not avail?
|
||||
// Actually getUpcomingEvents returns `${event.uid}-${occStart}` for recurring
|
||||
// or `event.uid || eventObj.url` for simple.
|
||||
|
||||
// We need the original object URL (filename) to update via DAV.
|
||||
// If we only have UID, we have to search for it.
|
||||
|
||||
// Strategy: Fetch all objects in range (expensive?) or try to find by UID?
|
||||
// CALDAV allows query by UID.
|
||||
|
||||
// Let's assume eventId passed in is the UID for now, or we can extract it.
|
||||
const uid = eventId.split('-')[0]; // Simple heuristic
|
||||
|
||||
// Use calendarQuery to find the object by UID
|
||||
// Valid PROP query for getetag and calendar-data
|
||||
|
||||
// Tsdav doesn't expose a simple "findOneByUID".
|
||||
// We'll traverse, assuming we can filter.
|
||||
// Actually, `fetchCalendarObjects` allows filters.
|
||||
|
||||
/*
|
||||
NOTE: tsdav filter support is XML based.
|
||||
Constructing a filter for UID:
|
||||
<filter>
|
||||
<comp-filter name="VCALENDAR">
|
||||
<comp-filter name="VEVENT">
|
||||
<prop-filter name="UID">
|
||||
<text-match collation="i;octet">${uid}</text-match>
|
||||
</prop-filter>
|
||||
</comp-filter>
|
||||
</comp-filter>
|
||||
</filter>
|
||||
*/
|
||||
|
||||
// Since constructing that XML object via tsdav's types might be complex,
|
||||
// let's try a simpler approach if possible, or build the object.
|
||||
|
||||
// For now, let's assume we can fetch objects and find the match in memory if the range isn't too huge?
|
||||
// No, better to search.
|
||||
|
||||
// Let's try to pass a simpler time range around the event if we knew the time.
|
||||
// If not, we scan.
|
||||
// Given we are editing, we usually have the original time.
|
||||
|
||||
// But `eventData` only has NEW data. We might need valid old data.
|
||||
// Let's rely on client logic to pass us enough info?
|
||||
// Wait, `updateCalendarEvent` in `calendar-events.ts` calls us.
|
||||
|
||||
// Let's assume for MVP we fetch objects in a wide range? No.
|
||||
|
||||
// Let's use `fetchCalendarObjects` without timerange -> fetches all? Dangerous for large cals.
|
||||
|
||||
// Alternative: The `eventId` from our `getUpcomingEvents` was `event.uid` (or derived).
|
||||
// Let's try to match by UID.
|
||||
|
||||
// Workaround: We will use a time range if provided in inputs (unlikely for existing?)
|
||||
// Actually, we don't have the OLD time in the `updateEvent` signature here easily unless we fetch.
|
||||
|
||||
// Let's try to standard approach: Fetch all from now - 1 month to + 1 year?
|
||||
// Or just valid `calendar-query` with UID filter.
|
||||
|
||||
// Since constructing the filter manually is hard in this context without xml-js helpers handy...
|
||||
// I will try to fetch the object by its URL if the ID *was* the URL.
|
||||
// In `getUpcomingEvents`, for simple events, we returned `id: event.uid || eventObj.url`.
|
||||
// If it's a URL (ends in .ics), we can just use it.
|
||||
|
||||
let objectUrl = '';
|
||||
const etag = '';
|
||||
const existingIcal = '';
|
||||
|
||||
if (eventId.endsWith('.ics')) {
|
||||
// It looks like a filename/url
|
||||
objectUrl = eventId;
|
||||
// But we need the full URL or relative?
|
||||
// tsdav expects `calendarObject.url`.
|
||||
}
|
||||
|
||||
// If we can't easily find it by ID, we might fail.
|
||||
// Let's assume for this iteration we try to find it.
|
||||
|
||||
const allObjects = await client.fetchCalendarObjects({
|
||||
calendar: targetCalendar,
|
||||
// No time range = all? limit?
|
||||
// Let's check if we can filter by UID in filter object
|
||||
});
|
||||
|
||||
// This fetches ALL objects (headers only usually?).
|
||||
// `fetchCalendarObjects` does report usually.
|
||||
|
||||
// Find matching UID
|
||||
const targetObject = allObjects.find(obj => {
|
||||
// obj.data contains iCal string if expanded?
|
||||
// If not expanded, we might need to fetch data.
|
||||
// By default `fetchCalendarObjects` usually fetches props specified.
|
||||
if (obj.data) {
|
||||
return obj.data.includes(`UID:${uid}`);
|
||||
// Try O(1) path first with new caldav:: ID format
|
||||
const parsed = parseCaldavId(eventId);
|
||||
if (parsed) {
|
||||
// Fetch single object directly by URL (O(1))
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar: { url: calendarUrl } as any,
|
||||
objectUrls: [parsed.objectUrl],
|
||||
});
|
||||
targetObject = objects?.[0] || null;
|
||||
if (!targetObject) {
|
||||
throw new Error('Event not found on server at URL: ' + parsed.objectUrl);
|
||||
}
|
||||
return obj.url.includes(uid); // Fallback assumption
|
||||
});
|
||||
} else {
|
||||
// Legacy fallback: O(n) scan for old-format IDs
|
||||
const calendars = await client.fetchCalendars();
|
||||
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
||||
|
||||
if (!targetObject) {
|
||||
throw new Error('Event not found on server');
|
||||
if (!targetCalendar) {
|
||||
throw new Error(`Calendar not found: ${calendarUrl}`);
|
||||
}
|
||||
|
||||
const uid = eventId.split('-')[0];
|
||||
|
||||
const allObjects = await client.fetchCalendarObjects({
|
||||
calendar: targetCalendar,
|
||||
});
|
||||
|
||||
targetObject = allObjects.find(obj => {
|
||||
if (obj.data) {
|
||||
return obj.data.includes(`UID:${uid}`);
|
||||
}
|
||||
return obj.url.includes(uid);
|
||||
});
|
||||
|
||||
if (!targetObject) {
|
||||
throw new Error('Event not found on server');
|
||||
}
|
||||
}
|
||||
|
||||
// Now we have the object.
|
||||
@ -580,6 +515,20 @@ export const updateEvent = async (
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
/**
|
||||
* Parse the new caldav:: ID format to extract the object URL and UID.
|
||||
* Format: "caldav::<objectUrl>::<uid>[::occurrence-iso]"
|
||||
* Returns null for legacy format IDs.
|
||||
*/
|
||||
function parseCaldavId(eventId: string): { objectUrl: string; uid: string } | null {
|
||||
if (!eventId.startsWith('caldav::')) return null;
|
||||
const parts = eventId.split('::');
|
||||
if (parts.length >= 3) {
|
||||
return { objectUrl: parts[1], uid: parts[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const deleteEvent = async (
|
||||
email: string,
|
||||
appSpecificPassword: string,
|
||||
@ -590,6 +539,18 @@ export const deleteEvent = async (
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
// Try O(1) path first with new caldav:: ID format
|
||||
const parsed = parseCaldavId(eventId);
|
||||
if (parsed) {
|
||||
await client.deleteObject({
|
||||
url: parsed.objectUrl,
|
||||
etag: undefined,
|
||||
} as any);
|
||||
console.log('[APPLE CALENDAR] Event deleted via direct URL (O(1))');
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy fallback: O(n) scan for old-format IDs
|
||||
const calendars = await client.fetchCalendars();
|
||||
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
||||
|
||||
@ -599,9 +560,6 @@ export const deleteEvent = async (
|
||||
|
||||
const uid = eventId.split('-')[0];
|
||||
|
||||
// Find object - similar logic to update
|
||||
// Optimal: Pass the object URL in the ID in getUpcomingEvents to allow O(1) delete/update
|
||||
|
||||
const allObjects = await client.fetchCalendarObjects({
|
||||
calendar: targetCalendar,
|
||||
});
|
||||
@ -618,13 +576,12 @@ export const deleteEvent = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// Same issue as update - use deleteObject directly
|
||||
await client.deleteObject({
|
||||
url: targetObject.url,
|
||||
etag: targetObject.etag
|
||||
} as any); // Cast to any to bypass type definition mismatch
|
||||
} as any);
|
||||
|
||||
console.log('[APPLE CALENDAR] Event deleted successfully');
|
||||
console.log('[APPLE CALENDAR] Event deleted via legacy scan');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[APPLE CALENDAR] Error deleting event:', error);
|
||||
|
||||
@ -68,6 +68,10 @@ export async function readCachedEvents(
|
||||
id: row.externalId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
location: row.location,
|
||||
url: row.url,
|
||||
recurringEventId: row.recurringEventId,
|
||||
isRecurring: row.isRecurring,
|
||||
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
|
||||
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
|
||||
source: row.provider as 'google' | 'apple' | 'outlook',
|
||||
@ -116,6 +120,9 @@ export async function refreshConnectionCache(
|
||||
title: ev.title,
|
||||
description: ev.description ?? null,
|
||||
location: ev.location ?? null,
|
||||
url: ev.url ?? null,
|
||||
recurringEventId: ev.recurringEventId ?? null,
|
||||
isRecurring: ev.isRecurring ?? false,
|
||||
startDateTime: ev.start.dateTime ? new Date(ev.start.dateTime) : null,
|
||||
startDate: ev.start.date ?? null,
|
||||
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
|
||||
@ -167,6 +174,9 @@ export async function upsertCachedEvent(
|
||||
title: event.title,
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
url: event.url ?? null,
|
||||
recurringEventId: event.recurringEventId ?? null,
|
||||
isRecurring: event.isRecurring ?? false,
|
||||
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
|
||||
startDate: event.start.date ?? null,
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
@ -180,6 +190,9 @@ export async function upsertCachedEvent(
|
||||
title: event.title,
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
url: event.url ?? null,
|
||||
recurringEventId: event.recurringEventId ?? null,
|
||||
isRecurring: event.isRecurring ?? false,
|
||||
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
|
||||
startDate: event.start.date ?? null,
|
||||
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
|
||||
|
||||
@ -21,6 +21,8 @@ export interface CalendarEvent {
|
||||
location?: string;
|
||||
url?: string;
|
||||
recurrence?: string;
|
||||
recurringEventId?: string;
|
||||
isRecurring?: boolean;
|
||||
source: 'google' | 'apple' | 'outlook';
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
@ -361,6 +363,8 @@ export const getCalendarEvents = async (
|
||||
},
|
||||
location: event.location,
|
||||
url: event.url,
|
||||
recurringEventId: event.recurringEventId,
|
||||
isRecurring: event.isRecurring,
|
||||
source: 'apple' as const,
|
||||
calendarId,
|
||||
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
|
||||
|
||||
383
src/lib/quotes.ts
Normal file
383
src/lib/quotes.ts
Normal file
@ -0,0 +1,383 @@
|
||||
// quotes.ts - Curated quotes collection and utility functions
|
||||
|
||||
export interface LocalQuote {
|
||||
text: string;
|
||||
author: string;
|
||||
language: "de" | "en";
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const PRESET_QUOTE_SOURCES = [
|
||||
{
|
||||
name: "ZenQuotes",
|
||||
url: "https://zenquotes.io/api/random",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom", "life"],
|
||||
},
|
||||
{
|
||||
name: "Quotable",
|
||||
url: "https://api.quotable.io/random",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom", "success", "life"],
|
||||
},
|
||||
{
|
||||
name: "Forismatic",
|
||||
url: "https://api.forismatic.com/api/1.0/?method=getQuote&format=json&lang=de",
|
||||
language: "de",
|
||||
tags: ["motivation", "wisdom", "life"],
|
||||
},
|
||||
{
|
||||
name: "Type.fit",
|
||||
url: "https://type.fit/api/quotes",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const LOCAL_QUOTES: LocalQuote[] = [
|
||||
// --- German Quotes ---
|
||||
{
|
||||
text: "Es ist nicht genug zu wissen, man muss auch anwenden; es ist nicht genug zu wollen, man muss auch tun.",
|
||||
author: "Johann Wolfgang von Goethe",
|
||||
language: "de",
|
||||
tags: ["motivation", "work", "perseverance"],
|
||||
},
|
||||
{
|
||||
text: "Wer immer tut, was er schon kann, bleibt immer das, was er schon ist.",
|
||||
author: "Henry Ford",
|
||||
language: "de",
|
||||
tags: ["motivation", "success", "perseverance"],
|
||||
},
|
||||
{
|
||||
text: "Der Zweck des Lebens ist das Leben selbst.",
|
||||
author: "Johann Wolfgang von Goethe",
|
||||
language: "de",
|
||||
tags: ["life", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "Man muss das Unmogliche versuchen, um das Mogliche zu erreichen.",
|
||||
author: "Hermann Hesse",
|
||||
language: "de",
|
||||
tags: ["motivation", "perseverance"],
|
||||
},
|
||||
{
|
||||
text: "Wer nicht kann, was er will, muss wollen, was er kann.",
|
||||
author: "Friedrich Schiller",
|
||||
language: "de",
|
||||
tags: ["wisdom", "perseverance", "life"],
|
||||
},
|
||||
{
|
||||
text: "Es hort doch jeder nur, was er versteht.",
|
||||
author: "Johann Wolfgang von Goethe",
|
||||
language: "de",
|
||||
tags: ["wisdom", "life"],
|
||||
},
|
||||
{
|
||||
text: "Ohne Musik ware das Leben ein Irrtum.",
|
||||
author: "Friedrich Nietzsche",
|
||||
language: "de",
|
||||
tags: ["life", "creativity"],
|
||||
},
|
||||
{
|
||||
text: "Wege entstehen dadurch, dass man sie geht.",
|
||||
author: "Franz Kafka",
|
||||
language: "de",
|
||||
tags: ["motivation", "perseverance", "life"],
|
||||
},
|
||||
{
|
||||
text: "Der Mensch ist nichts anderes als wozu er sich macht.",
|
||||
author: "Jean-Paul Sartre",
|
||||
language: "de",
|
||||
tags: ["wisdom", "life", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "Wer kampft, kann verlieren. Wer nicht kampft, hat schon verloren.",
|
||||
author: "Bertolt Brecht",
|
||||
language: "de",
|
||||
tags: ["motivation", "perseverance", "success"],
|
||||
},
|
||||
{
|
||||
text: "Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt.",
|
||||
author: "Ludwig Wittgenstein",
|
||||
language: "de",
|
||||
tags: ["wisdom", "creativity"],
|
||||
},
|
||||
{
|
||||
text: "Was mich nicht umbringt, macht mich starker.",
|
||||
author: "Friedrich Nietzsche",
|
||||
language: "de",
|
||||
tags: ["perseverance", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "Phantasie ist wichtiger als Wissen, denn Wissen ist begrenzt.",
|
||||
author: "Albert Einstein",
|
||||
language: "de",
|
||||
tags: ["creativity", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "Nur wer sein Ziel kennt, findet den Weg.",
|
||||
author: "Laozi",
|
||||
language: "de",
|
||||
tags: ["wisdom", "motivation", "success"],
|
||||
},
|
||||
{
|
||||
text: "Die beste Zeit einen Baum zu pflanzen war vor zwanzig Jahren. Die zweitbeste Zeit ist jetzt.",
|
||||
author: "Konfuzius",
|
||||
language: "de",
|
||||
tags: ["wisdom", "motivation", "work"],
|
||||
},
|
||||
{
|
||||
text: "Auch aus Steinen, die einem in den Weg gelegt werden, kann man Schones bauen.",
|
||||
author: "Johann Wolfgang von Goethe",
|
||||
language: "de",
|
||||
tags: ["perseverance", "creativity", "life"],
|
||||
},
|
||||
{
|
||||
text: "Handle, ehe du gehandelt wirst.",
|
||||
author: "Friedrich Schiller",
|
||||
language: "de",
|
||||
tags: ["motivation", "work"],
|
||||
},
|
||||
{
|
||||
text: "Geduld ist das Vertrauen, dass alles kommt, wenn die Zeit reif ist.",
|
||||
author: "Konfuzius",
|
||||
language: "de",
|
||||
tags: ["wisdom", "perseverance", "life"],
|
||||
},
|
||||
{
|
||||
text: "Im Wesen der Musik liegt es, Freude zu machen.",
|
||||
author: "Aristoteles",
|
||||
language: "de",
|
||||
tags: ["creativity", "life", "humor"],
|
||||
},
|
||||
{
|
||||
text: "Lache nie uber die Dummheit der anderen. Sie ist deine Chance.",
|
||||
author: "Winston Churchill",
|
||||
language: "de",
|
||||
tags: ["humor", "success", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "Leben ist das, was passiert, wahrend du eifrig dabei bist, andere Plane zu machen.",
|
||||
author: "John Lennon",
|
||||
language: "de",
|
||||
tags: ["life", "humor", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "Der Langsamste, der sein Ziel nicht aus den Augen verliert, geht noch immer geschwinder als jener, der ohne Ziel umherirrt.",
|
||||
author: "Gotthold Ephraim Lessing",
|
||||
language: "de",
|
||||
tags: ["perseverance", "motivation", "work"],
|
||||
},
|
||||
{
|
||||
text: "Ein Tropfen Humor auf einen Zentner Verstand.",
|
||||
author: "Friedrich Nietzsche",
|
||||
language: "de",
|
||||
tags: ["humor", "wisdom"],
|
||||
},
|
||||
// --- English Quotes ---
|
||||
{
|
||||
text: "The only way to do great work is to love what you do.",
|
||||
author: "Steve Jobs",
|
||||
language: "en",
|
||||
tags: ["work", "motivation", "success"],
|
||||
},
|
||||
{
|
||||
text: "Stay hungry, stay foolish.",
|
||||
author: "Steve Jobs",
|
||||
language: "en",
|
||||
tags: ["motivation", "creativity"],
|
||||
},
|
||||
{
|
||||
text: "Life is what happens when you are busy making other plans.",
|
||||
author: "John Lennon",
|
||||
language: "en",
|
||||
tags: ["life", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "In the middle of difficulty lies opportunity.",
|
||||
author: "Albert Einstein",
|
||||
language: "en",
|
||||
tags: ["perseverance", "motivation", "success"],
|
||||
},
|
||||
{
|
||||
text: "The unexamined life is not worth living.",
|
||||
author: "Socrates",
|
||||
language: "en",
|
||||
tags: ["wisdom", "life"],
|
||||
},
|
||||
{
|
||||
text: "Be yourself; everyone else is already taken.",
|
||||
author: "Oscar Wilde",
|
||||
language: "en",
|
||||
tags: ["life", "wisdom", "humor"],
|
||||
},
|
||||
{
|
||||
text: "To live is the rarest thing in the world. Most people exist, that is all.",
|
||||
author: "Oscar Wilde",
|
||||
language: "en",
|
||||
tags: ["life", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "The man who moves a mountain begins by carrying away small stones.",
|
||||
author: "Konfuzius",
|
||||
language: "en",
|
||||
tags: ["perseverance", "motivation", "work"],
|
||||
},
|
||||
{
|
||||
text: "It is not because things are difficult that we do not dare; it is because we do not dare that they are difficult.",
|
||||
author: "Seneca",
|
||||
language: "en",
|
||||
tags: ["motivation", "perseverance", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "The happiness of your life depends upon the quality of your thoughts.",
|
||||
author: "Marcus Aurelius",
|
||||
language: "en",
|
||||
tags: ["wisdom", "life"],
|
||||
},
|
||||
{
|
||||
text: "Waste no more time arguing about what a good man should be. Be one.",
|
||||
author: "Marcus Aurelius",
|
||||
language: "en",
|
||||
tags: ["wisdom", "motivation", "work"],
|
||||
},
|
||||
{
|
||||
text: "If you want to lift yourself up, lift up someone else.",
|
||||
author: "Booker T. Washington",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom", "life"],
|
||||
},
|
||||
{
|
||||
text: "Darkness cannot drive out darkness; only light can do that.",
|
||||
author: "Martin Luther King Jr.",
|
||||
language: "en",
|
||||
tags: ["wisdom", "life", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "The time is always right to do what is right.",
|
||||
author: "Martin Luther King Jr.",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom", "work"],
|
||||
},
|
||||
{
|
||||
text: "There is no greater agony than bearing an untold story inside you.",
|
||||
author: "Maya Angelou",
|
||||
language: "en",
|
||||
tags: ["creativity", "life"],
|
||||
},
|
||||
{
|
||||
text: "We delight in the beauty of the butterfly, but rarely admit the changes it has gone through to achieve that beauty.",
|
||||
author: "Maya Angelou",
|
||||
language: "en",
|
||||
tags: ["perseverance", "life", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "The secret of getting ahead is getting started.",
|
||||
author: "Mark Twain",
|
||||
language: "en",
|
||||
tags: ["motivation", "work", "success"],
|
||||
},
|
||||
{
|
||||
text: "Whenever you find yourself on the side of the majority, it is time to pause and reflect.",
|
||||
author: "Mark Twain",
|
||||
language: "en",
|
||||
tags: ["wisdom", "creativity"],
|
||||
},
|
||||
{
|
||||
text: "Twenty years from now you will be more disappointed by the things you did not do than by the ones you did.",
|
||||
author: "Mark Twain",
|
||||
language: "en",
|
||||
tags: ["motivation", "life", "perseverance"],
|
||||
},
|
||||
{
|
||||
text: "It does not matter how slowly you go as long as you do not stop.",
|
||||
author: "Konfuzius",
|
||||
language: "en",
|
||||
tags: ["perseverance", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "Success is not final, failure is not fatal: it is the courage to continue that counts.",
|
||||
author: "Winston Churchill",
|
||||
language: "en",
|
||||
tags: ["perseverance", "success", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "The best time to plant a tree was 20 years ago. The second best time is now.",
|
||||
author: "Chinese Proverb",
|
||||
language: "en",
|
||||
tags: ["motivation", "wisdom", "work"],
|
||||
},
|
||||
{
|
||||
text: "Do what you can, with what you have, where you are.",
|
||||
author: "Theodore Roosevelt",
|
||||
language: "en",
|
||||
tags: ["motivation", "work", "perseverance"],
|
||||
},
|
||||
{
|
||||
text: "Creativity is intelligence having fun.",
|
||||
author: "Albert Einstein",
|
||||
language: "en",
|
||||
tags: ["creativity", "humor", "wisdom"],
|
||||
},
|
||||
{
|
||||
text: "Everything you can imagine is real.",
|
||||
author: "Pablo Picasso",
|
||||
language: "en",
|
||||
tags: ["creativity", "motivation"],
|
||||
},
|
||||
{
|
||||
text: "I have not failed. I have just found 10,000 ways that will not work.",
|
||||
author: "Thomas Edison",
|
||||
language: "en",
|
||||
tags: ["perseverance", "humor", "success"],
|
||||
},
|
||||
{
|
||||
text: "Well done is better than well said.",
|
||||
author: "Benjamin Franklin",
|
||||
language: "en",
|
||||
tags: ["work", "motivation", "success"],
|
||||
},
|
||||
{
|
||||
text: "You miss 100% of the shots you do not take.",
|
||||
author: "Wayne Gretzky",
|
||||
language: "en",
|
||||
tags: ["motivation", "success"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* All unique tags available across the local quotes collection.
|
||||
*/
|
||||
export const AVAILABLE_QUOTE_TAGS: string[] = Array.from(
|
||||
new Set(LOCAL_QUOTES.flatMap((q) => q.tags))
|
||||
).sort();
|
||||
|
||||
/**
|
||||
* Returns a random quote from the local collection, optionally filtered
|
||||
* by language and/or tag.
|
||||
*
|
||||
* @param language - Filter by "de" or "en". If omitted, picks from all.
|
||||
* @param tag - Filter by a specific tag (e.g. "motivation"). If omitted, no tag filter.
|
||||
* @returns A random LocalQuote matching the filters, or a fallback if no match found.
|
||||
*/
|
||||
export function getRandomLocalQuote(
|
||||
language?: string,
|
||||
tag?: string
|
||||
): LocalQuote {
|
||||
let pool = LOCAL_QUOTES;
|
||||
|
||||
if (language) {
|
||||
pool = pool.filter((q) => q.language === language);
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
pool = pool.filter((q) => q.tags.includes(tag));
|
||||
}
|
||||
|
||||
if (pool.length === 0) {
|
||||
// Fallback: return a random quote from the full collection
|
||||
return LOCAL_QUOTES[Math.floor(Math.random() * LOCAL_QUOTES.length)];
|
||||
}
|
||||
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user