@knotpad/app 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +49 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import {
|
|
6
|
+
Trash2,
|
|
7
|
+
RefreshCw,
|
|
8
|
+
AlertTriangle,
|
|
9
|
+
CheckCircle2,
|
|
10
|
+
ExternalLink,
|
|
11
|
+
ClipboardPaste,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
ChevronUp,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useToast } from "@/components/ui/toast";
|
|
16
|
+
import { ConfirmDangerAction } from "@/components/settings/confirm-danger-action";
|
|
17
|
+
import { MAX_CALENDAR_FEEDS, MAX_FEED_LABEL_LEN, FEED_COLORS, FEED_COLOR_KEYS, DEFAULT_FEED_COLOR, type FeedColorKey } from "@/lib/limits";
|
|
18
|
+
|
|
19
|
+
/** Quick-connect providers: label, settings deep-link, and a hint for finding the iCal URL. */
|
|
20
|
+
const PROVIDERS = [
|
|
21
|
+
{
|
|
22
|
+
id: "google",
|
|
23
|
+
name: "Google Calendar",
|
|
24
|
+
href: "https://calendar.google.com/calendar/u/0/r/settings",
|
|
25
|
+
hint: "Settings → pick a calendar → scroll to \"Secret address in iCal format\" → copy the URL.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "apple",
|
|
29
|
+
name: "Apple iCloud",
|
|
30
|
+
href: "https://www.icloud.com/calendar",
|
|
31
|
+
hint: "Click the share icon next to a calendar → enable \"Public Calendar\" → copy the link.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "outlook",
|
|
35
|
+
name: "Outlook / Microsoft 365",
|
|
36
|
+
href: "https://outlook.live.com/calendar/0/options/calendar/SharedCalendars",
|
|
37
|
+
hint: "Settings → Calendar → Shared calendars → Publish a calendar → copy the ICS link.",
|
|
38
|
+
},
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/** Returns true if the string looks like an iCal subscription URL. */
|
|
42
|
+
function looksLikeIcsUrl(s: string): boolean {
|
|
43
|
+
const trimmed = s.trim();
|
|
44
|
+
return /^https?:\/\/\S+\.ics(\b|$)/i.test(trimmed) || trimmed.includes("/ical/") || trimmed.includes("/caldav/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Feed = {
|
|
48
|
+
id: string;
|
|
49
|
+
label: string;
|
|
50
|
+
color: string;
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
lastFetchedAt: string | null;
|
|
53
|
+
lastError: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type Props = { feeds: Feed[] };
|
|
57
|
+
|
|
58
|
+
export function CalendarFeedsSettings({ feeds: initialFeeds }: Props) {
|
|
59
|
+
const router = useRouter();
|
|
60
|
+
const toast = useToast();
|
|
61
|
+
const [feeds, setFeeds] = useState<Feed[]>(initialFeeds);
|
|
62
|
+
const [label, setLabel] = useState("");
|
|
63
|
+
const [url, setUrl] = useState("");
|
|
64
|
+
const [color, setColor] = useState<FeedColorKey>(DEFAULT_FEED_COLOR);
|
|
65
|
+
const [adding, setAdding] = useState(false);
|
|
66
|
+
const [syncingId, setSyncingId] = useState<string | null>(null);
|
|
67
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
68
|
+
const [editingColorId, setEditingColorId] = useState<string | null>(null);
|
|
69
|
+
const [providerHint, setProviderHint] = useState<string | null>(null);
|
|
70
|
+
const [clipboardNotice, setClipboardNotice] = useState(false);
|
|
71
|
+
const [showInstructions, setShowInstructions] = useState(false);
|
|
72
|
+
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; feedId: string }>({ open: false, feedId: "" });
|
|
73
|
+
|
|
74
|
+
const atLimit = feeds.length >= MAX_CALENDAR_FEEDS;
|
|
75
|
+
|
|
76
|
+
// Auto-detect .ics URL on clipboard when the URL field is focused.
|
|
77
|
+
const handleUrlFocus = useCallback(async () => {
|
|
78
|
+
if (url) return; // field already has content
|
|
79
|
+
try {
|
|
80
|
+
const text = await navigator.clipboard.readText();
|
|
81
|
+
if (looksLikeIcsUrl(text)) {
|
|
82
|
+
setUrl(text.trim());
|
|
83
|
+
setClipboardNotice(true);
|
|
84
|
+
// Auto-fill label if empty — try to guess from URL
|
|
85
|
+
if (!label) {
|
|
86
|
+
if (text.includes("google")) setLabel("Google Calendar");
|
|
87
|
+
else if (text.includes("outlook") || text.includes("office") || text.includes("live")) setLabel("Outlook Calendar");
|
|
88
|
+
else if (text.includes("icloud") || text.includes("apple")) setLabel("Apple Calendar");
|
|
89
|
+
else setLabel("My Calendar");
|
|
90
|
+
}
|
|
91
|
+
setTimeout(() => setClipboardNotice(false), 4000);
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Clipboard permission denied — that's fine, user can paste manually
|
|
95
|
+
}
|
|
96
|
+
}, [url, label]);
|
|
97
|
+
|
|
98
|
+
// Dismiss clipboard notice when URL changes
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (clipboardNotice && url) {
|
|
101
|
+
const t = setTimeout(() => setClipboardNotice(false), 3000);
|
|
102
|
+
return () => clearTimeout(t);
|
|
103
|
+
}
|
|
104
|
+
}, [clipboardNotice, url]);
|
|
105
|
+
|
|
106
|
+
async function handleAdd(e: React.FormEvent) {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
if (!label.trim() || !url.trim()) return;
|
|
109
|
+
|
|
110
|
+
setAdding(true);
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch("/api/calendar-feeds", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ label: label.trim(), url: url.trim(), color }),
|
|
116
|
+
});
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
toast(data.error ?? "Failed to add calendar", "error");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setFeeds((prev) => [...prev, data]);
|
|
123
|
+
setLabel("");
|
|
124
|
+
setUrl("");
|
|
125
|
+
setColor(DEFAULT_FEED_COLOR);
|
|
126
|
+
setProviderHint(null);
|
|
127
|
+
if (data.lastError) {
|
|
128
|
+
toast(`Calendar added, but the first sync failed: ${data.lastError}`, "error");
|
|
129
|
+
} else {
|
|
130
|
+
toast("Calendar connected and synced", "success");
|
|
131
|
+
}
|
|
132
|
+
router.refresh();
|
|
133
|
+
} catch {
|
|
134
|
+
toast("Failed to add calendar", "error");
|
|
135
|
+
} finally {
|
|
136
|
+
setAdding(false);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleToggle(feed: Feed) {
|
|
141
|
+
const next = !feed.enabled;
|
|
142
|
+
setFeeds((prev) => prev.map((f) => (f.id === feed.id ? { ...f, enabled: next } : f)));
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`/api/calendar-feeds/${feed.id}`, {
|
|
145
|
+
method: "PATCH",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ enabled: next }),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) throw new Error();
|
|
150
|
+
router.refresh();
|
|
151
|
+
} catch {
|
|
152
|
+
setFeeds((prev) => prev.map((f) => (f.id === feed.id ? { ...f, enabled: !next } : f)));
|
|
153
|
+
toast("Failed to update calendar", "error");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleSync(feedId: string) {
|
|
158
|
+
setSyncingId(feedId);
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(`/api/calendar-feeds/${feedId}/sync`, { method: "POST" });
|
|
161
|
+
const data = await res.json();
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
toast(data.error ?? "Sync failed", "error");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setFeeds((prev) => prev.map((f) => (f.id === feedId ? { ...f, ...data } : f)));
|
|
167
|
+
if (data.lastError) {
|
|
168
|
+
toast(`Sync failed: ${data.lastError}`, "error");
|
|
169
|
+
} else {
|
|
170
|
+
toast("Calendar synced", "success");
|
|
171
|
+
}
|
|
172
|
+
router.refresh();
|
|
173
|
+
} catch {
|
|
174
|
+
toast("Sync failed", "error");
|
|
175
|
+
} finally {
|
|
176
|
+
setSyncingId(null);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function handleDelete(feedId: string) {
|
|
181
|
+
setDeleteConfirm({ open: true, feedId });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function confirmDeleteFeed() {
|
|
185
|
+
const { feedId } = deleteConfirm;
|
|
186
|
+
setDeletingId(feedId);
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`/api/calendar-feeds/${feedId}`, { method: "DELETE" });
|
|
189
|
+
if (!res.ok) throw new Error();
|
|
190
|
+
setFeeds((prev) => prev.filter((f) => f.id !== feedId));
|
|
191
|
+
router.refresh();
|
|
192
|
+
} catch {
|
|
193
|
+
toast("Failed to remove calendar", "error");
|
|
194
|
+
} finally {
|
|
195
|
+
setDeletingId(null);
|
|
196
|
+
setDeleteConfirm({ open: false, feedId: "" });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function handleColorChange(feedId: string, newColor: FeedColorKey) {
|
|
201
|
+
setFeeds((prev) => prev.map((f) => (f.id === feedId ? { ...f, color: newColor } : f)));
|
|
202
|
+
setEditingColorId(null);
|
|
203
|
+
try {
|
|
204
|
+
await fetch(`/api/calendar-feeds/${feedId}`, {
|
|
205
|
+
method: "PATCH",
|
|
206
|
+
headers: { "Content-Type": "application/json" },
|
|
207
|
+
body: JSON.stringify({ color: newColor }),
|
|
208
|
+
});
|
|
209
|
+
router.refresh();
|
|
210
|
+
} catch {
|
|
211
|
+
toast("Failed to update colour", "error");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className="space-y-6">
|
|
217
|
+
{/* Connected feeds list */}
|
|
218
|
+
{feeds.length > 0 && (
|
|
219
|
+
<div className="space-y-2">
|
|
220
|
+
{feeds.map((feed) => {
|
|
221
|
+
const fc =
|
|
222
|
+
(FEED_COLORS as Record<string, { bg: string; bgLight: string; border: string; text: string; label: string }>)[
|
|
223
|
+
feed.color
|
|
224
|
+
] ?? FEED_COLORS.sky;
|
|
225
|
+
return (
|
|
226
|
+
<div
|
|
227
|
+
key={feed.id}
|
|
228
|
+
className="relative rounded-md border border-zinc-800 bg-zinc-900/40 px-3 py-2.5"
|
|
229
|
+
>
|
|
230
|
+
<div className="flex items-center gap-3">
|
|
231
|
+
{/* Colour dot — click to open picker */}
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => setEditingColorId(editingColorId === feed.id ? null : feed.id)}
|
|
235
|
+
title="Change colour"
|
|
236
|
+
className={`h-4 w-4 shrink-0 rounded-full ring-2 ring-transparent hover:ring-zinc-600 transition-all ${fc.bg}`}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
role="switch"
|
|
242
|
+
aria-checked={feed.enabled}
|
|
243
|
+
onClick={() => handleToggle(feed)}
|
|
244
|
+
title={feed.enabled ? "Disable" : "Enable"}
|
|
245
|
+
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 ${
|
|
246
|
+
feed.enabled ? "bg-emerald-600" : "bg-zinc-700"
|
|
247
|
+
}`}
|
|
248
|
+
>
|
|
249
|
+
<span
|
|
250
|
+
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
251
|
+
feed.enabled ? "translate-x-6" : "translate-x-1"
|
|
252
|
+
}`}
|
|
253
|
+
/>
|
|
254
|
+
</button>
|
|
255
|
+
|
|
256
|
+
<div className="min-w-0 flex-1">
|
|
257
|
+
<p className="truncate text-sm text-zinc-200">{feed.label}</p>
|
|
258
|
+
{feed.lastError ? (
|
|
259
|
+
<p className="flex items-center gap-1 text-xs text-red-400">
|
|
260
|
+
<AlertTriangle size={11} className="shrink-0" />
|
|
261
|
+
<span className="truncate">{feed.lastError}</span>
|
|
262
|
+
</p>
|
|
263
|
+
) : feed.lastFetchedAt ? (
|
|
264
|
+
<p className="flex items-center gap-1 text-xs text-zinc-600">
|
|
265
|
+
<CheckCircle2 size={11} className="shrink-0 text-emerald-500" />
|
|
266
|
+
Synced {new Date(feed.lastFetchedAt).toLocaleString()}
|
|
267
|
+
</p>
|
|
268
|
+
) : (
|
|
269
|
+
<p className="text-xs text-zinc-600">Not synced yet</p>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
onClick={() => handleSync(feed.id)}
|
|
276
|
+
disabled={syncingId === feed.id}
|
|
277
|
+
title="Sync now"
|
|
278
|
+
className="text-zinc-500 hover:text-zinc-200 transition-colors disabled:opacity-50"
|
|
279
|
+
>
|
|
280
|
+
<RefreshCw size={14} className={syncingId === feed.id ? "animate-spin" : ""} />
|
|
281
|
+
</button>
|
|
282
|
+
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => handleDelete(feed.id)}
|
|
286
|
+
disabled={deletingId === feed.id}
|
|
287
|
+
title="Remove calendar"
|
|
288
|
+
className="text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50"
|
|
289
|
+
>
|
|
290
|
+
<Trash2 size={14} />
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Inline colour picker popover */}
|
|
295
|
+
{editingColorId === feed.id && (
|
|
296
|
+
<div className="mt-2 flex flex-wrap gap-1.5 pl-7">
|
|
297
|
+
{FEED_COLOR_KEYS.map((key) => {
|
|
298
|
+
const c = FEED_COLORS[key];
|
|
299
|
+
const selected = feed.color === key;
|
|
300
|
+
return (
|
|
301
|
+
<button
|
|
302
|
+
key={key}
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() => handleColorChange(feed.id, key)}
|
|
305
|
+
title={c.label}
|
|
306
|
+
className={`h-5 w-5 rounded-full transition-all ${c.bg} ${
|
|
307
|
+
selected
|
|
308
|
+
? "ring-2 ring-white ring-offset-2 ring-offset-zinc-900 scale-110"
|
|
309
|
+
: "ring-1 ring-zinc-700 hover:ring-zinc-500 hover:scale-110"
|
|
310
|
+
}`}
|
|
311
|
+
/>
|
|
312
|
+
);
|
|
313
|
+
})}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{atLimit ? (
|
|
323
|
+
<p className="text-sm text-zinc-500">
|
|
324
|
+
You've connected the maximum of {MAX_CALENDAR_FEEDS} calendars. Remove one to add another.
|
|
325
|
+
</p>
|
|
326
|
+
) : (
|
|
327
|
+
<form onSubmit={handleAdd} className="space-y-4 rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
|
328
|
+
{/* Quick-connect provider buttons */}
|
|
329
|
+
<div className="space-y-2">
|
|
330
|
+
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">1. Get your calendar URL</p>
|
|
331
|
+
<div className="flex flex-wrap gap-2">
|
|
332
|
+
{PROVIDERS.map((p) => (
|
|
333
|
+
<button
|
|
334
|
+
key={p.id}
|
|
335
|
+
type="button"
|
|
336
|
+
onClick={() => {
|
|
337
|
+
window.open(p.href, "_blank", "noopener,noreferrer");
|
|
338
|
+
setProviderHint(p.hint);
|
|
339
|
+
// Auto-fill label if empty
|
|
340
|
+
if (!label) setLabel(p.name);
|
|
341
|
+
}}
|
|
342
|
+
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
|
343
|
+
providerHint === p.hint
|
|
344
|
+
? "border-zinc-500 bg-zinc-800 text-zinc-200"
|
|
345
|
+
: "border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200 hover:bg-zinc-800/60"
|
|
346
|
+
}`}
|
|
347
|
+
>
|
|
348
|
+
<ExternalLink size={11} />
|
|
349
|
+
<span>{p.name}</span>
|
|
350
|
+
</button>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
{providerHint && (
|
|
354
|
+
<p className="text-xs text-zinc-400 bg-zinc-800/60 rounded-md px-3 py-2 border border-zinc-700/50">
|
|
355
|
+
{providerHint}
|
|
356
|
+
</p>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{/* Paste URL */}
|
|
361
|
+
<div className="space-y-2">
|
|
362
|
+
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">2. Paste the URL here</p>
|
|
363
|
+
<div className="space-y-1">
|
|
364
|
+
<div className="relative">
|
|
365
|
+
<input
|
|
366
|
+
value={url}
|
|
367
|
+
onChange={(e) => { setUrl(e.target.value); setClipboardNotice(false); }}
|
|
368
|
+
onFocus={handleUrlFocus}
|
|
369
|
+
placeholder="https://calendar.google.com/calendar/ical/.../basic.ics"
|
|
370
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none ring-focus"
|
|
371
|
+
/>
|
|
372
|
+
{!url && (
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
onClick={async () => {
|
|
376
|
+
try {
|
|
377
|
+
const text = await navigator.clipboard.readText();
|
|
378
|
+
if (looksLikeIcsUrl(text)) {
|
|
379
|
+
setUrl(text.trim());
|
|
380
|
+
if (!label) {
|
|
381
|
+
if (text.includes("google")) setLabel("Google Calendar");
|
|
382
|
+
else if (text.includes("outlook") || text.includes("office") || text.includes("live")) setLabel("Outlook Calendar");
|
|
383
|
+
else if (text.includes("icloud") || text.includes("apple")) setLabel("Apple Calendar");
|
|
384
|
+
else setLabel("My Calendar");
|
|
385
|
+
}
|
|
386
|
+
toast("Pasted iCal URL from clipboard", "success");
|
|
387
|
+
} else if (text) {
|
|
388
|
+
setUrl(text.trim());
|
|
389
|
+
toast("Pasted from clipboard", "success");
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
toast("Could not read clipboard", "error");
|
|
393
|
+
}
|
|
394
|
+
}}
|
|
395
|
+
title="Paste from clipboard"
|
|
396
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-300 transition-colors"
|
|
397
|
+
>
|
|
398
|
+
<ClipboardPaste size={14} />
|
|
399
|
+
</button>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
{clipboardNotice && (
|
|
403
|
+
<p className="text-xs text-emerald-400 flex items-center gap-1">
|
|
404
|
+
<CheckCircle2 size={11} />
|
|
405
|
+
Auto-detected iCal URL from your clipboard
|
|
406
|
+
</p>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{/* Name */}
|
|
412
|
+
<div className="space-y-1">
|
|
413
|
+
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Calendar name</label>
|
|
414
|
+
<input
|
|
415
|
+
value={label}
|
|
416
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
417
|
+
maxLength={MAX_FEED_LABEL_LEN}
|
|
418
|
+
placeholder="e.g. Work Calendar"
|
|
419
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none ring-focus"
|
|
420
|
+
/>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{/* Colour */}
|
|
424
|
+
<div className="space-y-2">
|
|
425
|
+
<label className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Colour</label>
|
|
426
|
+
<div className="flex flex-wrap gap-2">
|
|
427
|
+
{FEED_COLOR_KEYS.map((key) => {
|
|
428
|
+
const c = FEED_COLORS[key];
|
|
429
|
+
const selected = color === key;
|
|
430
|
+
return (
|
|
431
|
+
<button
|
|
432
|
+
key={key}
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={() => setColor(key)}
|
|
435
|
+
title={c.label}
|
|
436
|
+
className={`h-6 w-6 rounded-full transition-all ${c.bg} ${
|
|
437
|
+
selected
|
|
438
|
+
? "ring-2 ring-white ring-offset-2 ring-offset-zinc-900 scale-110"
|
|
439
|
+
: "ring-1 ring-zinc-700 hover:ring-zinc-500 hover:scale-110"
|
|
440
|
+
}`}
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
{/* Submit */}
|
|
448
|
+
<button
|
|
449
|
+
type="submit"
|
|
450
|
+
disabled={adding || !label.trim() || !url.trim()}
|
|
451
|
+
className="rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 disabled:opacity-50 transition-colors"
|
|
452
|
+
>
|
|
453
|
+
{adding ? "Connecting…" : "Connect calendar"}
|
|
454
|
+
</button>
|
|
455
|
+
|
|
456
|
+
{/* Collapsible: how it works */}
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
onClick={() => setShowInstructions((v) => !v)}
|
|
460
|
+
className="flex items-center gap-1 text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
|
461
|
+
>
|
|
462
|
+
{showInstructions ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
463
|
+
<span>How does this work?</span>
|
|
464
|
+
</button>
|
|
465
|
+
{showInstructions && (
|
|
466
|
+
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-500 space-y-1.5">
|
|
467
|
+
<p>Knotpad connects to your calendar using a <span className="text-zinc-400">read-only subscription URL</span> (iCal/ICS format). This is a one-way sync — events from your calendar appear as “busy blocks” in Knotpad's calendar view.</p>
|
|
468
|
+
<p>Your calendar provider never sees your Knotpad data. The URL is encrypted at rest.</p>
|
|
469
|
+
<p className="text-zinc-600 pt-1 border-t border-zinc-800">
|
|
470
|
+
Events sync automatically every few hours, or you can tap the refresh icon to sync on demand.
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
</form>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{/* Confirmation dialog */}
|
|
478
|
+
<ConfirmDangerAction
|
|
479
|
+
open={deleteConfirm.open}
|
|
480
|
+
title="Remove Calendar"
|
|
481
|
+
body="Remove this calendar? Cached events will be deleted."
|
|
482
|
+
confirmLabel="Remove"
|
|
483
|
+
cancelLabel="Cancel"
|
|
484
|
+
onConfirm={confirmDeleteFeed}
|
|
485
|
+
onClose={() => setDeleteConfirm({ open: false, feedId: "" })}
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
export function CalendarGeneralSettings() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
|
|
9
|
+
// Public holidays state
|
|
10
|
+
const [holidaysEnabled, setHolidaysEnabled] = useState(false);
|
|
11
|
+
const [holidayCountry, setHolidayCountry] = useState("MY");
|
|
12
|
+
const [holidayRegion, setHolidayRegion] = useState("");
|
|
13
|
+
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
|
|
14
|
+
const [regions, setRegions] = useState<{ code: string; name: string }[]>([]);
|
|
15
|
+
|
|
16
|
+
// First day of week state
|
|
17
|
+
const [firstDayOfWeek, setFirstDayOfWeek] = useState("Sun"); // "Sun" | "Mon" | "Sat"
|
|
18
|
+
|
|
19
|
+
// Initial load — persist defaults to localStorage so other pages (calendar)
|
|
20
|
+
// always find an explicit value instead of falling back to browser locale.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const storedCountry = localStorage.getItem("brief:holidays-country");
|
|
23
|
+
if (!storedCountry) localStorage.setItem("brief:holidays-country", "MY");
|
|
24
|
+
setHolidaysEnabled(localStorage.getItem("brief:holidays") === "true");
|
|
25
|
+
setHolidayCountry(storedCountry ?? "MY");
|
|
26
|
+
setHolidayRegion(localStorage.getItem("brief:holidays-region") ?? "");
|
|
27
|
+
setFirstDayOfWeek(localStorage.getItem("brief:first-day-of-week") ?? "Sun");
|
|
28
|
+
|
|
29
|
+
// Fetch countries
|
|
30
|
+
fetch("/api/holidays/countries")
|
|
31
|
+
.then((r) => (r.ok ? r.json() : { countries: [] }))
|
|
32
|
+
.then((d: { countries: { code: string; name: string }[] }) => {
|
|
33
|
+
setCountries(d.countries);
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {});
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
// Fetch regions when country changes
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!holidayCountry) {
|
|
41
|
+
setRegions([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
fetch(`/api/holidays/states?country=${holidayCountry}`)
|
|
45
|
+
.then((r) => (r.ok ? r.json() : { states: [] }))
|
|
46
|
+
.then((d: { states: { code: string; name: string }[] }) => {
|
|
47
|
+
setRegions(d.states ?? []);
|
|
48
|
+
})
|
|
49
|
+
.catch(() => setRegions([]));
|
|
50
|
+
}, [holidayCountry]);
|
|
51
|
+
|
|
52
|
+
// Handle holiday toggle
|
|
53
|
+
function handleToggleHolidays() {
|
|
54
|
+
const next = !holidaysEnabled;
|
|
55
|
+
setHolidaysEnabled(next);
|
|
56
|
+
localStorage.setItem("brief:holidays", String(next));
|
|
57
|
+
window.dispatchEvent(new Event("brief:holidays-settings-changed"));
|
|
58
|
+
router.refresh();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Handle country change
|
|
62
|
+
function handleCountryChange(code: string) {
|
|
63
|
+
setHolidayCountry(code);
|
|
64
|
+
setHolidayRegion("");
|
|
65
|
+
localStorage.setItem("brief:holidays-country", code);
|
|
66
|
+
localStorage.removeItem("brief:holidays-region");
|
|
67
|
+
window.dispatchEvent(new Event("brief:holidays-settings-changed"));
|
|
68
|
+
router.refresh();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle region change
|
|
72
|
+
function handleRegionChange(code: string) {
|
|
73
|
+
setHolidayRegion(code);
|
|
74
|
+
localStorage.setItem("brief:holidays-region", code);
|
|
75
|
+
window.dispatchEvent(new Event("brief:holidays-settings-changed"));
|
|
76
|
+
router.refresh();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle first day of week change
|
|
80
|
+
function handleFirstDayOfWeekChange(val: string) {
|
|
81
|
+
setFirstDayOfWeek(val);
|
|
82
|
+
localStorage.setItem("brief:first-day-of-week", val);
|
|
83
|
+
router.refresh();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="space-y-6 rounded-lg border border-zinc-800 bg-zinc-900/30 p-4">
|
|
88
|
+
{/* General Calendar Preferences */}
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
<h3 className="text-sm font-semibold text-zinc-300">Preferences</h3>
|
|
91
|
+
|
|
92
|
+
<div className="flex flex-col gap-1.5">
|
|
93
|
+
<label className="text-xs font-medium text-zinc-400">First day of week</label>
|
|
94
|
+
<select
|
|
95
|
+
value={firstDayOfWeek}
|
|
96
|
+
onChange={(e) => handleFirstDayOfWeekChange(e.target.value)}
|
|
97
|
+
className="w-full max-w-xs rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-zinc-500"
|
|
98
|
+
>
|
|
99
|
+
<option value="Sun">Sunday</option>
|
|
100
|
+
<option value="Mon">Monday</option>
|
|
101
|
+
<option value="Sat">Saturday</option>
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<hr className="border-zinc-800" />
|
|
107
|
+
|
|
108
|
+
{/* Public Holidays Section */}
|
|
109
|
+
<div className="space-y-4">
|
|
110
|
+
<div className="flex items-center justify-between">
|
|
111
|
+
<div className="space-y-0.5">
|
|
112
|
+
<h3 className="text-sm font-semibold text-zinc-300">Public Holidays</h3>
|
|
113
|
+
<p className="text-xs text-zinc-500">
|
|
114
|
+
Display official public holidays directly on your calendar grid.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
role="switch"
|
|
120
|
+
aria-checked={holidaysEnabled}
|
|
121
|
+
onClick={handleToggleHolidays}
|
|
122
|
+
title={holidaysEnabled ? "Disable" : "Enable"}
|
|
123
|
+
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 ${
|
|
124
|
+
holidaysEnabled ? "bg-emerald-600" : "bg-zinc-700"
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
<span
|
|
128
|
+
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
129
|
+
holidaysEnabled ? "translate-x-6" : "translate-x-1"
|
|
130
|
+
}`}
|
|
131
|
+
/>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{holidaysEnabled && (
|
|
136
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
137
|
+
<div className="flex flex-col gap-1.5">
|
|
138
|
+
<label className="text-xs font-medium text-zinc-400">Country</label>
|
|
139
|
+
<select
|
|
140
|
+
value={holidayCountry}
|
|
141
|
+
onChange={(e) => handleCountryChange(e.target.value)}
|
|
142
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-zinc-500"
|
|
143
|
+
>
|
|
144
|
+
{countries.map((c) => (
|
|
145
|
+
<option key={c.code} value={c.code}>
|
|
146
|
+
{c.name}
|
|
147
|
+
</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{regions.length > 0 && (
|
|
153
|
+
<div className="flex flex-col gap-1.5">
|
|
154
|
+
<label className="text-xs font-medium text-zinc-400">State / Region (Optional)</label>
|
|
155
|
+
<select
|
|
156
|
+
value={holidayRegion}
|
|
157
|
+
onChange={(e) => handleRegionChange(e.target.value)}
|
|
158
|
+
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-zinc-500"
|
|
159
|
+
>
|
|
160
|
+
<option value="">All Regions</option>
|
|
161
|
+
{regions.map((r) => (
|
|
162
|
+
<option key={r.code} value={r.code}>
|
|
163
|
+
{r.name}
|
|
164
|
+
</option>
|
|
165
|
+
))}
|
|
166
|
+
</select>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|