@knotpad/app 0.1.0

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.
Files changed (342) hide show
  1. package/README.md +167 -0
  2. package/app/(app)/calendar/page.tsx +57 -0
  3. package/app/(app)/error.tsx +35 -0
  4. package/app/(app)/graph/page.tsx +32 -0
  5. package/app/(app)/guide/page.tsx +21 -0
  6. package/app/(app)/kanban/loading.tsx +24 -0
  7. package/app/(app)/kanban/page.tsx +59 -0
  8. package/app/(app)/layout.tsx +122 -0
  9. package/app/(app)/list/loading.tsx +21 -0
  10. package/app/(app)/list/page.tsx +137 -0
  11. package/app/(app)/loading.tsx +18 -0
  12. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  13. package/app/(app)/notes/layout.tsx +30 -0
  14. package/app/(app)/notes/page.tsx +39 -0
  15. package/app/(app)/page.tsx +5 -0
  16. package/app/(app)/settings/agent-token/page.tsx +59 -0
  17. package/app/(app)/settings/backup/page.tsx +49 -0
  18. package/app/(app)/settings/billing/page.tsx +53 -0
  19. package/app/(app)/settings/calendar/page.tsx +41 -0
  20. package/app/(app)/settings/layout.test.tsx +39 -0
  21. package/app/(app)/settings/layout.tsx +71 -0
  22. package/app/(app)/settings/page.tsx +4 -0
  23. package/app/(app)/settings/security/page.tsx +43 -0
  24. package/app/(app)/settings/team/page.tsx +74 -0
  25. package/app/(app)/settings/workspace/page.tsx +27 -0
  26. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  27. package/app/(auth)/forgot-password/page.tsx +106 -0
  28. package/app/(auth)/guest/page.tsx +56 -0
  29. package/app/(auth)/layout.tsx +13 -0
  30. package/app/(auth)/login/page.tsx +14 -0
  31. package/app/(auth)/register/page.tsx +193 -0
  32. package/app/(auth)/reset-password/page.tsx +138 -0
  33. package/app/api/account/claim/route.tsx +135 -0
  34. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  35. package/app/api/admin/license/route.tsx +42 -0
  36. package/app/api/auth/2fa/route.tsx +148 -0
  37. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  38. package/app/api/auth/change-password/route.tsx +61 -0
  39. package/app/api/auth/check-2fa/route.tsx +19 -0
  40. package/app/api/auth/forgot-password/route.tsx +65 -0
  41. package/app/api/auth/reset-password/route.tsx +52 -0
  42. package/app/api/auth/verify-2fa/route.tsx +88 -0
  43. package/app/api/backup/download/db/route.ts +29 -0
  44. package/app/api/backup/download/notes/route.ts +25 -0
  45. package/app/api/backup/settings/route.ts +92 -0
  46. package/app/api/billing/checkout/route.tsx +81 -0
  47. package/app/api/billing/migrate/route.tsx +163 -0
  48. package/app/api/billing/portal/route.tsx +24 -0
  49. package/app/api/billing/setup-intent/route.tsx +55 -0
  50. package/app/api/billing/status/route.tsx +36 -0
  51. package/app/api/billing/subscribe/route.tsx +85 -0
  52. package/app/api/billing/webhook/route.tsx +199 -0
  53. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  54. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  55. package/app/api/calendar-feeds/events/route.tsx +82 -0
  56. package/app/api/calendar-feeds/route.tsx +52 -0
  57. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  58. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  59. package/app/api/cron/stale-tasks/route.tsx +51 -0
  60. package/app/api/cron/sync/route.tsx +34 -0
  61. package/app/api/devices/[deviceId]/route.tsx +25 -0
  62. package/app/api/devices/route.tsx +41 -0
  63. package/app/api/export/route.tsx +40 -0
  64. package/app/api/feedback/route.tsx +54 -0
  65. package/app/api/folders/[folderId]/route.tsx +51 -0
  66. package/app/api/folders/route.tsx +37 -0
  67. package/app/api/graph/route.tsx +242 -0
  68. package/app/api/guest/route.tsx +58 -0
  69. package/app/api/health/route.tsx +10 -0
  70. package/app/api/holidays/countries/route.tsx +14 -0
  71. package/app/api/holidays/route.tsx +49 -0
  72. package/app/api/holidays/states/route.tsx +21 -0
  73. package/app/api/invites/[token]/route.tsx +131 -0
  74. package/app/api/invites/route.tsx +74 -0
  75. package/app/api/mcp/generate-token/route.tsx +55 -0
  76. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  77. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  78. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  79. package/app/api/notes/[noteId]/route.tsx +360 -0
  80. package/app/api/notes/route.tsx +112 -0
  81. package/app/api/notifications/route.tsx +44 -0
  82. package/app/api/register/route.tsx +67 -0
  83. package/app/api/restore/route.tsx +148 -0
  84. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  85. package/app/api/sync/conflicts/route.tsx +48 -0
  86. package/app/api/sync/status/route.tsx +49 -0
  87. package/app/api/sync/trigger/route.tsx +15 -0
  88. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  89. package/app/api/tasks/[taskId]/route.tsx +259 -0
  90. package/app/api/tasks/bulk/route.tsx +133 -0
  91. package/app/api/tasks/route.tsx +36 -0
  92. package/app/api/workspace/active/route.tsx +39 -0
  93. package/app/api/workspace/create-team/route.tsx +42 -0
  94. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  95. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  96. package/app/api/workspace/route.tsx +24 -0
  97. package/app/download/page.tsx +170 -0
  98. package/app/favicon.ico +0 -0
  99. package/app/generated/prisma/client.d.ts +1 -0
  100. package/app/generated/prisma/client.js +5 -0
  101. package/app/generated/prisma/default.d.ts +1 -0
  102. package/app/generated/prisma/default.js +5 -0
  103. package/app/generated/prisma/edge.d.ts +1 -0
  104. package/app/generated/prisma/edge.js +497 -0
  105. package/app/generated/prisma/index-browser.js +523 -0
  106. package/app/generated/prisma/index.d.ts +46376 -0
  107. package/app/generated/prisma/index.js +497 -0
  108. package/app/generated/prisma/package.json +144 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  111. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  112. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  113. package/app/generated/prisma/runtime/client.js +86 -0
  114. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  115. package/app/generated/prisma/runtime/index-browser.js +6 -0
  116. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  117. package/app/generated/prisma/schema.prisma +456 -0
  118. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  119. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  120. package/app/globals.css +54 -0
  121. package/app/invite/[token]/page.tsx +52 -0
  122. package/app/layout.tsx +90 -0
  123. package/app/mcp/route.tsx +430 -0
  124. package/app/opengraph-image.tsx +120 -0
  125. package/app/page.tsx +398 -0
  126. package/app/privacy/page.tsx +69 -0
  127. package/app/robots.tsx +25 -0
  128. package/app/sitemap.tsx +36 -0
  129. package/app/terms/page.tsx +69 -0
  130. package/app/upgrade/page.tsx +75 -0
  131. package/auth.config.ts +33 -0
  132. package/auth.ts +79 -0
  133. package/bin/brief.js +224 -0
  134. package/components/auth/login-form.tsx +302 -0
  135. package/components/auth/password-checklist.tsx +31 -0
  136. package/components/auth/password-input.tsx +36 -0
  137. package/components/auth/switch-account-button.test.tsx +22 -0
  138. package/components/auth/switch-account-button.tsx +19 -0
  139. package/components/auth/two-factor-input.tsx +116 -0
  140. package/components/billing/billing-dashboard.tsx +265 -0
  141. package/components/billing/card-form.tsx +210 -0
  142. package/components/billing/claim-account-form.tsx +99 -0
  143. package/components/branding/app-logo.test.tsx +20 -0
  144. package/components/branding/app-logo.tsx +25 -0
  145. package/components/calendar/calendar-agenda.tsx +150 -0
  146. package/components/calendar/calendar-drag.test.tsx +177 -0
  147. package/components/calendar/calendar-grid.tsx +357 -0
  148. package/components/calendar/calendar-hooks.test.tsx +27 -0
  149. package/components/calendar/calendar-hooks.ts +351 -0
  150. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  151. package/components/calendar/calendar-toolbar.tsx +291 -0
  152. package/components/calendar/calendar-types.ts +148 -0
  153. package/components/calendar/calendar-view.test.tsx +295 -0
  154. package/components/calendar/calendar-view.tsx +307 -0
  155. package/components/calendar/day-detail-popover.tsx +174 -0
  156. package/components/calendar/task-chip.tsx +86 -0
  157. package/components/command/command-palette.test.tsx +33 -0
  158. package/components/command/command-palette.tsx +310 -0
  159. package/components/download-cta.tsx +87 -0
  160. package/components/feedback/feedback-popup.tsx +207 -0
  161. package/components/graph/graph-draw.ts +337 -0
  162. package/components/graph/graph-overlays.tsx +160 -0
  163. package/components/graph/graph-page.test.tsx +131 -0
  164. package/components/graph/graph-page.tsx +263 -0
  165. package/components/graph/graph-types.ts +47 -0
  166. package/components/graph/graph-view.tsx +322 -0
  167. package/components/guide/guide-view.tsx +522 -0
  168. package/components/kanban/kanban-board.test.tsx +128 -0
  169. package/components/kanban/kanban-board.tsx +361 -0
  170. package/components/kanban/kanban-card-menu.tsx +102 -0
  171. package/components/kanban/kanban-card.tsx +227 -0
  172. package/components/kanban/kanban-column.tsx +49 -0
  173. package/components/kanban/kanban-status-context.tsx +28 -0
  174. package/components/landing/calendar-sandbox.test.tsx +15 -0
  175. package/components/landing/calendar-sandbox.tsx +107 -0
  176. package/components/landing/graph-sandbox.test.tsx +27 -0
  177. package/components/landing/graph-sandbox.tsx +80 -0
  178. package/components/landing/kanban-sandbox.test.tsx +24 -0
  179. package/components/landing/kanban-sandbox.tsx +101 -0
  180. package/components/landing/landing-showcase.test.tsx +21 -0
  181. package/components/landing/landing-showcase.tsx +54 -0
  182. package/components/landing/list-sandbox.tsx +86 -0
  183. package/components/landing/mock-workspace.ts +168 -0
  184. package/components/landing/notes-sandbox.test.tsx +14 -0
  185. package/components/landing/notes-sandbox.tsx +88 -0
  186. package/components/layout/app-shell.tsx +83 -0
  187. package/components/layout/backup-scheduler.tsx +122 -0
  188. package/components/layout/bottom-nav.tsx +43 -0
  189. package/components/layout/icon-bar.test.tsx +29 -0
  190. package/components/layout/icon-bar.tsx +118 -0
  191. package/components/layout/mobile-top-bar.tsx +68 -0
  192. package/components/layout/notes-panel-folder.tsx +127 -0
  193. package/components/layout/notes-panel-note-item.tsx +140 -0
  194. package/components/layout/notes-panel-task-tab.tsx +63 -0
  195. package/components/layout/notes-panel-types.ts +44 -0
  196. package/components/layout/notes-panel.tsx +476 -0
  197. package/components/layout/notification-bell.tsx +251 -0
  198. package/components/layout/paywall-screen.tsx +41 -0
  199. package/components/layout/pro-banner.tsx +76 -0
  200. package/components/layout/sw-register.tsx +27 -0
  201. package/components/layout/workspace-switcher.tsx +90 -0
  202. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  203. package/components/notes/note-editor-context-menu.tsx +47 -0
  204. package/components/notes/note-editor-dom.ts +33 -0
  205. package/components/notes/note-editor-dropdowns.tsx +484 -0
  206. package/components/notes/note-editor-hooks.ts +692 -0
  207. package/components/notes/note-editor-keyboard.ts +305 -0
  208. package/components/notes/note-editor-overlay.tsx +90 -0
  209. package/components/notes/note-editor.test.tsx +372 -0
  210. package/components/notes/note-editor.tsx +662 -0
  211. package/components/notes/note-preview-pane.tsx +156 -0
  212. package/components/notes/note-tabs.tsx +120 -0
  213. package/components/notes/note-types.tsx +157 -0
  214. package/components/settings/accept-invite.tsx +108 -0
  215. package/components/settings/agent-token-settings.tsx +369 -0
  216. package/components/settings/backup-restore-settings.test.tsx +25 -0
  217. package/components/settings/backup-restore-settings.tsx +327 -0
  218. package/components/settings/calendar-feeds-settings.tsx +489 -0
  219. package/components/settings/calendar-general-settings.tsx +174 -0
  220. package/components/settings/confirm-danger-action.test.tsx +215 -0
  221. package/components/settings/confirm-danger-action.tsx +65 -0
  222. package/components/settings/security-settings.tsx +252 -0
  223. package/components/settings/settings-guidance.test.tsx +98 -0
  224. package/components/settings/team-settings.tsx +319 -0
  225. package/components/settings/two-factor-auth.tsx +296 -0
  226. package/components/settings/workspace-settings-client.tsx +363 -0
  227. package/components/settings/workspace-settings-form.tsx +73 -0
  228. package/components/sync/conflict-viewer.tsx +247 -0
  229. package/components/sync/sync-indicator.tsx +171 -0
  230. package/components/tasks/snippet-thread.tsx +119 -0
  231. package/components/tasks/status-dot.tsx +47 -0
  232. package/components/tasks/task-badge.tsx +43 -0
  233. package/components/tasks/task-detail.test.tsx +187 -0
  234. package/components/tasks/task-detail.tsx +458 -0
  235. package/components/tasks/task-list-filters.test.tsx +75 -0
  236. package/components/tasks/task-list-filters.tsx +163 -0
  237. package/components/tasks/task-list-types.ts +20 -0
  238. package/components/tasks/task-list.test.tsx +175 -0
  239. package/components/tasks/task-list.tsx +481 -0
  240. package/components/tasks/task-row.tsx +85 -0
  241. package/components/tasks/task-table-row.tsx +259 -0
  242. package/components/ui/skeleton.tsx +3 -0
  243. package/components/ui/toast.test.tsx +42 -0
  244. package/components/ui/toast.tsx +70 -0
  245. package/electron/main.ts +251 -0
  246. package/electron/preload.ts +56 -0
  247. package/instrumentation.tsx +23 -0
  248. package/lib/api-error.ts +50 -0
  249. package/lib/backup/backup-runner.test.ts +32 -0
  250. package/lib/backup/backup-runner.ts +19 -0
  251. package/lib/backup/backup-schedule.test.ts +23 -0
  252. package/lib/backup/backup-schedule.ts +55 -0
  253. package/lib/backup/backup-settings.test.ts +30 -0
  254. package/lib/backup/backup-settings.ts +27 -0
  255. package/lib/backup/export-notes-zip.test.ts +26 -0
  256. package/lib/backup/export-notes-zip.ts +82 -0
  257. package/lib/backup/export-workspace-backup.test.ts +17 -0
  258. package/lib/backup/export-workspace-backup.ts +77 -0
  259. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  260. package/lib/backup/restore-workspace-from-export.ts +183 -0
  261. package/lib/backup/types.ts +14 -0
  262. package/lib/brand-icons.ts +1 -0
  263. package/lib/calendar-feed-crypto.ts +38 -0
  264. package/lib/calendar-feed.ts +239 -0
  265. package/lib/client/online-status.ts +47 -0
  266. package/lib/conflict-resolver.test.ts +57 -0
  267. package/lib/conflict-resolver.ts +240 -0
  268. package/lib/db-init.ts +79 -0
  269. package/lib/email.ts +159 -0
  270. package/lib/encryption.test.ts +41 -0
  271. package/lib/encryption.ts +98 -0
  272. package/lib/extract-snippet.test.ts +123 -0
  273. package/lib/extract-snippet.ts +69 -0
  274. package/lib/kanban-status.ts +55 -0
  275. package/lib/license.ts +21 -0
  276. package/lib/limits.ts +31 -0
  277. package/lib/mcp-auth.test.ts +58 -0
  278. package/lib/mcp-auth.ts +65 -0
  279. package/lib/mcp-contract.test.ts +25 -0
  280. package/lib/mcp-contract.ts +210 -0
  281. package/lib/mcp-handler.ts +31 -0
  282. package/lib/mcp-url.test.ts +12 -0
  283. package/lib/mcp-url.ts +7 -0
  284. package/lib/mentions.test.ts +45 -0
  285. package/lib/mentions.ts +73 -0
  286. package/lib/note-crypto.ts +108 -0
  287. package/lib/note-sync.ts +201 -0
  288. package/lib/note-title.ts +93 -0
  289. package/lib/prisma.ts +193 -0
  290. package/lib/pro-flush.ts +292 -0
  291. package/lib/rate-limit.ts +57 -0
  292. package/lib/stripe.ts +38 -0
  293. package/lib/sync-worker.ts +388 -0
  294. package/lib/task-parser.test.ts +91 -0
  295. package/lib/task-parser.ts +81 -0
  296. package/lib/task-utils.ts +52 -0
  297. package/lib/use-is-electron.ts +19 -0
  298. package/lib/use-is-mobile.ts +22 -0
  299. package/lib/validation/calendar-feed.ts +31 -0
  300. package/lib/validation/note.ts +27 -0
  301. package/lib/validation/task.ts +26 -0
  302. package/lib/view-preferences.test.ts +54 -0
  303. package/lib/view-preferences.ts +28 -0
  304. package/lib/workspace.ts +66 -0
  305. package/next.config.ts +21 -0
  306. package/package.json +99 -0
  307. package/postcss.config.mjs +7 -0
  308. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  309. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  310. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  311. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  312. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  313. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  314. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  315. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  316. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  317. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  318. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  319. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  320. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  321. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  322. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  323. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  324. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  325. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  326. package/prisma/migrations/migration_lock.toml +3 -0
  327. package/prisma/schema.prisma +457 -0
  328. package/public/Logo_icon.svg +1 -0
  329. package/public/file.svg +1 -0
  330. package/public/globe.svg +1 -0
  331. package/public/icon-192.png +0 -0
  332. package/public/icon-512.png +0 -0
  333. package/public/icon.svg +4 -0
  334. package/public/icon_dark.svg +1 -0
  335. package/public/knotpad_icon.svg +1 -0
  336. package/public/knotpad_logo_full.svg +1 -0
  337. package/public/manifest.json +14 -0
  338. package/public/next.svg +1 -0
  339. package/public/sw.js +137 -0
  340. package/public/vercel.svg +1 -0
  341. package/public/window.svg +1 -0
  342. package/tsconfig.json +35 -0
@@ -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&apos;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 &ldquo;busy blocks&rdquo; in Knotpad&apos;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
+ }