feat: add Notion provider integration + Apple Reminders deprecation note
- Notion OAuth integration: start/callback routes, calendar-events dispatch, CRUD operations (create/update/delete via Notion API) - New notion-calendar.ts provider library with database discovery - Onboarding wizard: expanded to 6 steps (header/tasks/display design), improved preview fidelity, universal dummy content, Notion in connect step - Apple Calendar: label changed to "events only", added warning that Reminders are unsupported since iOS 13 (no CalDAV/API from Apple) - Fixed 12h time format on now-line, wizard settings apply on completion v1.57.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4ac73d3e03
commit
ee4b540558
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.36.1",
|
||||
"version": "1.56.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.36.1",
|
||||
"version": "1.56.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@ -15,6 +15,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@notionhq/client": "^5.14.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
@ -2269,6 +2270,15 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@notionhq/client": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@notionhq/client/-/client-5.14.0.tgz",
|
||||
"integrity": "sha512-9bbH7/9M6D9YlHMYCZ1aAFxRCWiKRBpP/XOnAHFtBCFDf00PPhpWRSsGE1FfmjYCNW2BFRj19WshJFH5IFfNvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.56.0",
|
||||
"version": "1.57.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": {
|
||||
@ -27,6 +27,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@notionhq/client": "^5.14.0",
|
||||
"@prisma/client": "5.22.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
|
||||
124
src/app/api/calendar/notion/callback/route.ts
Normal file
124
src/app/api/calendar/notion/callback/route.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getUserDatabases } from '@/lib/notion-calendar';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
|
||||
}
|
||||
|
||||
const userId = (session?.user as any)?.id || state;
|
||||
if (!userId) {
|
||||
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', appBaseUrl));
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const clientId = process.env.NOTION_CLIENT_ID || '';
|
||||
const clientSecret = process.env.NOTION_CLIENT_SECRET || '';
|
||||
const redirectUri = process.env.NOTION_REDIRECT_URI
|
||||
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
|
||||
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
|
||||
const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error('Notion token exchange failed:', errorText);
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token || null;
|
||||
|
||||
// Fetch databases shared with the integration
|
||||
const databases = await getUserDatabases(accessToken);
|
||||
|
||||
// Map to calendar-style format for consistency
|
||||
const calendars = databases.map(db => ({
|
||||
id: db.id,
|
||||
title: db.title,
|
||||
selected: true,
|
||||
dateProperty: db.dateProperty,
|
||||
backgroundColor: '#000000',
|
||||
}));
|
||||
|
||||
// Check for existing connection
|
||||
const existingConnection = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'notion' },
|
||||
});
|
||||
|
||||
if (existingConnection) {
|
||||
// Merge selection state
|
||||
let finalCalendars = calendars;
|
||||
if (existingConnection.calendars && Array.isArray(existingConnection.calendars)) {
|
||||
const existingList = existingConnection.calendars as any[];
|
||||
finalCalendars = calendars.map(remote => {
|
||||
const match = existingList.find((e: any) => e.id === remote.id);
|
||||
return {
|
||||
...remote,
|
||||
selected: match ? match.selected : true,
|
||||
dateProperty: remote.dateProperty || match?.dateProperty,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.calendarConnection.update({
|
||||
where: { id: existingConnection.id },
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken: refreshToken || existingConnection.refreshToken,
|
||||
calendars: finalCalendars,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.calendarConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: 'notion',
|
||||
accessToken,
|
||||
refreshToken,
|
||||
calendars,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/tasks?calendar=connected&openSettings=calendars', appBaseUrl));
|
||||
} catch (error) {
|
||||
console.error('Notion OAuth error:', error);
|
||||
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
|
||||
}
|
||||
}
|
||||
43
src/app/api/calendar/notion/start/route.ts
Normal file
43
src/app/api/calendar/notion/start/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
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));
|
||||
}
|
||||
|
||||
const clientId = process.env.NOTION_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Notion OAuth not configured. Please set NOTION_CLIENT_ID and NOTION_CLIENT_SECRET.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = process.env.NOTION_REDIRECT_URI
|
||||
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
|
||||
|
||||
const authUrl = new URL('https://api.notion.com/v1/oauth/authorize');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('owner', 'user');
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.set('state', userId);
|
||||
|
||||
return NextResponse.redirect(authUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Error initiating Notion OAuth:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to initiate Notion connection' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4908,9 +4908,26 @@ h3 {
|
||||
@media (max-width: 480px) {
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-width: 100% !important;
|
||||
border-radius: 0;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.onboarding-card {
|
||||
max-width: 95% !important;
|
||||
}
|
||||
.onboarding-wide-step {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.onboarding-wide-step .onboarding-settings-panel {
|
||||
width: 100% !important;
|
||||
max-height: none !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
.onboarding-wide-step .onboarding-preview-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ import CalendarEventModal from "./CalendarEventModal";
|
||||
import TaskRecurrenceModal from "./RecurrenceModal";
|
||||
import { GridTaskBlock } from "./GridTaskBlock";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar,
|
||||
faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop,
|
||||
@ -161,7 +161,7 @@ interface CalendarEvent {
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
source: "google" | "apple" | "outlook" | "synology";
|
||||
source: "google" | "apple" | "outlook" | "synology" | "notion";
|
||||
calendarId?: string;
|
||||
calendarTitle?: string;
|
||||
calendarColor?: string;
|
||||
@ -290,7 +290,9 @@ const translations: Record<string, any> = {
|
||||
connectMore: "Connect More",
|
||||
connectGoogle: "Connect Google Calendar",
|
||||
connectApple: "Connect Apple Calendar",
|
||||
appleRemindersNote: "Apple Reminders are not supported. Since iOS 13 / macOS Catalina, Apple no longer provides a CalDAV or public API for Reminders. Only calendar events can be synced.",
|
||||
connectSynology: "Connect Synology",
|
||||
connectNotion: "Connect Notion",
|
||||
noCalendars: "No calendars connected yet.",
|
||||
dataPrivacy: "Data & Privacy",
|
||||
downloadData: "Download My Data",
|
||||
@ -381,6 +383,7 @@ const translations: Record<string, any> = {
|
||||
styling: "Styling",
|
||||
motivation: "Motivation",
|
||||
about: "About",
|
||||
setupAssistant: "Run Setup Assistant",
|
||||
weekStartLabel: "Start week on",
|
||||
startViewLabel: "Start view on",
|
||||
monday: "Monday",
|
||||
@ -504,6 +507,8 @@ const translations: Record<string, any> = {
|
||||
connectMore: "Mehr verbinden",
|
||||
connectGoogle: "Google Kalender verbinden",
|
||||
connectApple: "Apple Kalender verbinden",
|
||||
appleRemindersNote: "Apple Erinnerungen werden nicht unterstützt. Seit iOS 13 / macOS Catalina bietet Apple keine CalDAV- oder öffentliche API mehr für Erinnerungen an. Nur Kalender-Ereignisse können synchronisiert werden.",
|
||||
connectNotion: "Notion verbinden",
|
||||
noCalendars: "Keine Kalender verbunden.",
|
||||
dataPrivacy: "Daten & Datenschutz",
|
||||
downloadData: "Meine Daten herunterladen",
|
||||
@ -594,6 +599,7 @@ const translations: Record<string, any> = {
|
||||
styling: "Design",
|
||||
motivation: "Motivation",
|
||||
about: "Über",
|
||||
setupAssistant: "Einrichtungsassistent starten",
|
||||
weekStartLabel: "Woche beginnt am",
|
||||
startViewLabel: "Ansicht beginnt mit",
|
||||
monday: "Montag",
|
||||
@ -715,7 +721,9 @@ const translations: Record<string, any> = {
|
||||
connectMore: "En connecter d'autres",
|
||||
connectGoogle: "Connecter Google Agenda",
|
||||
connectApple: "Connecter le calendrier Apple",
|
||||
appleRemindersNote: "Les rappels Apple ne sont pas pris en charge. Depuis iOS 13 / macOS Catalina, Apple ne fournit plus de CalDAV ni d'API publique pour les rappels. Seuls les événements de calendrier peuvent être synchronisés.",
|
||||
connectSynology: "Connecter Synology",
|
||||
connectNotion: "Connecter Notion",
|
||||
noCalendars: "Aucun calendrier connecté.",
|
||||
dataPrivacy: "Données et confidentialité",
|
||||
downloadData: "Télécharger mes données",
|
||||
@ -806,6 +814,7 @@ const translations: Record<string, any> = {
|
||||
styling: "Style",
|
||||
motivation: "Motivation",
|
||||
about: "À propos",
|
||||
setupAssistant: "Lancer l'assistant de configuration",
|
||||
weekStartLabel: "La semaine commence le",
|
||||
startViewLabel: "Vue commence par",
|
||||
monday: "Lundi",
|
||||
@ -927,7 +936,9 @@ const translations: Record<string, any> = {
|
||||
connectMore: "Conectar más",
|
||||
connectGoogle: "Conectar Google Calendar",
|
||||
connectApple: "Conectar calendario de Apple",
|
||||
appleRemindersNote: "Los recordatorios de Apple no son compatibles. Desde iOS 13 / macOS Catalina, Apple ya no ofrece CalDAV ni una API pública para recordatorios. Solo se pueden sincronizar eventos del calendario.",
|
||||
connectSynology: "Conectar Synology",
|
||||
connectNotion: "Conectar Notion",
|
||||
noCalendars: "No hay calendarios conectados.",
|
||||
dataPrivacy: "Datos y privacidad",
|
||||
downloadData: "Descargar mis datos",
|
||||
@ -1018,6 +1029,7 @@ const translations: Record<string, any> = {
|
||||
styling: "Estilo",
|
||||
motivation: "Motivación",
|
||||
about: "Acerca de",
|
||||
setupAssistant: "Iniciar asistente de configuración",
|
||||
weekStartLabel: "La semana empieza el",
|
||||
startViewLabel: "Vista empieza con",
|
||||
monday: "Lunes",
|
||||
@ -1139,7 +1151,9 @@ const translations: Record<string, any> = {
|
||||
connectMore: "Collega altri",
|
||||
connectGoogle: "Collega Google Calendar",
|
||||
connectApple: "Collega il calendario Apple",
|
||||
appleRemindersNote: "I promemoria Apple non sono supportati. Da iOS 13 / macOS Catalina, Apple non fornisce più CalDAV o un'API pubblica per i promemoria. Solo gli eventi del calendario possono essere sincronizzati.",
|
||||
connectSynology: "Collega Synology",
|
||||
connectNotion: "Collega Notion",
|
||||
noCalendars: "Nessun calendario collegato.",
|
||||
dataPrivacy: "Dati e privacy",
|
||||
downloadData: "Scarica i miei dati",
|
||||
@ -1230,6 +1244,7 @@ const translations: Record<string, any> = {
|
||||
styling: "Stile",
|
||||
motivation: "Motivazione",
|
||||
about: "Info",
|
||||
setupAssistant: "Assistente di configurazione",
|
||||
weekStartLabel: "La settimana inizia il",
|
||||
startViewLabel: "Vista inizia con",
|
||||
monday: "Lunedì",
|
||||
@ -7167,7 +7182,7 @@ export default function WeeklyView() {
|
||||
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||
const topPosition =
|
||||
minutesSinceStart * pixelsPerMinute;
|
||||
const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`;
|
||||
const timeString = formatHour(nowHour, nowMinute, "full", timeFormat);
|
||||
return (
|
||||
<div
|
||||
className="now-line"
|
||||
@ -8066,7 +8081,7 @@ export default function WeeklyView() {
|
||||
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
||||
const iconContent = (
|
||||
<span
|
||||
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
|
||||
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider === "notion" ? "Notion" : list.externalProvider}`}
|
||||
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
|
||||
>
|
||||
{list.externalProvider === "outlook" ? (
|
||||
@ -8077,6 +8092,8 @@ export default function WeeklyView() {
|
||||
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
|
||||
) : list.externalProvider === "synology" ? (
|
||||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
|
||||
) : list.externalProvider === "notion" ? (
|
||||
<FontAwesomeIcon icon={faNotion} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#000] dark:hover:text-[#fff] transition-colors" />
|
||||
) : (
|
||||
<RefreshCcw size={14} className="text-zinc-400" />
|
||||
)}
|
||||
@ -8757,7 +8774,12 @@ export default function WeeklyView() {
|
||||
<SettingsSidebar
|
||||
initialTab={activeTab}
|
||||
onRemoveConnection={handleRemoveConnection}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onClose={() => {
|
||||
setShowSettings(false);
|
||||
if (profile.hasCompletedOnboarding === false && localStorage.getItem("onboarding_step")) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}}
|
||||
onSettingsChanged={handleSettingsChanged}
|
||||
showTimeGrid={showTimeGrid}
|
||||
setShowTimeGrid={setShowTimeGrid}
|
||||
@ -8875,6 +8897,7 @@ export default function WeeklyView() {
|
||||
saveViewSetting: saveViewSetting as any,
|
||||
getEffective: getEffective as any,
|
||||
}}
|
||||
onRunSetupAssistant={() => setShowOnboarding(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -8897,11 +8920,16 @@ export default function WeeklyView() {
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
connections={connections}
|
||||
onComplete={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
||||
onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
||||
onComplete={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||||
onSkip={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||||
saveSetting={saveSetting}
|
||||
onLanguageChange={(lang: string) => { setLanguage(lang); }}
|
||||
onDarkModeToggle={() => setDarkMode(!darkMode)}
|
||||
onConnectProvider={(provider: string) => {
|
||||
setShowOnboarding(false);
|
||||
setActiveTab("calendar");
|
||||
setShowSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -9744,6 +9772,7 @@ function TaskItem({
|
||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||
notion: { icon: faNotion, color: "#000000", label: "Notion" },
|
||||
};
|
||||
const info = iconMap[provider];
|
||||
if (!info) return null;
|
||||
@ -10646,6 +10675,7 @@ interface SettingsSidebarProps {
|
||||
saveViewSetting: (key: string, value: any, perView: boolean) => void;
|
||||
getEffective: (key: string, globalVal: any) => any;
|
||||
};
|
||||
onRunSetupAssistant?: () => void;
|
||||
}
|
||||
// Notes Sidebar Component
|
||||
interface NotesSidebarProps {
|
||||
@ -10871,6 +10901,7 @@ function SettingsSidebar({
|
||||
isMobile: isMobileSidebar,
|
||||
mobileActions,
|
||||
perView,
|
||||
onRunSetupAssistant,
|
||||
}: SettingsSidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
||||
@ -11084,6 +11115,10 @@ function SettingsSidebar({
|
||||
window.location.href = "/api/calendar/outlook/start";
|
||||
};
|
||||
|
||||
const handleNotionConnect = () => {
|
||||
window.location.href = "/api/calendar/notion/start";
|
||||
};
|
||||
|
||||
const handleUpdateCalendar = async (
|
||||
connectionId: string,
|
||||
calendarId: string,
|
||||
@ -12191,7 +12226,9 @@ function SettingsSidebar({
|
||||
? <FontAwesomeIcon icon={faApple} />
|
||||
: conn.provider === "synology"
|
||||
? <FontAwesomeIcon icon={faServer} />
|
||||
: <FontAwesomeIcon icon={faMicrosoft} />}
|
||||
: conn.provider === "notion"
|
||||
? <FontAwesomeIcon icon={faNotion} />
|
||||
: <FontAwesomeIcon icon={faMicrosoft} />}
|
||||
</span>
|
||||
{conn.provider === "google"
|
||||
? "Google Calendar"
|
||||
@ -12199,7 +12236,9 @@ function SettingsSidebar({
|
||||
? "Apple Calendar"
|
||||
: conn.provider === "synology"
|
||||
? "Synology Calendar"
|
||||
: "Outlook Calendar"}
|
||||
: conn.provider === "notion"
|
||||
? "Notion"
|
||||
: "Outlook Calendar"}
|
||||
</div>
|
||||
{confirmDisconnectId === conn.id ? (
|
||||
<div
|
||||
@ -12418,7 +12457,9 @@ function SettingsSidebar({
|
||||
? t.noCalendarsApple
|
||||
: conn.provider === "synology"
|
||||
? t.noCalendarsSynology
|
||||
: t.selectionAfterConnect}
|
||||
: conn.provider === "notion"
|
||||
? t.selectionAfterConnect
|
||||
: t.selectionAfterConnect}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@ -12461,6 +12502,12 @@ function SettingsSidebar({
|
||||
>
|
||||
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNotionConnect}
|
||||
className="calendar-connect-btn"
|
||||
>
|
||||
<FontAwesomeIcon icon={faNotion} className="mr-2" /> {t.connectNotion || "Connect Notion"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
@ -14332,6 +14379,24 @@ function SettingsSidebar({
|
||||
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { onRunSetupAssistant?.(); }}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
|
||||
width: "100%", padding: "12px 20px", borderRadius: "10px",
|
||||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
background: "var(--weekly-bg, #fff)",
|
||||
color: "var(--weekly-text, #333)",
|
||||
fontSize: "0.9rem", fontWeight: 500, cursor: "pointer",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--weekly-hover, #f3f4f6)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--weekly-bg, #fff)"; }}
|
||||
>
|
||||
<Play size={16} />
|
||||
{t.setupAssistant}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Account Tab */
|
||||
@ -15035,6 +15100,13 @@ function SettingsSidebar({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded p-3 mb-4 text-sm text-amber-800" style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: "1rem", flexShrink: 0 }}>⚠️</span>
|
||||
<p style={{ margin: 0, fontSize: "0.8rem", lineHeight: 1.4 }}>
|
||||
{t.appleRemindersNote}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{appleCalError && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||||
{appleCalError}
|
||||
|
||||
@ -2,6 +2,7 @@ import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomin
|
||||
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar';
|
||||
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
|
||||
import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
|
||||
import { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@ -42,7 +43,7 @@ export interface CalendarEvent {
|
||||
recurrence?: string;
|
||||
recurringEventId?: string;
|
||||
isRecurring?: boolean;
|
||||
source: 'google' | 'apple' | 'outlook' | 'synology';
|
||||
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
|
||||
calendarId: string;
|
||||
calendarTitle: string;
|
||||
backgroundColor?: string;
|
||||
@ -581,6 +582,78 @@ export const getCalendarEvents = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (connection.provider === 'notion') {
|
||||
console.log('[CALENDAR] Processing Notion connection:', connection.id);
|
||||
|
||||
// Refresh token if available
|
||||
if (connection.refreshToken) {
|
||||
try {
|
||||
const refreshed = await refreshNotionToken(
|
||||
connection.refreshToken,
|
||||
process.env.NOTION_CLIENT_ID || '',
|
||||
process.env.NOTION_CLIENT_SECRET || '',
|
||||
);
|
||||
accessToken = refreshed.accessToken;
|
||||
await prisma.calendarConnection.update({
|
||||
where: { id: connection.id },
|
||||
data: {
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (refreshErr) {
|
||||
console.error('[CALENDAR] Notion token refresh failed, using existing token:', refreshErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected databases
|
||||
const databases = (connection.calendars as any[] || []).filter((c: any) => c.selected !== false);
|
||||
if (databases.length === 0) {
|
||||
console.log('[CALENDAR] Notion: no databases selected, skipping');
|
||||
}
|
||||
|
||||
for (const db of databases) {
|
||||
if (!db.dateProperty) {
|
||||
console.log(`[CALENDAR] Notion: database "${db.title}" has no date property, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const notionEvents = await getNotionEvents(
|
||||
accessToken,
|
||||
db.id,
|
||||
db.dateProperty,
|
||||
timeMin,
|
||||
timeMax,
|
||||
db.title,
|
||||
);
|
||||
|
||||
console.log(`[CALENDAR] Notion: fetched ${notionEvents.length} events from "${db.title}"`);
|
||||
|
||||
events = events.concat(notionEvents.map((ne) => ({
|
||||
id: ne.id,
|
||||
title: ne.title,
|
||||
description: ne.description,
|
||||
start: {
|
||||
dateTime: ne.allDay ? undefined : ne.start,
|
||||
date: ne.allDay ? ne.start : undefined,
|
||||
},
|
||||
end: {
|
||||
dateTime: ne.allDay ? undefined : (ne.end || ne.start),
|
||||
date: ne.allDay ? (ne.end || ne.start) : undefined,
|
||||
},
|
||||
location: undefined,
|
||||
url: ne.url,
|
||||
source: 'notion' as const,
|
||||
calendarId: db.id,
|
||||
calendarTitle: db.title,
|
||||
backgroundColor: '#000000',
|
||||
})));
|
||||
} catch (dbError) {
|
||||
console.error(`[CALENDAR] Notion: error fetching from "${db.title}":`, dbError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CALENDAR] Total events for connection:', events.length);
|
||||
@ -868,13 +941,44 @@ export const createCalendarEvent = async (
|
||||
id: createdEvent.id,
|
||||
title: createdEvent.title,
|
||||
description: createdEvent.description,
|
||||
start: { dateTime: createdEvent.startDate },
|
||||
start: { dateTime: createdEvent.startDate },
|
||||
end: { dateTime: createdEvent.endDate },
|
||||
location: createdEvent.location,
|
||||
source: 'synology',
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'notion') {
|
||||
if (!event.title) throw new Error('Event title is required');
|
||||
if (!event.start) throw new Error('Event start time is required');
|
||||
|
||||
const databases = (connection.calendars as any[] || []);
|
||||
const db = databases.find((d: any) => d.id === calendarId);
|
||||
const dateProperty = db?.dateProperty;
|
||||
if (!dateProperty) throw new Error('No date property found for this Notion database');
|
||||
|
||||
const startDate = event.start.dateTime || event.start.date || '';
|
||||
const endDate = event.end?.dateTime || event.end?.date || undefined;
|
||||
|
||||
const { createEvent: createNotionEvent } = await import('./notion-calendar');
|
||||
const pageId = await createNotionEvent(
|
||||
connection.accessToken,
|
||||
calendarId,
|
||||
dateProperty,
|
||||
event.title,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
id: pageId,
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
end: event.end || event.start,
|
||||
source: 'notion',
|
||||
calendarId,
|
||||
calendarTitle: db?.title || '',
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
|
||||
@ -1060,6 +1164,34 @@ export const updateCalendarEvent = async (
|
||||
calendarId,
|
||||
calendarTitle: '',
|
||||
} as CalendarEvent;
|
||||
} else if (connection.provider === 'notion') {
|
||||
const databases = (connection.calendars as any[] || []);
|
||||
const db = databases.find((d: any) => d.id === calendarId);
|
||||
const dateProperty = db?.dateProperty;
|
||||
if (!dateProperty) throw new Error('No date property found for this Notion database');
|
||||
|
||||
const startDate = event.start?.dateTime || event.start?.date || undefined;
|
||||
const endDate = event.end?.dateTime || event.end?.date || undefined;
|
||||
|
||||
const { updateEvent: updateNotionEvent } = await import('./notion-calendar');
|
||||
await updateNotionEvent(
|
||||
connection.accessToken,
|
||||
eventId,
|
||||
dateProperty,
|
||||
event.title,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
id: eventId,
|
||||
title: event.title || '',
|
||||
start: event.start || { dateTime: '' },
|
||||
end: event.end || event.start || { dateTime: '' },
|
||||
source: 'notion',
|
||||
calendarId,
|
||||
calendarTitle: db?.title || '',
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
|
||||
@ -1125,6 +1257,10 @@ export const deleteCalendarEvent = async (
|
||||
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
||||
);
|
||||
return;
|
||||
} else if (connection.provider === 'notion') {
|
||||
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
|
||||
await deleteNotionEvent(connection.accessToken, eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
|
||||
|
||||
@ -29,7 +29,7 @@ export async function sendVerificationEmail(
|
||||
token: string
|
||||
) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}`;
|
||||
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
|
||||
246
src/lib/notion-calendar.ts
Normal file
246
src/lib/notion-calendar.ts
Normal file
@ -0,0 +1,246 @@
|
||||
import { Client } from '@notionhq/client';
|
||||
|
||||
export interface NotionDatabase {
|
||||
id: string;
|
||||
title: string;
|
||||
selected: boolean;
|
||||
dateProperty?: string; // name of the date property to use
|
||||
}
|
||||
|
||||
export interface NotionEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start: string; // ISO date or datetime
|
||||
end?: string;
|
||||
allDay: boolean;
|
||||
url?: string;
|
||||
databaseId: string;
|
||||
databaseTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all databases shared with the integration
|
||||
*/
|
||||
export async function getUserDatabases(accessToken: string): Promise<NotionDatabase[]> {
|
||||
const notion = new Client({ auth: accessToken });
|
||||
|
||||
const response = await notion.search({
|
||||
filter: { property: 'object', value: 'database' },
|
||||
page_size: 50,
|
||||
});
|
||||
|
||||
const databases: NotionDatabase[] = [];
|
||||
for (const result of response.results) {
|
||||
if (result.object !== 'database') continue;
|
||||
const db = result as any;
|
||||
const title = db.title?.map((t: any) => t.plain_text).join('') || 'Untitled';
|
||||
|
||||
// Find a date property in the database schema
|
||||
let dateProperty: string | undefined;
|
||||
if (db.properties) {
|
||||
for (const [name, prop] of Object.entries(db.properties)) {
|
||||
if ((prop as any).type === 'date') {
|
||||
dateProperty = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
databases.push({
|
||||
id: db.id,
|
||||
title,
|
||||
selected: true,
|
||||
dateProperty,
|
||||
});
|
||||
}
|
||||
|
||||
return databases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query a Notion database for items with dates in the given range
|
||||
*/
|
||||
export async function getUpcomingEvents(
|
||||
accessToken: string,
|
||||
databaseId: string,
|
||||
dateProperty: string,
|
||||
timeMin: string,
|
||||
timeMax: string,
|
||||
databaseTitle: string,
|
||||
): Promise<NotionEvent[]> {
|
||||
const notion = new Client({ auth: accessToken });
|
||||
|
||||
const startDate = timeMin.split('T')[0];
|
||||
const endDate = timeMax.split('T')[0];
|
||||
|
||||
let results: any[] = [];
|
||||
let hasMore = true;
|
||||
let startCursor: string | undefined;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await notion.databases.query({
|
||||
database_id: databaseId,
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
property: dateProperty,
|
||||
date: { on_or_after: startDate },
|
||||
},
|
||||
{
|
||||
property: dateProperty,
|
||||
date: { on_or_before: endDate },
|
||||
},
|
||||
],
|
||||
},
|
||||
start_cursor: startCursor,
|
||||
page_size: 100,
|
||||
});
|
||||
|
||||
results = results.concat(response.results);
|
||||
hasMore = response.has_more;
|
||||
startCursor = response.next_cursor || undefined;
|
||||
}
|
||||
|
||||
return results.map((page: any) => {
|
||||
// Extract title from the first title property
|
||||
let title = 'Untitled';
|
||||
for (const [, prop] of Object.entries(page.properties)) {
|
||||
const p = prop as any;
|
||||
if (p.type === 'title' && p.title?.length > 0) {
|
||||
title = p.title.map((t: any) => t.plain_text).join('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract date
|
||||
const dateProp = page.properties[dateProperty];
|
||||
const dateVal = dateProp?.date;
|
||||
const start = dateVal?.start || '';
|
||||
const end = dateVal?.end || undefined;
|
||||
const allDay = start.length === 10; // YYYY-MM-DD = all day
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
title,
|
||||
description: undefined,
|
||||
start,
|
||||
end,
|
||||
allDay,
|
||||
url: page.url,
|
||||
databaseId,
|
||||
databaseTitle,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a page in a Notion database
|
||||
*/
|
||||
export async function createEvent(
|
||||
accessToken: string,
|
||||
databaseId: string,
|
||||
dateProperty: string,
|
||||
title: string,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
): Promise<string> {
|
||||
const notion = new Client({ auth: accessToken });
|
||||
|
||||
const page = await notion.pages.create({
|
||||
parent: { database_id: databaseId },
|
||||
properties: {
|
||||
// The title property - find it dynamically
|
||||
title: {
|
||||
title: [{ text: { content: title } }],
|
||||
},
|
||||
[dateProperty]: {
|
||||
date: {
|
||||
start: startDate,
|
||||
end: endDate || null,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
return page.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a page in Notion
|
||||
*/
|
||||
export async function updateEvent(
|
||||
accessToken: string,
|
||||
pageId: string,
|
||||
dateProperty: string,
|
||||
title?: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): Promise<void> {
|
||||
const notion = new Client({ auth: accessToken });
|
||||
|
||||
const properties: any = {};
|
||||
if (title !== undefined) {
|
||||
properties.title = { title: [{ text: { content: title } }] };
|
||||
}
|
||||
if (startDate !== undefined) {
|
||||
properties[dateProperty] = {
|
||||
date: { start: startDate, end: endDate || null },
|
||||
};
|
||||
}
|
||||
|
||||
await notion.pages.update({
|
||||
page_id: pageId,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive (soft-delete) a page in Notion
|
||||
*/
|
||||
export async function deleteEvent(
|
||||
accessToken: string,
|
||||
pageId: string,
|
||||
): Promise<void> {
|
||||
const notion = new Client({ auth: accessToken });
|
||||
|
||||
await notion.pages.update({
|
||||
page_id: pageId,
|
||||
archived: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Notion OAuth token
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; expiresAt?: Date }> {
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
|
||||
const response = await fetch('https://api.notion.com/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Notion token refresh failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken,
|
||||
expiresAt: undefined, // Notion doesn't return expiry in refresh
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user