Compare commits

..

13 Commits

10 changed files with 701 additions and 197 deletions

View File

@@ -13,6 +13,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4", "drizzle-orm": "^0.44.4",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
@@ -25,7 +26,9 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
}, },
"devDependencies": { "devDependencies": {
@@ -55,6 +58,8 @@
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="], "@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], "@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
@@ -507,6 +512,10 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -897,6 +906,8 @@
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
"react-day-picker": ["react-day-picker@9.9.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg=="],
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -953,6 +964,8 @@
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4", "drizzle-orm": "^0.44.4",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
@@ -30,7 +31,9 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -3,113 +3,153 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9232 0.0026 48.7171);
--foreground: oklch(0.2795 0.0368 260.0310);
--card: oklch(0.9699 0.0013 106.4238);
--card-foreground: oklch(0.2795 0.0368 260.0310);
--popover: oklch(0.9699 0.0013 106.4238);
--popover-foreground: oklch(0.2795 0.0368 260.0310);
--primary: oklch(0.5854 0.2041 277.1173);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8687 0.0043 56.3660);
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
--muted: oklch(0.9232 0.0026 48.7171);
--muted-foreground: oklch(0.5510 0.0234 264.3637);
--accent: oklch(0.9376 0.0260 321.9388);
--accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8687 0.0043 56.3660);
--input: oklch(0.8687 0.0043 56.3660);
--ring: oklch(0.5854 0.2041 277.1173);
--chart-1: oklch(0.5854 0.2041 277.1173);
--chart-2: oklch(0.5106 0.2301 276.9656);
--chart-3: oklch(0.4568 0.2146 277.0229);
--chart-4: oklch(0.3984 0.1773 277.3662);
--chart-5: oklch(0.3588 0.1354 278.6973);
--sidebar: oklch(0.8687 0.0043 56.3660);
--sidebar-foreground: oklch(0.2795 0.0368 260.0310);
--sidebar-primary: oklch(0.5854 0.2041 277.1173);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9376 0.0260 321.9388);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
--sidebar-border: oklch(0.8687 0.0043 56.3660);
--sidebar-ring: oklch(0.5854 0.2041 277.1173);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2244 0.0074 67.4370);
--foreground: oklch(0.9288 0.0126 255.5078);
--card: oklch(0.2801 0.0080 59.3379);
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2801 0.0080 59.3379);
--popover-foreground: oklch(0.9288 0.0126 255.5078);
--primary: oklch(0.5994 0.1568 47.5224);
--primary-foreground: oklch(0.2244 0.0074 67.4370);
--secondary: oklch(0.3359 0.0077 59.4197);
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
--muted: oklch(0.2801 0.0080 59.3379);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3896 0.0074 59.4734);
--accent-foreground: oklch(0.8717 0.0093 258.3382);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2244 0.0074 67.4370);
--border: oklch(0.3359 0.0077 59.4197);
--input: oklch(0.3359 0.0077 59.4197);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.5854 0.2041 277.1173);
--chart-3: oklch(0.5106 0.2301 276.9656);
--chart-4: oklch(0.4568 0.2146 277.0229);
--chart-5: oklch(0.3984 0.1773 277.3662);
--sidebar: oklch(0.3359 0.0077 59.4197);
--sidebar-foreground: oklch(0.9288 0.0126 255.5078);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0.2244 0.0074 67.4370);
--sidebar-accent: oklch(0.3896 0.0074 59.4734);
--sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
--sidebar-border: oklch(0.3359 0.0077 59.4197);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
}
:root { --shadow-2xs: var(--shadow-2xs);
--radius: 0.65rem; --shadow-xs: var(--shadow-xs);
--background: oklch(1 0 0); --shadow-sm: var(--shadow-sm);
--foreground: oklch(0.141 0.005 285.823); --shadow: var(--shadow);
--card: oklch(1 0 0); --shadow-md: var(--shadow-md);
--card-foreground: oklch(0.141 0.005 285.823); --shadow-lg: var(--shadow-lg);
--popover: oklch(1 0 0); --shadow-xl: var(--shadow-xl);
--popover-foreground: oklch(0.141 0.005 285.823); --shadow-2xl: var(--shadow-2xl);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
} }
@layer base { @layer base {

View File

@@ -1,14 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist, Magra } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import SignIn from "@/components/sign-in"; import SignIn from "@/components/sign-in";
import AuthSessionProvider from "@/components/SessionProvider"; import AuthSessionProvider from "@/components/SessionProvider";
import { Toaster } from "@/components/ui/sonner";
import Link from "next/link" import Link from "next/link"
const geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" }) const geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" })
const magra = Magra({ subsets: ["latin"], weight: "400", variable: "--font-cascadia-code" })
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Local iCal', title: 'Local iCal',
description: 'Local iCal editor for calendar events', description: 'Local iCal editor for calendar events',
@@ -32,9 +35,11 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<header className="dark:text-white text-gray-900 px-4 py-3 font-bold shadow flex justify-between items-center-safe"> <header className="dark:text-white text-gray-900 px-4 py-3 font-bold flex justify-between items-center-safe">
<Link href={"/"}> <Link href={"/"}>
<p className={`${magra.variable}`}>
{metadata.title as string || "iCal PWA"} {metadata.title as string || "iCal PWA"}
</p>
</Link> </Link>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<SignIn /> <SignIn />
@@ -42,6 +47,7 @@ export default function RootLayout({
</div> </div>
</header> </header>
<main className="flex-1 p-4">{children}</main> <main className="flex-1 p-4">{children}</main>
<Toaster closeButton richColors />
</ThemeProvider> </ThemeProvider>
</AuthSessionProvider> </AuthSessionProvider>
</body> </body>

View File

@@ -5,7 +5,10 @@ import { nanoid } from 'nanoid'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card' import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { LucideMapPin, Clock, MoreHorizontal, Calendar1Icon } from 'lucide-react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { RecurrencePicker } from '@/components/recurrence-picker' import { RecurrencePicker } from '@/components/recurrence-picker'
import { IcsFilePicker } from '@/components/ics-file-picker' import { IcsFilePicker } from '@/components/ics-file-picker'
@@ -14,6 +17,112 @@ import { parseICS, generateICS } from '@/lib/ical'
import type { CalendarEvent } from '@/lib/types' import type { CalendarEvent } from '@/lib/types'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { toast } from 'sonner'
// Individual event card component
const EventCard = ({ event, onEdit, onDelete }: { event: CalendarEvent, onEdit: (event: CalendarEvent) => void, onDelete: (eventId: string) => void }) => {
const formatDateTime = (dateStr: string, allDay: boolean | undefined) => {
return allDay
? new Date(dateStr).toLocaleDateString()
: new Date(dateStr).toLocaleString()
}
const handleEdit = () => {
onEdit({
id: event.id,
title: event.title,
description: event.description || '',
location: event.location || '',
url: event.url || '',
start: event.start,
end: event.end || '',
allDay: event.allDay || false
})
}
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<h3 className="font-semibold leading-none tracking-tight">
{event.title}
</h3>
{event.recurrenceRule && (
<Badge variant="secondary" className="text-xs">
Repeats: {event.recurrenceRule}
</Badge>
)}
{event.description && (
<p className="text-sm text-muted-foreground mt-2 break-words">
{event.description}
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
Edit event
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(event.id)}
className="text-destructive"
>
Delete event
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4" />
{formatDateTime(event.start, event.allDay)}
</div>
{event.location && (
<div className="flex items-center text-sm text-muted-foreground">
<LucideMapPin className="mr-2 h-4 w-4" />
{event.location}
</div>
)}
</div>
</CardContent>
</Card>
)
}
const EventsList = ({ events, onEdit, onDelete }: { events: CalendarEvent[], onEdit: (event: CalendarEvent) => void, onDelete: (eventId: string) => void }) => {
if (events.length === 0) {
return (<div className="flex flex-col items-center justify-center py-8 text-center">
<Calendar1Icon className='h-12 w-12 text-muted-foreground mb-4' />
<h3 className="text-lg font-medium text-muted-foreground">No events yet</h3>
<p className="text-sm text-muted-foreground">Create your first event to get started</p>
</div>)
}
return (
<div className="space-y-4">
{events.map(event => (
<EventCard
key={event.id}
event={event}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)
}
export default function HomePage() { export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]) const [events, setEvents] = useState<CalendarEvent[]>([])
@@ -128,7 +237,7 @@ export default function HomePage() {
if (file.name.endsWith('.ics')) { if (file.name.endsWith('.ics')) {
handleImport(file) handleImport(file)
} else { } else {
alert('Please drop an .ics file') toast.warning('Please drop an .ics file')
} }
} }
} }
@@ -137,6 +246,8 @@ export default function HomePage() {
const handleAiCreate = async () => { const handleAiCreate = async () => {
if (!aiPrompt.trim()) return if (!aiPrompt.trim()) return
setAiLoading(true) setAiLoading(true)
const promise = (): Promise<{ message: string }> => new Promise(async (resolve, reject) => {
try { try {
const res = await fetch('/api/ai-event', { const res = await fetch('/api/ai-event', {
method: 'POST', method: 'POST',
@@ -145,8 +256,10 @@ export default function HomePage() {
}) })
if (res.status === 401) { if (res.status === 401) {
alert('Please sign in to use AI features.')
setAiLoading(false) setAiLoading(false)
reject({
message: 'Please sign in to use AI features.'
})
return return
} }
@@ -164,8 +277,13 @@ export default function HomePage() {
setEnd(ev.end || '') setEnd(ev.end || '')
setAllDay(ev.allDay || false) setAllDay(ev.allDay || false)
setEditingId(null) setEditingId(null)
setAiPrompt("")
setDialogOpen(true) setDialogOpen(true)
setRecurrenceRule(ev.recurrenceRule || undefined) setRecurrenceRule(ev.recurrenceRule || undefined)
resolve({
message: 'Event has been created!'
})
} else { } else {
// Save them all directly to DB // Save them all directly to DB
for (const ev of data) { for (const ev of data) {
@@ -179,18 +297,37 @@ export default function HomePage() {
} }
const stored = await getAllEvents() const stored = await getAllEvents()
setEvents(stored) setEvents(stored)
setAiPrompt("")
setSummary(`Added ${data.length} AI-generated events.`) setSummary(`Added ${data.length} AI-generated events.`)
setSummaryUpdated(new Date().toLocaleString()) setSummaryUpdated(new Date().toLocaleString())
resolve({
message: 'Event has been created!'
})
} }
} else { } else {
alert('AI did not return event data.') reject({
message: 'AI did not return event data.'
})
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
alert('Error from AI service.') reject({
} finally { message: 'Error from AI service.'
setAiLoading(false) })
} }
})
toast.promise(promise, {
loading: "Generating event...",
success: ({ message }) => {
return message
},
error: ({ message }) => {
return message
}
})
setAiLoading(false)
} }
// AI Summarize Events // AI Summarize Events
@@ -231,16 +368,18 @@ export default function HomePage() {
{/* AI Toolbar */} {/* AI Toolbar */}
{status === "loading" ? <div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div> : <div> {status === "loading" ? <div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div> : <div>
{session?.user ? ( {session?.user ? (
<div className="flex flex-row gap-4 mb-4 items-start"> <div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
<div className='w-full'> <div className='w-full'>
<Textarea <Textarea
className="wrap-anywhere min-h-12" className="wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto sm:overflow-y-visible px-3 py-2 scroll-p-8 placeholder:italic"
// Band-aid for scrollbar clipping out of the box
style={{ clipPath: "inset(0 round 1rem)" }}
placeholder='Describe event for AI to create' placeholder='Describe event for AI to create'
value={aiPrompt} value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)} onChange={e => setAiPrompt(e.target.value)}
/> />
</div> </div>
<div className='flex flex-row gap-2 pt-1.5'> <div className='flex flex-row gap-2 pt-1'>
<Button onClick={handleAiCreate} disabled={aiLoading}> <Button onClick={handleAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'} {aiLoading ? 'Thinking...' : 'AI Create'}
</Button> </Button>
@@ -249,7 +388,7 @@ export default function HomePage() {
) : ( ) : (
<div className="mb-4 p-4 border border-dashed rounded-lg text-center"> <div className="mb-4 p-4 border border-dashed rounded-lg text-center">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Sign in to unlock AI natural language event creation Sign in to unlock natural language event creation powered by AI
</div> </div>
</div> </div>
)} )}
@@ -290,41 +429,24 @@ export default function HomePage() {
</div> </div>
{/* Event List */} {/* Event List */}
{events.length === 0 && <p className="text-gray-500 italic">No events yet</p>} {events.length === 0 && (
<ul className="space-y-2"> <>
{events.map(ev => ( </>)}
<li key={ev.id} className="p-3 border rounded flex justify-between items-start"> <EventsList
<div> events={events}
<div className="font-semibold">{ev.title}</div> onEdit={(eventData) => {
{ev.recurrenceRule && ( setTitle(eventData.title)
<div className="text-xs text-blue-600 mt-1"> setDescription(eventData.description || "")
Repeats: {ev.recurrenceRule} setLocation(eventData.location || "")
</div> setUrl(eventData.url || "")
)} setStart(eventData.start)
<div className="text-sm text-gray-500"> setEnd(eventData.end || "")
{ev.allDay ? ev.start.split('T')[0] : new Date(ev.start).toLocaleString()} setAllDay(eventData.allDay || false)
{ev.location && <span> @ {ev.location}</span>} setEditingId(eventData.id)
</div>
{ev.description && <div className="text-sm mt-1 wrap-anywhere">{ev.description}</div>}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => {
setTitle(ev.title)
setDescription(ev.description || '')
setLocation(ev.location || '')
setUrl(ev.url || '')
setStart(ev.start)
setEnd(ev.end || '')
setAllDay(ev.allDay || false)
setEditingId(ev.id)
setDialogOpen(true) setDialogOpen(true)
}}>Edit</Button> }}
<Button variant="secondary" size="sm" onClick={() => handleDelete(ev.id)}>Delete</Button> onDelete={handleDelete}
</div> />
</li>
))}
</ul>
{/* Add/Edit Dialog */} {/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}> <Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}>

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Moon, Sun } from "lucide-react" import { Moon, Sun, Monitor } from "lucide-react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -12,15 +12,51 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
type ThemeIconProps = {
theme?: string
}
const ThemeIcon = ({ theme }: ThemeIconProps) => {
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
switch (theme) {
case "light":
return (
<Sun className="absolute h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
)
case "dark":
return (
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
)
case "system":
return (
<Monitor className="absolute h-[1.2rem] w-[1.2rem] scale-100" />
)
default:
return (<>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</>)
}
}
export function ModeToggle() { export function ModeToggle() {
const { setTheme } = useTheme() const { setTheme, theme } = useTheme()
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> <ThemeIcon theme={theme} />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "active:scale-[.95] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium duration-100 transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }