Compare commits

...

58 Commits

Author SHA1 Message Date
10ad239259 build: update bun lockfile version 2025-12-12 09:31:20 -05:00
755076351b security: bump nextjs version 2025-12-12 09:31:03 -05:00
28c982ee37 fix: docker build 2025-12-12 09:27:02 -05:00
d7dc911db4 Update next to fix react2shell CVE
Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
2025-12-06 09:34:25 -05:00
f20f3d66a1 Simplify dropdown menu labels in event card
Remove redundant "event" text from Edit and Delete menu items for cleaner UI
2025-08-22 13:38:57 -04:00
824768ce93 Add RRuleDisplay component and clean up unused imports
- Create new RRuleDisplay component for better recurrence rule formatting
- Replace Badge with RRuleDisplay in EventCard for improved UX
- Remove unused imports across multiple files (CalendarEvent, Badge, Card components)
- Remove unused catch parameter in ai-event route
2025-08-22 13:35:13 -04:00
eb73f9f929 Refactor event management into reusable components
- Extract EventCard, EventsList, and event dialog into separate components
- Add new AI toolbar and drag-drop container components
- Simplify main page.tsx by removing inline component definitions
- Improve code organization and maintainability
2025-08-22 12:33:07 -04:00
6ab2946e8a refactor events list as shadcn cards 2025-08-20 14:12:32 -04:00
cde44ee2d7 fix grammar 2025-08-20 14:11:53 -04:00
655517a27c animate button clicks for UX 2025-08-20 14:07:22 -04:00
9a836fc866 refactor ai event creation into a promise toast 2025-08-20 13:14:29 -04:00
275e83a6c0 replace alerts with toasts 2025-08-20 13:13:52 -04:00
d8d0039c44 install 'sonner' toask 2025-08-20 13:12:08 -04:00
6e6e9b0699 update logo font 2025-08-20 12:36:33 -04:00
044e4fbb07 remove shadow from the header 2025-08-20 12:36:22 -04:00
2d0da9dbeb autoresize textfield with content & minor ui tweaks 2025-08-20 12:22:38 -04:00
d8e55e85a1 'fix' hydration error by rendering on the client 2025-08-20 11:27:11 -04:00
e0ff037c06 clear ai event prompt after generation 2025-08-20 11:14:24 -04:00
46a99775a0 add icon for 'system' theme 2025-08-20 11:04:25 -04:00
d50d77538b change shadcn theme 2025-08-20 11:04:05 -04:00
308f5c8380 fix d&d cta position 2025-08-19 05:52:28 -04:00
112ab01445 moved sign-in component to a proper folder 2025-08-19 05:41:46 -04:00
6818046d58 adjust recurrence picker to not be a card 2025-08-19 05:41:28 -04:00
ef035e2b7d add labels to action button sections 2025-08-19 05:33:36 -04:00
1a30b729e6 fix sign buttons inconsistencies in the header 2025-08-19 05:32:29 -04:00
383ce4c33a Merge branch 'drizzle-claude' 2025-08-19 05:02:22 -04:00
caa89a87de update metadata & use logo as a link to root 2025-08-19 05:00:58 -04:00
fa39d7584b fix csr nextjs build error
https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
2025-08-19 04:35:07 -04:00
f92c79ac60 implement auth error page and moved auth-related pages to /auth path 2025-08-19 04:27:13 -04:00
12e9ec5d85 proper user/session creation and auth integration into UI 2025-08-19 03:40:06 -04:00
1a13013b45 use different env files based on node env 2025-08-19 01:54:19 -04:00
92535f7e54 claude drizzle integration 2025-08-19 01:54:19 -04:00
9942a11c0d drizzle setup 2025-08-19 01:54:19 -04:00
8808daead3 use independent db
easier for local development
2025-08-19 01:54:19 -04:00
c0740877b5 minor style adjustment 2025-08-18 17:05:02 -04:00
42f3521cd7 update .env file 2025-08-17 11:41:27 -04:00
3bb320b59f update docker-compose with a db 2025-08-17 11:15:30 -04:00
20b26274d4 create example .env files 2025-08-17 11:15:17 -04:00
a5aebca54c install packages 2025-08-17 10:50:32 -04:00
ba30ad4bc7 tidy up docker-compose.yml 2025-08-16 22:15:52 -04:00
1fdc20ee0c fix random auth errors
idk how i did it
2025-08-16 22:15:22 -04:00
1fe3ef0ee1 fix untrustedHost authjs issue
https://authjs.dev/reference/core/errors#untrustedhost
2025-08-16 19:39:46 -04:00
b17ed18d46 remove explicit coloring from shadcn summary card 2025-08-16 19:32:39 -04:00
63d32931b2 move summarize button to action bar 2025-08-16 19:32:15 -04:00
0e919ea69b create auth session provider 2025-08-16 19:26:19 -04:00
0696382d43 Merge branch 'auth-v2' 2025-08-16 19:10:14 -04:00
2c6737ceb4 implement authentik auth 2025-08-16 19:09:57 -04:00
ad54758193 update recurrence picker with shadcn components 2025-08-16 12:14:40 -04:00
bb00d9548d use textarea instead of input for ai prompt 2025-08-16 11:48:55 -04:00
6ec15c9124 fix description overflow 2025-08-16 11:34:47 -04:00
e7de213c18 use button as a file picker 2025-08-16 11:34:34 -04:00
f747a82322 actual dark mode theme 2025-08-16 11:05:47 -04:00
eea61bcb0f shadcn orange theme 2025-08-16 10:33:55 -04:00
cecf3a8d5b add dark theme support 2025-08-16 10:33:37 -04:00
69e61573cb Merge branch 'pwa' 2025-08-16 09:21:46 -04:00
fefd0c47d6 init native nextjs pwa 2025-08-16 09:21:32 -04:00
dadf518c2d Merge branch 'advanced-dockerfile' 2025-08-16 09:10:09 -04:00
98072c6d67 improve dockerfile
- multi-stage build
- standalone output
- layer caching
- production dependencies
2025-08-16 09:07:00 -04:00
54 changed files with 3429 additions and 1116 deletions

View File

@@ -10,7 +10,8 @@ LICENSE
.vscode .vscode
Makefile Makefile
helm-charts helm-charts
.env .env*.local
.vercel
.editorconfig .editorconfig
.idea .idea
coverage* coverage*

3
.env.db.example Normal file
View File

@@ -0,0 +1,3 @@
POSTGRES_PASSWORD=
POSTGRES_USER=
POSTGRES_DB=

9
.env.production.example Normal file
View File

@@ -0,0 +1,9 @@
OPENROUTER_API_KEY=
AUTH_AUTHENTIK_CLIENT_ID=
AUTH_AUTHENTIK_CLIENT_SECRET=notsosupersecret
AUTH_AUTHENTIK_ISSUER=https://example.com
NEXTAUTH_URL=https://example.com
AUTH_SECRET=supersecret
NEXTAUTH_SECRET=supersecret
DB_URL=postgres://<user>:<password>@<host>:<port>/<database>

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files # dotenv environment variable files
.env* .env*
!.env*.example
# caches # caches
.eslintcache .eslintcache

View File

@@ -1,14 +1,61 @@
# syntax=docker.io/docker/dockerfile:1 # syntax=docker.io/docker/dockerfile:1
FROM oven/bun:1.2.10 AS base # Use multi-stage build for optimization
FROM imbios/bun-node:latest-current-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json bun.lock* ./ COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
# Install dependencies
RUN bun install --frozen-lockfile --production
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Configure Next.js for standalone output
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun add --exact --dev typescript
# Build the application
RUN bun run build RUN bun run build
# Production image, copy all the files and run next
FROM imbios/bun-node:latest-current-alpine AS runner
WORKDIR /app
# Create non-root user for security
# RUN addgroup --system --gid 1001 nodejs
# RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder stage
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set environment variables
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Switch to non-root user
USER bun
# Expose port
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "start"] # Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD bun --version || exit 1
# Start the application
CMD ["bun", "run", "server.js"]

View File

@@ -1,4 +1,4 @@
# FIXME # FIXME
- [] minimatch types - [ ] minimatch types
https://github.com/strapi/strapi/issues/23859 https://github.com/strapi/strapi/issues/23859

1013
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,8 @@
services: services:
ical-pwa: local-ical:
build: . build: .
container_name: ical-pwa container_name: local-ical
restart: unless-stopped restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# NODE_ENV: production
# OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
# volumes:
# - .:/app
# - /app/node_modules
networks: networks:
- traefik - traefik
- internal - internal
@@ -27,4 +19,4 @@ networks:
external: true external: true
internal: internal:
external: false external: false
name: ical-local-network name: local-ical-network

21
drizzle.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
if (!process.env.DATABASE_URL) {
if (process.env.NODE_ENV === "production") {
dotenv.config({ path: '.env.production' });
} else {
dotenv.config({ path: '.env.local' });
}
}
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,56 @@
-- Current sql file was generated after introspecting the database
-- If you want to run this migration please uncomment this code before executing migrations
/*
CREATE TABLE "session" (
"sessionToken" text PRIMARY KEY NOT NULL,
"userId" text NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"email" text NOT NULL,
"emailVerified" timestamp,
"image" text
);
--> statement-breakpoint
CREATE TABLE "verificationToken" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
CREATE TABLE "authenticator" (
"credentialID" text NOT NULL,
"userId" text NOT NULL,
"providerAccountId" text NOT NULL,
"credentialPublicKey" text NOT NULL,
"counter" integer NOT NULL,
"credentialDeviceType" text NOT NULL,
"credentialBackedUp" boolean NOT NULL,
"transports" text,
CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("credentialID","userId"),
CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID")
);
--> statement-breakpoint
CREATE TABLE "account" (
"userId" text NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"providerAccountId" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" text,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
*/

View File

@@ -0,0 +1,344 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"prevId": "",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.session": {
"name": "session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.verificationToken": {
"name": "verificationToken",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.authenticator": {
"name": "authenticator",
"schema": "",
"columns": {
"credentialID": {
"name": "credentialID",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialPublicKey": {
"name": "credentialPublicKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"credentialDeviceType": {
"name": "credentialDeviceType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialBackedUp": {
"name": "credentialBackedUp",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"authenticator_userId_user_id_fk": {
"name": "authenticator_userId_user_id_fk",
"tableFrom": "authenticator",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"authenticator_userId_credentialID_pk": {
"name": "authenticator_userId_credentialID_pk",
"columns": [
"credentialID",
"userId"
]
}
},
"uniqueConstraints": {
"authenticator_credentialID_unique": {
"columns": [
"credentialID"
],
"nullsNotDistinct": false,
"name": "authenticator_credentialID_unique"
}
},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1755586325384,
"tag": "0000_loose_catseye",
"breakpoints": true
}
]
}

29
drizzle/relations.ts Normal file
View File

@@ -0,0 +1,29 @@
import { relations } from "drizzle-orm/relations";
import { user, session, authenticator, account } from "./schema";
export const sessionRelations = relations(session, ({one}) => ({
user: one(user, {
fields: [session.userId],
references: [user.id]
}),
}));
export const userRelations = relations(user, ({many}) => ({
sessions: many(session),
authenticators: many(authenticator),
accounts: many(account),
}));
export const authenticatorRelations = relations(authenticator, ({one}) => ({
user: one(user, {
fields: [authenticator.userId],
references: [user.id]
}),
}));
export const accountRelations = relations(account, ({one}) => ({
user: one(user, {
fields: [account.userId],
references: [user.id]
}),
}));

72
drizzle/schema.ts Normal file
View File

@@ -0,0 +1,72 @@
import { pgTable, foreignKey, text, timestamp, primaryKey, unique, integer, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const session = pgTable("session", {
sessionToken: text().primaryKey().notNull(),
userId: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "session_userId_user_id_fk"
}).onDelete("cascade"),
]);
export const user = pgTable("user", {
id: text().primaryKey().notNull(),
name: text(),
email: text().notNull(),
emailVerified: timestamp({ mode: 'string' }),
image: text(),
});
export const verificationToken = pgTable("verificationToken", {
identifier: text().notNull(),
token: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull(),
}, (table) => [
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
]);
export const authenticator = pgTable("authenticator", {
credentialId: text().notNull(),
userId: text().notNull(),
providerAccountId: text().notNull(),
credentialPublicKey: text().notNull(),
counter: integer().notNull(),
credentialDeviceType: text().notNull(),
credentialBackedUp: boolean().notNull(),
transports: text(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "authenticator_userId_user_id_fk"
}).onDelete("cascade"),
primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
unique("authenticator_credentialID_unique").on(table.credentialId),
]);
export const account = pgTable("account", {
userId: text().notNull(),
type: text().notNull(),
provider: text().notNull(),
providerAccountId: text().notNull(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: text("expires_at"),
tokenType: text("token_type"),
scope: text(),
idToken: text("id_token"),
sessionState: text("session_state"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "account_userId_user_id_fk"
}).onDelete("cascade"),
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
]);

View File

@@ -1,21 +1,49 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const withPWA = require("next-pwa")({ const nextConfig: NextConfig = {
dest: "public",
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === "development",
});
// const nextConfig: NextConfig = {
// /* config options here */
// reactStrictMode: true,
// };
const nextConfig: NextConfig = withPWA({
/* config options here */
reactStrictMode: true, reactStrictMode: true,
// output: "standalone", output: "standalone",
}); images: {
formats: ["image/webp", "image/avif"],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
{
source: "/sw.js",
headers: [
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self'",
},
],
},
];
},
};
export default nextConfig; export default nextConfig;

View File

@@ -9,29 +9,45 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.10.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@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",
"drizzle-orm": "^0.44.4",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.4.6", "next": "15.4.10",
"next-pwa": "^5.6.0", "next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"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": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.15.5",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.4.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.4",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"typescript": "^5" "typescript": "^5"
}, },

View File

@@ -1,6 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth";
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
const { prompt } = await request.json(); const { prompt } = await request.json();
const systemPrompt = ` const systemPrompt = `
@@ -50,7 +60,7 @@ Rules:
const content = data.choices[0].message.content; const content = data.choices[0].message.content;
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return NextResponse.json(parsed); return NextResponse.json(parsed);
} catch (e) { } catch {
return NextResponse.json( return NextResponse.json(
{ error: "Failed to parse AI output", raw: data }, { error: "Failed to parse AI output", raw: data },
{ status: 500 }, { status: 500 },

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { CalendarEvent } from "@/lib/types";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,38 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
function Search() {
const searchParams = useSearchParams()
const errorMessage = searchParams.get('error')
return (<div className="text-center p-3 bg-background rounded-lg">
{errorMessage}
</div>)
}
export default function AuthErrorPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md bg-red-400 dark:bg-red-600">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Error</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Suspense>
<Search />
</Suspense>
<div className="flex flex-row">
<Button variant="secondary" asChild>
<Link href="/">Go back to Homepage</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { signIn, auth } from "@/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
import Link from "next/link"
export default async function SignInPage() {
const session = await auth()
// If already signed in, redirect to home
if (session?.user) {
redirect("/")
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Welcome</CardTitle>
<CardDescription>
Sign in to access AI-powered calendar features
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
action={async () => {
"use server"
await signIn("authentik", { redirectTo: "/" })
}}
>
<Button type="submit" className="w-full" size="lg">
Continue with Authentik
</Button>
</form>
<div className="text-center">
<Link href="/" className="text-sm text-muted-foreground hover:underline">
Continue without signing in
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { signOut, auth } from "@/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
import Link from "next/link"
export default async function SignOutPage() {
const session = await auth()
if (!session) {
redirect("/")
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Sign Out</CardTitle>
<CardDescription>
Are you sure you want to sign out?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">Currently signed in as</div>
<div className="font-medium">{session.user?.name || session.user?.email}</div>
</div>
<div className="grid grid-cols-2 gap-3">
<form
action={async () => {
"use server"
await signOut({ redirectTo: "/" })
}}
>
<Button type="submit" variant="destructive" className="w-full">
Sign Out
</Button>
</form>
<Button variant="outline" asChild>
<Link href="/">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

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.625rem; --shadow-xs: var(--shadow-xs);
--background: oklch(1 0 0); --shadow-sm: var(--shadow-sm);
--foreground: oklch(0.145 0 0); --shadow: var(--shadow);
--card: oklch(1 0 0); --shadow-md: var(--shadow-md);
--card-foreground: oklch(0.145 0 0); --shadow-lg: var(--shadow-lg);
--popover: oklch(1 0 0); --shadow-xl: var(--shadow-xl);
--popover-foreground: oklch(0.145 0 0); --shadow-2xl: var(--shadow-2xl);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--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.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--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.556 0 0);
--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.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {

View File

@@ -1,12 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Geist, Magra } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "next-themes";
import { ModeToggle } from "@/components/mode-toggle";
import SignIn from "@/components/sign-in";
import AuthSessionProvider from "@/components/SessionProvider";
import { Toaster } from "@/components/ui/sonner";
import Link from "next/link"
const inter = Inter({ subsets: ['latin'], variable: "--font-inter" }) const geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" })
export const metadata = { const magra = Magra({ subsets: ["latin"], weight: "400", variable: "--font-cascadia-code" })
title: 'iCal PWA',
description: 'Minimal PWA for calendar events', export const metadata: Metadata = {
title: 'Local iCal',
description: 'Local iCal editor for calendar events',
creator: "Dmytro Stanchiev",
} }
export default function RootLayout({ export default function RootLayout({
@@ -15,14 +24,32 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body <body
className={`${inter.variable} antialiased min-h-screen flex flex-col bg-gray-50 text-gray-900`} className={`${geist.variable} antialiased min-h-screen flex flex-col dark:text-gray-300 --color-background`}
> >
<header className="bg-blue-600 text-white px-4 py-3 font-bold shadow"> <AuthSessionProvider>
iCal PWA <ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<header className="dark:text-white text-gray-900 px-4 py-3 font-bold flex justify-between items-center-safe">
<Link href={"/"}>
<p className={`${magra.variable}`}>
{metadata.title as string || "iCal PWA"}
</p>
</Link>
<div className="flex flex-row gap-2">
<SignIn />
<ModeToggle />
</div>
</header> </header>
<main className="flex-1 p-4">{children}</main> <main className="flex-1 p-4">{children}</main>
<Toaster closeButton richColors />
</ThemeProvider>
</AuthSessionProvider>
</body> </body>
</html> </html>
); );

25
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "local-ical PWA",
short_name: "local-ical",
description: "Local iCal editor with AI features",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
};
}

View File

@@ -1,17 +1,20 @@
'use client' "use client"
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { Button } from '@/components/ui/button' import { useSession } from 'next-auth/react'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { RecurrencePicker } from '@/components/recurrence-picker'
import { addEvent, deleteEvent, getAllEvents, clearEvents, getDB } from '@/lib/db' import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
import { parseICS, generateICS } from '@/lib/ical' import { parseICS, generateICS } from '@/lib/ical'
import type { CalendarEvent } from '@/lib/types' import type { CalendarEvent } from '@/lib/types'
import { AIToolbar } from '@/components/ai-toolbar'
import { EventActionsToolbar } from '@/components/event-actions-toolbar'
import { EventsList } from '@/components/events-list'
import { EventDialog } from '@/components/event-dialog'
import { DragDropContainer } from '@/components/drag-drop-container'
export default function HomePage() { export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]) const [events, setEvents] = useState<CalendarEvent[]>([])
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -41,6 +44,8 @@ export default function HomePage() {
})() })()
}, []) }, [])
const { data: session, status } = useSession()
const resetForm = () => { const resetForm = () => {
setTitle('') setTitle('')
setDescription('') setDescription('')
@@ -50,6 +55,7 @@ export default function HomePage() {
setEnd('') setEnd('')
setAllDay(false) setAllDay(false)
setEditingId(null) setEditingId(null)
setRecurrenceRule(undefined)
} }
const handleSave = async () => { const handleSave = async () => {
@@ -69,11 +75,8 @@ export default function HomePage() {
lastModified: new Date().toISOString(), lastModified: new Date().toISOString(),
} }
if (editingId) { if (editingId) {
const db = await getDB() await updateEvent(eventData)
if (db) {
await db.put('events', eventData)
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e))) setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
}
} else { } else {
await addEvent(eventData) await addEvent(eventData)
setEvents(prev => [...prev, eventData]) setEvents(prev => [...prev, eventData])
@@ -115,32 +118,27 @@ export default function HomePage() {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
// Drag-and-drop
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) }
const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false) }
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0]
if (file.name.endsWith('.ics')) {
handleImport(file)
} else {
alert('Please drop an .ics file')
}
}
}
// AI Create Event // AI Create Event
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',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: aiPrompt }) body: JSON.stringify({ prompt: aiPrompt })
}) })
if (res.status === 401) {
setAiLoading(false)
reject({
message: 'Please sign in to use AI features.'
})
return
}
const data = await res.json() const data = await res.json()
if (Array.isArray(data) && data.length > 0) { if (Array.isArray(data) && data.length > 0) {
@@ -155,8 +153,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) {
@@ -170,18 +173,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
@@ -214,123 +236,74 @@ export default function HomePage() {
} }
} }
return ( const handleEdit = (eventData: CalendarEvent) => {
<div onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} setTitle(eventData.title)
className={`p-4 min-h-[80vh] rounded border-2 border-dashed transition ${isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300' setDescription(eventData.description || "")
}`} setLocation(eventData.location || "")
> setUrl(eventData.url || "")
{/* AI Toolbar */} setStart(eventData.start)
<div className="flex flex-wrap gap-2 mb-4 items-center"> setEnd(eventData.end || "")
<Input setAllDay(eventData.allDay || false)
className="flex-1" setEditingId(eventData.id)
placeholder='Describe event for AI to create' setRecurrenceRule(eventData.recurrenceRule)
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
<Button onClick={handleAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
<Button variant="secondary" onClick={handleAiSummarize} disabled={aiLoading}>
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
</Button>
</div>
{/* Summary Panel */}
{summary && (
<Card className="p-4 mb-4 bg-gray-50 border border-gray-200">
<div className="text-sm text-gray-500 mb-1">
Summary updated {summaryUpdated}
</div>
<div>{summary}</div>
</Card>
)}
{/* Control Toolbar */}
<div className="flex flex-wrap gap-2 mb-4">
<Button onClick={() => setDialogOpen(true)}>Add Event</Button>
{events.length > 0 && (
<>
<Button variant="secondary" onClick={handleExport}>Export .ics</Button>
<Button variant="destructive" onClick={handleClearAll}>Clear All</Button>
</>
)}
<label className="cursor-pointer">
<span className="px-3 py-2 bg-blue-500 text-white rounded">Import .ics</span>
<input type="file" accept=".ics" hidden onChange={e => {
if (e.target.files?.length) handleImport(e.target.files[0])
}} />
</label>
</div>
{/* Event List */}
{events.length === 0 && <p className="text-gray-500 italic">No events yet</p>}
<ul className="space-y-2">
{events.map(ev => (
<li key={ev.id} className="p-3 border rounded flex justify-between items-start">
<div>
<div className="font-semibold">{ev.title}</div>
{ev.recurrenceRule && (
<div className="text-xs text-blue-600 mt-1">
Repeats: {ev.recurrenceRule}
</div>
)}
<div className="text-sm text-gray-500">
{ev.allDay ? ev.start.split('T')[0] : new Date(ev.start).toLocaleString()}
{ev.location && <span> @ {ev.location}</span>}
</div>
{ev.description && <div className="text-sm mt-1">{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>
</div>
</li>
))}
</ul>
{/* Add/Edit Dialog */} return (
<Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}> <DragDropContainer
<DialogContent> isDragOver={isDragOver}
<DialogHeader> setIsDragOver={setIsDragOver}
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle> onImport={handleImport}
</DialogHeader> >
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} /> <AIToolbar
<textarea className="border rounded p-2 w-full" placeholder="Description" session={session}
value={description} onChange={e => setDescription(e.target.value)} /> status={status}
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} /> aiPrompt={aiPrompt}
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} /> setAiPrompt={setAiPrompt}
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} /> aiLoading={aiLoading}
onAiCreate={handleAiCreate}
onAiSummarize={handleAiSummarize}
summary={summary}
summaryUpdated={summaryUpdated}
/>
<label className="flex items-center gap-2 mt-2"> <EventActionsToolbar
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} /> events={events}
All day event onAddEvent={() => setDialogOpen(true)}
</label> onImport={handleImport}
{!allDay ? ( onExport={handleExport}
<> onClearAll={handleClearAll}
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} /> />
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
</> <EventsList
) : ( events={events}
<> onEdit={handleEdit}
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} /> onDelete={handleDelete}
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} /> />
</>
)} <EventDialog
<DialogFooter> open={dialogOpen}
<Button onClick={handleSave}>{editingId ? 'Update' : 'Save'}</Button> onOpenChange={setDialogOpen}
</DialogFooter> editingId={editingId}
</DialogContent> title={title}
</Dialog> setTitle={setTitle}
</div> description={description}
setDescription={setDescription}
location={location}
setLocation={setLocation}
url={url}
setUrl={setUrl}
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allDay={allDay}
setAllDay={setAllDay}
recurrenceRule={recurrenceRule}
setRecurrenceRule={setRecurrenceRule}
onSave={handleSave}
onReset={resetForm}
/>
</DragDropContainer>
) )
} }

35
src/auth.ts Normal file
View File

@@ -0,0 +1,35 @@
import NextAuth, { NextAuthConfig, NextAuthResult } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import type { Provider } from "next-auth/providers";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db/index";
const providers: Provider[] = [
Authentik({
clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
];
export const providerMap = providers.map((provider) => {
if (typeof provider === "function") {
const providerData = provider();
return { id: providerData.id, name: providerData.name };
} else {
return { id: provider.id, name: provider.name };
}
});
const config = {
adapter: DrizzleAdapter(db),
providers,
pages: {
signIn: "/auth/signin",
signOut: "/auth/signout",
error: "/auth/error",
},
trustHost: true,
} satisfies NextAuthConfig;
export const { handlers, signIn, signOut, auth }: NextAuthResult =
NextAuth(config);

View File

@@ -0,0 +1,12 @@
"use client"
import { SessionProvider } from "next-auth/react"
import { ReactNode } from "react"
interface Props {
children: ReactNode
}
export default function AuthSessionProvider({ children }: Props) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@@ -0,0 +1,83 @@
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card } from '@/components/ui/card'
import { Session } from 'next-auth'
interface AIToolbarProps {
session: Session | null
status: 'loading' | 'authenticated' | 'unauthenticated'
aiPrompt: string
setAiPrompt: (prompt: string) => void
aiLoading: boolean
onAiCreate: () => void
onAiSummarize: () => void
summary: string | null
summaryUpdated: string | null
}
export const AIToolbar = ({
session,
status,
aiPrompt,
setAiPrompt,
aiLoading,
onAiCreate,
onAiSummarize,
summary,
summaryUpdated
}: AIToolbarProps) => {
return (
<>
{/* AI Toolbar */}
{status === "loading" ? (
<div className='mb-4 p-4 text-center animate-pulse bg-muted'>Loading...</div>
) : (
<div>
{session?.user ? (
<div className="flex flex-col sm:flex-row gap-4 mb-4 items-start">
<div className='w-full'>
<Textarea
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'
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
</div>
<div className='flex flex-row gap-2 pt-1'>
<Button onClick={onAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
</div>
</div>
) : (
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
<div className="text-sm text-muted-foreground">
Sign in to unlock natural language event creation powered by AI
</div>
</div>
)}
</div>
)}
{/* Summary Panel */}
{summary && (
<Card className="p-4 mb-4">
<div className="text-sm mb-1">
Summary updated {summaryUpdated}
</div>
<div>{summary}</div>
</Card>
)}
{/* AI Actions Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>AI actions</p>
<div className="gap-2 mb-4">
<Button variant="secondary" onClick={onAiSummarize} disabled={aiLoading}>
{aiLoading ? 'Summarizing...' : 'AI Summarize'}
</Button>
</div>
</>
)
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react'
import { toast } from 'sonner'
interface DragDropContainerProps {
children: ReactNode
isDragOver: boolean
setIsDragOver: (isDragOver: boolean) => void
onImport: (file: File) => void
}
export const DragDropContainer = ({
children,
isDragOver,
setIsDragOver,
onImport
}: DragDropContainerProps) => {
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
if (e.dataTransfer.files?.length) {
const file = e.dataTransfer.files[0]
if (file.name.endsWith('.ics')) {
onImport(file)
} else {
toast.warning('Please drop an .ics file')
}
}
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`p-4 min-h-[80vh] flex flex-col rounded border-2 border-dashed transition ${
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-700'
}`}
>
{children}
<div className='mt-auto w-full pb-4 text-gray-400'>
<div className='max-w-fit m-auto'>Drag & Drop *.ics here</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button'
import { IcsFilePicker } from '@/components/ics-file-picker'
import type { CalendarEvent } from '@/lib/types'
interface EventActionsToolbarProps {
events: CalendarEvent[]
onAddEvent: () => void
onImport: (file: File) => void
onExport: () => void
onClearAll: () => void
}
export const EventActionsToolbar = ({
events,
onAddEvent,
onImport,
onExport,
onClearAll
}: EventActionsToolbarProps) => {
return (
<>
{/* Control Toolbar */}
<p className='text-muted-foreground text-sm pb-2 pl-1'>Event Actions</p>
<div className="flex flex-wrap gap-2 mb-4">
<Button onClick={onAddEvent}>Add Event</Button>
<IcsFilePicker onFileSelect={onImport} variant='secondary'>Import .ics</IcsFilePicker>
{events.length > 0 && (
<>
<Button variant="secondary" onClick={onExport}>Export .ics</Button>
<Button variant="destructive" onClick={onClearAll}>Clear All</Button>
</>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
import { LucideMapPin, Clock, MoreHorizontal } from 'lucide-react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { RRuleDisplay } from '@/components/rrule-display'
import type { CalendarEvent } from '@/lib/types'
interface EventCardProps {
event: CalendarEvent
onEdit: (event: CalendarEvent) => void
onDelete: (eventId: string) => void
}
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
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 && (
<div className="mt-1">
<RRuleDisplay rrule={event.recurrenceRule} />
</div>
)}
{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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(event.id)}
className="text-destructive"
>
Delete
</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>
)
}

View File

@@ -0,0 +1,96 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { RecurrencePicker } from '@/components/recurrence-picker'
interface EventDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editingId: string | null
title: string
setTitle: (title: string) => void
description: string
setDescription: (description: string) => void
location: string
setLocation: (location: string) => void
url: string
setUrl: (url: string) => void
start: string
setStart: (start: string) => void
end: string
setEnd: (end: string) => void
allDay: boolean
setAllDay: (allDay: boolean) => void
recurrenceRule: string | undefined
setRecurrenceRule: (rule: string | undefined) => void
onSave: () => void
onReset: () => void
}
export const EventDialog = ({
open,
onOpenChange,
editingId,
title,
setTitle,
description,
setDescription,
location,
setLocation,
url,
setUrl,
start,
setStart,
end,
setEnd,
allDay,
setAllDay,
recurrenceRule,
setRecurrenceRule,
onSave,
onReset
}: EventDialogProps) => {
const handleOpenChange = (val: boolean) => {
if (!val) onReset()
onOpenChange(val)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
</DialogHeader>
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
<textarea
className="border rounded p-2 w-full"
placeholder="Description"
value={description}
onChange={e => setDescription(e.target.value)}
/>
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
<RecurrencePicker value={recurrenceRule} onChange={setRecurrenceRule} />
<label className="flex items-center gap-2 mt-2">
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
All day event
</label>
{!allDay ? (
<>
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
</>
) : (
<>
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} />
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} />
</>
)}
<DialogFooter>
<Button onClick={onSave}>{editingId ? 'Update' : 'Save'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,34 @@
import { Calendar1Icon } from 'lucide-react'
import { EventCard } from './event-card'
import type { CalendarEvent } from '@/lib/types'
interface EventsListProps {
events: CalendarEvent[]
onEdit: (event: CalendarEvent) => void
onDelete: (eventId: string) => void
}
export const EventsList = ({ events, onEdit, onDelete }: EventsListProps) => {
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>
)
}

View File

@@ -0,0 +1,58 @@
"use client"
import type React from "react"
import { useRef } from "react"
import { Button } from "@/components/ui/button"
import { Calendar } from "lucide-react"
interface IcsFilePickerProps {
onFileSelect?: (file: File) => void
className?: string
children?: React.ReactNode
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon"
}
export function IcsFilePicker({
onFileSelect,
className,
children,
variant = "default",
size = "default",
}: IcsFilePickerProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleButtonClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file && onFileSelect) {
onFileSelect(file)
}
}
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".ics"
onChange={handleFileChange}
className="hidden"
aria-hidden="true"
/>
<Button onClick={handleButtonClick} variant={variant} size={size} className={className}>
{children || (
<>
<Calendar className="mr-2 h-4 w-4" />
Import Calendar
</>
)}
</Button>
</>
)
}

View File

@@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { Moon, Sun, Monitor } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} 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() {
const { setTheme, theme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<ThemeIcon theme={theme} />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,10 +1,13 @@
'use client' "use client"
import { useState } from 'react' import { useState } from "react"
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
type Recurrence = { type Recurrence = {
freq: 'NONE' | 'DAILY' | 'WEEKLY' | 'MONTHLY' freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
interval: number interval: number
byDay?: string[] byDay?: string[]
count?: number count?: number
@@ -20,105 +23,126 @@ export function RecurrencePicker({ value, onChange }: Props) {
const [rec, setRec] = useState<Recurrence>(() => { const [rec, setRec] = useState<Recurrence>(() => {
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL) // If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
if (value) { if (value) {
const parts = Object.fromEntries( const parts = Object.fromEntries(value.split(";").map((p) => p.split("=")))
value.split(';').map(p => p.split('='))
)
return { return {
freq: parts.FREQ || 'NONE', freq: parts.FREQ || "NONE",
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : 1, interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
byDay: parts.BYDAY ? parts.BYDAY.split(',') : [], byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined, count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
until: parts.UNTIL until: parts.UNTIL,
} }
} }
return { freq: 'NONE', interval: 1 } return { freq: "NONE", interval: 1 }
}) })
const update = (updates: Partial<Recurrence>) => { const update = (updates: Partial<Recurrence>) => {
const newRec = { ...rec, ...updates } const newRec = { ...rec, ...updates }
setRec(newRec) setRec(newRec)
if (newRec.freq === 'NONE') { if (newRec.freq === "NONE") {
onChange(undefined) onChange(undefined)
return return
} }
// Build RRULE string // Build RRULE string
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}` let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`
if (newRec.freq === 'WEEKLY' && newRec.byDay?.length) { if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
rrule += `;BYDAY=${newRec.byDay.join(',')}` rrule += `;BYDAY=${newRec.byDay.join(",")}`
} }
if (newRec.count) rrule += `;COUNT=${newRec.count}` if (newRec.count) rrule += `;COUNT=${newRec.count}`
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, '')}T000000Z` if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`
onChange(rrule) onChange(rrule)
} }
const toggleDay = (day: string) => { const toggleDay = (day: string) => {
const byDay = rec.byDay || [] const byDay = rec.byDay || []
const newByDay = byDay.includes(day) const newByDay = byDay.includes(day) ? byDay.filter((d) => d !== day) : [...byDay, day]
? byDay.filter(d => d !== day)
: [...byDay, day]
update({ byDay: newByDay }) update({ byDay: newByDay })
} }
return ( const dayLabels = {
<div className="space-y-2 border rounded p-2 mt-2 bg-gray-50"> MO: "Mon",
<label className="block font-semibold text-sm">Repeats</label> TU: "Tue",
<select WE: "Wed",
className="border p-1 rounded w-full" TH: "Thu",
value={rec.freq} FR: "Fri",
onChange={e => update({ freq: e.target.value as "NONE" | "DAILY" | "WEEKLY" | "MONTHLY" | undefined })} SA: "Sat",
> SU: "Sun",
<option value="NONE">Does not repeat</option> }
<option value="DAILY">Daily</option>
<option value="WEEKLY">Weekly</option>
<option value="MONTHLY">Monthly</option>
</select>
{rec.freq !== 'NONE' && ( return (
<div className="">
<Label htmlFor="frequency" className="pt-4 pb-2 pl-1">Repeats</Label>
<div className="space-y-2">
<Select value={rec.freq} onValueChange={(value) => update({ freq: value as Recurrence["freq"] })}>
<SelectTrigger id="frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Does not repeat</SelectItem>
<SelectItem value="DAILY">Daily</SelectItem>
<SelectItem value="WEEKLY">Weekly</SelectItem>
<SelectItem value="MONTHLY">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
{rec.freq !== "NONE" && (
<> <>
<label className="block text-sm"> <div className="space-y-2">
Interval (every N {rec.freq === 'DAILY' ? 'days' : rec.freq === 'WEEKLY' ? 'weeks' : 'months'}) <Label htmlFor="interval">
</label> Interval (every {rec.interval} {rec.freq === "DAILY" ? "day" : rec.freq === "WEEKLY" ? "week" : "month"}
{rec.interval > 1 ? "s" : ""})
</Label>
<Input <Input
id="interval"
type="number" type="number"
min={1} min={1}
value={rec.interval} value={rec.interval}
onChange={e => update({ interval: parseInt(e.target.value, 10) || 1 })} onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })}
className="w-24"
/> />
</div>
{rec.freq === 'WEEKLY' && ( {rec.freq === "WEEKLY" && (
<div className="flex gap-2 mt-2"> <div className="space-y-2">
{['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'].map(day => ( <Label>Days of the week</Label>
<label key={day} className="flex items-center gap-1"> <div className="flex flex-wrap gap-4">
<input {["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
type="checkbox" <div key={day} className="flex items-center space-x-2">
checked={rec.byDay?.includes(day)} <Checkbox
onChange={() => toggleDay(day)} id={day}
checked={rec.byDay?.includes(day) || false}
onCheckedChange={() => toggleDay(day)}
/> />
{day} <Label htmlFor={day} className="text-sm font-normal">
</label> {dayLabels[day as keyof typeof dayLabels]}
</Label>
</div>
))} ))}
</div> </div>
</div>
)} )}
<div className="flex gap-2 mt-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div className="space-y-2">
<label className="text-sm">End after count</label> <Label htmlFor="count">End after (occurrences)</Label>
<Input <Input
id="count"
type="number" type="number"
placeholder="e.g. 10" placeholder="e.g. 10"
value={rec.count || ''} value={rec.count || ""}
onChange={e => update({ count: e.target.value ? parseInt(e.target.value, 10) : undefined })} onChange={(e) => update({ count: e.target.value ? Number.parseInt(e.target.value, 10) : undefined })}
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="text-sm">End by date</label> <Label htmlFor="until">End by date</Label>
<Input <Input
id="until"
type="date" type="date"
value={rec.until || ''} value={rec.until || ""}
onChange={e => update({ until: e.target.value || undefined })} onChange={(e) => update({ until: e.target.value || undefined })}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,244 @@
import { Badge } from "@/components/ui/badge"
import type { RecurrenceRule } from "@/lib/rfc5545-types"
interface RRuleDisplayProps {
rrule: string | RecurrenceRule
className?: string
}
export function RRuleDisplay({ rrule, className }: RRuleDisplayProps) {
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
const humanText = formatRRuleToHuman(parsedRule)
return (
<div className={className}>
<span className="text-sm text-muted-foreground">{humanText}</span>
</div>
)
}
interface RRuleDisplayDetailedProps {
rrule: string | RecurrenceRule
className?: string
showBadges?: boolean
}
export function RRuleDisplayDetailed({ rrule, className, showBadges = true }: RRuleDisplayDetailedProps) {
const parsedRule = typeof rrule === 'string' ? parseRRuleString(rrule) : rrule
const humanText = formatRRuleToHuman(parsedRule)
const details = getRRuleDetails(parsedRule)
return (
<div className={className}>
<div className="space-y-2">
<div className="text-sm font-medium">{humanText}</div>
{showBadges && details.length > 0 && (
<div className="flex flex-wrap gap-1">
{details.map((detail, index) => (
<Badge key={index} variant="outline" className="text-xs">
{detail}
</Badge>
))}
</div>
)}
</div>
</div>
)
}
function parseRRuleString(rruleString: string): RecurrenceRule {
const parts = Object.fromEntries(rruleString.split(";").map(p => p.split("=")))
return {
freq: parts.FREQ as RecurrenceRule['freq'],
until: parts.UNTIL ? new Date(parts.UNTIL.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, '$1-$2-$3T$4:$5:$6Z')).toISOString() : undefined,
count: parts.COUNT ? parseInt(parts.COUNT, 10) : undefined,
interval: parts.INTERVAL ? parseInt(parts.INTERVAL, 10) : undefined,
bySecond: parts.BYSECOND ? parts.BYSECOND.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byMinute: parts.BYMINUTE ? parts.BYMINUTE.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byHour: parts.BYHOUR ? parts.BYHOUR.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byDay: parts.BYDAY ? parts.BYDAY.split(",") : undefined,
byMonthDay: parts.BYMONTHDAY ? parts.BYMONTHDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byYearDay: parts.BYYEARDAY ? parts.BYYEARDAY.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byWeekNo: parts.BYWEEKNO ? parts.BYWEEKNO.split(",").map((n: string) => parseInt(n, 10)) : undefined,
byMonth: parts.BYMONTH ? parts.BYMONTH.split(",").map((n: string) => parseInt(n, 10)) : undefined,
bySetPos: parts.BYSETPOS ? parts.BYSETPOS.split(",").map((n: string) => parseInt(n, 10)) : undefined,
wkst: parts.WKST as RecurrenceRule['wkst'],
}
}
function formatRRuleToHuman(rule: RecurrenceRule): string {
const { freq, interval = 1, count, until, byDay, byMonthDay, byMonth, byHour, byMinute, bySecond } = rule
let text = ""
// Base frequency
switch (freq) {
case 'SECONDLY':
text = interval === 1 ? "Every second" : `Every ${interval} seconds`
break
case 'MINUTELY':
text = interval === 1 ? "Every minute" : `Every ${interval} minutes`
break
case 'HOURLY':
text = interval === 1 ? "Every hour" : `Every ${interval} hours`
break
case 'DAILY':
text = interval === 1 ? "Daily" : `Every ${interval} days`
break
case 'WEEKLY':
text = interval === 1 ? "Weekly" : `Every ${interval} weeks`
break
case 'MONTHLY':
text = interval === 1 ? "Monthly" : `Every ${interval} months`
break
case 'YEARLY':
text = interval === 1 ? "Yearly" : `Every ${interval} years`
break
}
// Add day specifications
if (byDay?.length) {
const dayNames = {
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
}
const days = byDay.map(day => {
// Handle numbered days like "2TU" (second Tuesday)
const match = day.match(/^(-?\d+)?([A-Z]{2})$/)
if (match) {
const [, num, dayCode] = match
const dayName = dayNames[dayCode as keyof typeof dayNames]
if (num) {
const ordinal = getOrdinal(parseInt(num))
return `${ordinal} ${dayName}`
}
return dayName
}
return day
})
if (freq === 'WEEKLY') {
text += ` on ${formatList(days)}`
} else {
text += ` on ${formatList(days)}`
}
}
// Add month day specifications
if (byMonthDay?.length) {
const days = byMonthDay.map(day => {
if (day < 0) {
return `${getOrdinal(Math.abs(day))} to last day`
}
return getOrdinal(day)
})
text += ` on the ${formatList(days)}`
}
// Add month specifications
if (byMonth?.length) {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const months = byMonth.map(month => monthNames[month - 1])
text += ` in ${formatList(months)}`
}
// Add time specifications
if (byHour?.length || byMinute?.length || bySecond?.length) {
const timeSpecs = []
if (byHour?.length) {
const hours = byHour.map(h => `${h.toString().padStart(2, '0')}:00`)
timeSpecs.push(`at ${formatList(hours)}`)
}
if (byMinute?.length && !byHour?.length) {
timeSpecs.push(`at minute ${formatList(byMinute.map(String))}`)
}
if (bySecond?.length && !byHour?.length && !byMinute?.length) {
timeSpecs.push(`at second ${formatList(bySecond.map(String))}`)
}
if (timeSpecs.length) {
text += ` ${timeSpecs.join(' ')}`
}
}
// Add end conditions
if (count) {
text += `, ${count} time${count === 1 ? '' : 's'}`
} else if (until) {
const date = new Date(until)
text += `, until ${date.toLocaleDateString()}`
}
return text
}
function getRRuleDetails(rule: RecurrenceRule): string[] {
const details: string[] = []
if (rule.wkst && rule.wkst !== 'MO') {
const dayNames = {
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday', 'WE': 'Wednesday',
'TH': 'Thursday', 'FR': 'Friday', 'SA': 'Saturday'
}
details.push(`Week starts ${dayNames[rule.wkst]}`)
}
if (rule.byWeekNo?.length) {
details.push(`Week ${formatList(rule.byWeekNo.map(String))}`)
}
if (rule.byYearDay?.length) {
details.push(`Day ${formatList(rule.byYearDay.map(String))} of year`)
}
if (rule.bySetPos?.length) {
const positions = rule.bySetPos.map(pos => {
if (pos < 0) {
return `${getOrdinal(Math.abs(pos))} to last`
}
return getOrdinal(pos)
})
details.push(`Position ${formatList(positions)}`)
}
return details
}
function getOrdinal(num: number): string {
const suffix = ['th', 'st', 'nd', 'rd']
const v = num % 100
return num + (suffix[(v - 20) % 10] || suffix[v] || suffix[0])
}
function formatList(items: string[]): string {
if (items.length === 0) return ''
if (items.length === 1) return items[0]
if (items.length === 2) return `${items[0]} and ${items[1]}`
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
}
// Hook for easy usage in components
export function useRRuleDisplay(rrule?: string) {
if (!rrule) return null
try {
const parsedRule = parseRRuleString(rrule)
return {
humanText: formatRRuleToHuman(parsedRule),
details: getRRuleDetails(parsedRule),
parsedRule
}
} catch (error) {
return {
humanText: "Invalid recurrence rule",
details: [],
parsedRule: null,
error: error instanceof Error ? error.message : String(error)
}
}
}

View File

@@ -0,0 +1,40 @@
"use client"
import { signOut, useSession } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
export default function SignIn() {
const { data: session, status } = useSession()
const router = useRouter()
const handleSignOut = async () => {
await signOut({ redirect: false })
router.push("/")
router.refresh()
}
if (status === "loading") {
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
}
if (session?.user) {
return (
<div className="flex items-center gap-4">
<Button onClick={handleSignOut} variant="ghost" size="default">
Sign Out
</Button>
</div>
)
}
return (
<Button
onClick={() => router.push("/auth/signin")}
variant="outline"
size="default"
>
Sign In
</Button>
)
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

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,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

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 }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

12
src/db/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, {
prepare: false,
connect_timeout: 30,
idle_timeout: 30,
});
export const db = drizzle(client, { schema });

55
src/db/schema.ts Normal file
View File

@@ -0,0 +1,55 @@
import { pgTable, text, timestamp, integer, boolean, primaryKey } from 'drizzle-orm/pg-core';
export const users = pgTable('user', {
id: text('id').primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'string' }),
image: text('image'),
});
export const accounts = pgTable('account', {
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: text('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
}, (account) => ({
compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] })
}));
export const sessions = pgTable('session', {
sessionToken: text().primaryKey().notNull(),
userId: text().notNull().references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp({ mode: 'string' }).notNull(),
});
export const verificationTokens = pgTable('verificationToken', {
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'string' }).notNull(),
}, (vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] })
}));
export const authenticators = pgTable('authenticator', {
credentialID: text('credentialID').notNull().unique(),
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
}, (authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.credentialID, authenticator.userId],
name: "authenticator_userId_credentialID_pk"
})
}));

View File

@@ -1,55 +0,0 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import { type CalendarEvent } from "./types";
interface ICalDB extends DBSchema {
events: {
key: string;
value: CalendarEvent;
};
}
let dbPromise: Promise<IDBPDatabase<ICalDB>> | null = null;
async function initDB() {
return openDB<ICalDB>("icalPWA", 1, {
upgrade(db) {
if (!db.objectStoreNames.contains("events")) {
db.createObjectStore("events", { keyPath: "id" });
}
},
});
}
// Get the database in a browser-safe way
export async function getDB() {
if (typeof window === "undefined") return null;
if (!dbPromise) {
dbPromise = initDB();
}
return dbPromise;
}
// CRUD operations — all SSR-safe
export async function getAllEvents() {
const db = await getDB();
if (!db) return [];
return db.getAll("events");
}
export async function addEvent(event: ICalDB["events"]["value"]) {
const db = await getDB();
if (!db) return;
return db.put("events", event);
}
export async function deleteEvent(id: string) {
const db = await getDB();
if (!db) return;
return db.delete("events", id);
}
export async function clearEvents() {
const db = await getDB();
if (!db) return;
return db.clear("events");
}

62
src/lib/events-db.ts Normal file
View File

@@ -0,0 +1,62 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { CalendarEvent } from '@/lib/types';
const DB_NAME = 'LocalCalEvents';
const DB_VERSION = 1;
const EVENTS_STORE = 'events';
let dbPromise: Promise<IDBPDatabase> | null = null;
function getDB() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(EVENTS_STORE)) {
const store = db.createObjectStore(EVENTS_STORE, { keyPath: 'id' });
store.createIndex('start', 'start');
store.createIndex('title', 'title');
}
},
});
}
return dbPromise;
}
export async function saveEvent(event: CalendarEvent): Promise<void> {
const db = await getDB();
await db.put(EVENTS_STORE, event);
}
export async function getEvents(): Promise<CalendarEvent[]> {
const db = await getDB();
return db.getAll(EVENTS_STORE);
}
export async function getEvent(id: string): Promise<CalendarEvent | undefined> {
const db = await getDB();
return db.get(EVENTS_STORE, id);
}
export async function deleteEvent(id: string): Promise<void> {
const db = await getDB();
await db.delete(EVENTS_STORE, id);
}
export async function updateEvent(event: CalendarEvent): Promise<void> {
const db = await getDB();
await db.put(EVENTS_STORE, event);
}
export async function getEventsByDateRange(startDate: string, endDate: string): Promise<CalendarEvent[]> {
const db = await getDB();
const tx = db.transaction(EVENTS_STORE, 'readonly');
const index = tx.store.index('start');
const events = await index.getAll(IDBKeyRange.bound(startDate, endDate));
await tx.done;
return events;
}
export async function clearEvents(): Promise<void> {
const db = await getDB();
await db.clear(EVENTS_STORE);
}

23
src/lib/rfc5545-types.ts Normal file
View File

@@ -0,0 +1,23 @@
// RFC 5545 (iCalendar) Recurrence Rule types
// Based on the iCalendar specification for RRULE
export type Frequency = 'SECONDLY' | 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
export type Weekday = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA'
export interface RecurrenceRule {
freq: Frequency
until?: string // ISO 8601 date string
count?: number
interval?: number
bySecond?: number[]
byMinute?: number[]
byHour?: number[]
byDay?: string[]
byMonthDay?: number[]
byYearDay?: number[]
byWeekNo?: number[]
byMonth?: number[]
bySetPos?: number[]
wkst?: Weekday
}