Compare commits
80 Commits
80de65f577
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ad239259 | |||
| 755076351b | |||
| 28c982ee37 | |||
| d7dc911db4 | |||
| f20f3d66a1 | |||
| 824768ce93 | |||
| eb73f9f929 | |||
| 6ab2946e8a | |||
| cde44ee2d7 | |||
| 655517a27c | |||
| 9a836fc866 | |||
| 275e83a6c0 | |||
| d8d0039c44 | |||
| 6e6e9b0699 | |||
| 044e4fbb07 | |||
| 2d0da9dbeb | |||
| d8e55e85a1 | |||
| e0ff037c06 | |||
| 46a99775a0 | |||
| d50d77538b | |||
| 308f5c8380 | |||
| 112ab01445 | |||
| 6818046d58 | |||
| ef035e2b7d | |||
| 1a30b729e6 | |||
| 383ce4c33a | |||
| caa89a87de | |||
| fa39d7584b | |||
| f92c79ac60 | |||
| 12e9ec5d85 | |||
| 1a13013b45 | |||
| 92535f7e54 | |||
| 9942a11c0d | |||
| 8808daead3 | |||
| c0740877b5 | |||
| 42f3521cd7 | |||
| 3bb320b59f | |||
| 20b26274d4 | |||
| a5aebca54c | |||
| ba30ad4bc7 | |||
| 1fdc20ee0c | |||
| 1fe3ef0ee1 | |||
| b17ed18d46 | |||
| 63d32931b2 | |||
| 0e919ea69b | |||
| 0696382d43 | |||
| 2c6737ceb4 | |||
| ad54758193 | |||
| bb00d9548d | |||
| 6ec15c9124 | |||
| e7de213c18 | |||
| f747a82322 | |||
| eea61bcb0f | |||
| cecf3a8d5b | |||
| 69e61573cb | |||
| fefd0c47d6 | |||
| dadf518c2d | |||
| 98072c6d67 | |||
| 7083c816ec | |||
| 3108053017 | |||
| 39c870998c | |||
| 759cf5df2c | |||
| 625d66ee00 | |||
| 836feb2e11 | |||
| 2d5db29f27 | |||
| a41d003401 | |||
| b4c59bbde0 | |||
| 3ee7be9110 | |||
| 238d3cfbfe | |||
| 5dfd38e5a5 | |||
| 9d53e4da21 | |||
| b1dad2f6ba | |||
| ad600a896c | |||
| daa785c721 | |||
| 664663cf29 | |||
| 114f99aebb | |||
| 1e6e7d50aa | |||
| 59735ceb43 | |||
| 6b71467c3c | |||
| 396f83b366 |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
FIXME.md
|
||||||
|
LICENSE
|
||||||
|
.vscode
|
||||||
|
Makefile
|
||||||
|
helm-charts
|
||||||
|
.env*.local
|
||||||
|
.vercel
|
||||||
|
.editorconfig
|
||||||
|
.idea
|
||||||
|
coverage*
|
||||||
|
|
||||||
3
.env.db.example
Normal file
3
.env.db.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_USER=
|
||||||
|
POSTGRES_DB=
|
||||||
9
.env.production.example
Normal file
9
.env.production.example
Normal 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>
|
||||||
60
.gitignore
vendored
60
.gitignore
vendored
@@ -2,8 +2,68 @@
|
|||||||
.devenv*
|
.devenv*
|
||||||
devenv.local.nix
|
devenv.local.nix
|
||||||
|
|
||||||
|
# dependencies (bun install)
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
# direnv
|
# direnv
|
||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
|
# output
|
||||||
|
/out/
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
/build
|
||||||
|
*.tgz
|
||||||
|
|
||||||
# pre-commit
|
# pre-commit
|
||||||
.pre-commit-config.yaml
|
.pre-commit-config.yaml
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env*
|
||||||
|
!.env*.example
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
/.next/
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
public/sw.js
|
||||||
|
public/workbox-*.js
|
||||||
|
|||||||
61
Dockerfile
Normal file
61
Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
|
# 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* ./
|
||||||
|
|
||||||
|
# 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 . .
|
||||||
|
|
||||||
|
# Configure Next.js for standalone output
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN bun add --exact --dev typescript
|
||||||
|
# Build the application
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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"]
|
||||||
4
FIXME.md
Normal file
4
FIXME.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# FIXME
|
||||||
|
|
||||||
|
- [ ] minimatch types
|
||||||
|
https://github.com/strapi/strapi/issues/23859
|
||||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
## **Project Plan: PWA iCal Editor with AI Integration**
|
||||||
|
|
||||||
|
### Phase 0 — Set Up Your Workspace (1 day)
|
||||||
|
**Goal:** Ensure your dev environment and project skeleton are ready.
|
||||||
|
**Tasks:**
|
||||||
|
1. Install bun runtime and Docker locally.
|
||||||
|
2. Create a GitHub repo for your project.
|
||||||
|
3. Set up a kanban board (e.g., GitHub Projects, Trello, or even a whiteboard) with columns: _Backlog → In Progress → Done_.
|
||||||
|
4. Initialize Next.js project with **bun**:
|
||||||
|
```bash
|
||||||
|
bun create next-app my-ical-pwa
|
||||||
|
```
|
||||||
|
5. Integrate **shadcn/ui** + TailwindCSS.
|
||||||
|
```
|
||||||
|
npx shadcn-ui init
|
||||||
|
```
|
||||||
|
6. Install TurboPack config for faster builds.
|
||||||
|
7. Commit and push to GitHub.
|
||||||
|
|
||||||
|
✅ **Success criteria:** You can run `bun dev` and see the starter NextJS page with a shadcn component rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1 — PWA Foundations (2–3 days)
|
||||||
|
**Goal:** Make your app installable, offline-capable, and structured.
|
||||||
|
**Tasks:**
|
||||||
|
1. Configure NextJS PWA using the [`next-pwa`](https://github.com/shadowwalker/next-pwa) plugin or manual service worker.
|
||||||
|
2. Add manifest.json (name, icons, start_url).
|
||||||
|
3. Ensure offline caching works for basic pages.
|
||||||
|
4. Build your base layout with shadcn (Navigation bar, App shell).
|
||||||
|
|
||||||
|
✅ **Success criteria:**
|
||||||
|
- App can be installed on desktop/mobile.
|
||||||
|
- Basic UI framework in place (navigation, header).
|
||||||
|
- Works offline in basic form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2 — Local Calendar CRUD (3–4 days)
|
||||||
|
**Goal:** Create, Read, Update, and Delete events in **IndexedDB** (calendar data stored locally).
|
||||||
|
**Tasks:**
|
||||||
|
1. Set up IndexedDB wrapper (recommend [`idb`](https://github.com/jakearchibald/idb) library for simplicity).
|
||||||
|
2. Design minimal event model:
|
||||||
|
```ts
|
||||||
|
type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
3. Build **Add Event** form (shadcn modal or page).
|
||||||
|
4. Render events in list view.
|
||||||
|
5. Enable edit/delete logic.
|
||||||
|
6. Store small user preferences (theme, last viewed date) in localStorage.
|
||||||
|
|
||||||
|
✅ **Success criteria:**
|
||||||
|
- You can add, edit, and delete events without reloading.
|
||||||
|
- Changes persist after refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3 — iCal File Import/Export (3 days)
|
||||||
|
**Goal:** Read `.ics` files and export your event list as iCal.
|
||||||
|
**Tasks:**
|
||||||
|
1. Use [`ical.js`](https://github.com/mozilla-comm/ical.js) or [`ical-generator`](https://github.com/sebbo2002/ical-generator) to handle parsing.
|
||||||
|
2. Create import button (upload local iCal file and store events in IndexedDB).
|
||||||
|
3. Create export button that takes current events and downloads `.ics`.
|
||||||
|
|
||||||
|
✅ **Success criteria:**
|
||||||
|
- Importing an `.ics` populates your list.
|
||||||
|
- Export downloads a valid `.ics` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4 — AI Integration (OpenRouter) (3–5 days)
|
||||||
|
**Goal:** Add AI-assisted event creation / summarization.
|
||||||
|
**Tasks:**
|
||||||
|
1. Set up API route in Next.js that calls **OpenRouter** API.
|
||||||
|
2. Write prompt format for:
|
||||||
|
- Turning natural language into structured event JSON.
|
||||||
|
- Summarizing events into a bullet list.
|
||||||
|
3. Add text input where the user can type: _"Lunch with Sarah next Friday at noon"_ → generates event form populated automatically.
|
||||||
|
4. Integrate results directly into CRUD flow.
|
||||||
|
|
||||||
|
✅ **Success criteria:**
|
||||||
|
- User can create an event by typing in natural language and AI fills in details.
|
||||||
|
- AI can summarize selected events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5 — Polish & Deployment (2–3 days)
|
||||||
|
**Goal:** Dockerize and deploy to bare metal.
|
||||||
|
**Tasks:**
|
||||||
|
1. Write `Dockerfile` for **bun + NextJS** app.
|
||||||
|
2. Add `.dockerignore` to optimize build.
|
||||||
|
3. Deploy to your server using `docker-compose` or raw `docker run`.
|
||||||
|
4. Enable HTTPS with Caddy or Nginx reverse proxy.
|
||||||
|
|
||||||
|
✅ **Success criteria:**
|
||||||
|
- App is live, installable, and can read/write iCal files with AI support.
|
||||||
|
- Access from phone and desktop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADHD-Friendly Productivity Tips for This Project
|
||||||
|
- **Visual progress:** Keep your kanban board visible; drag at least 1 card to "Done" daily.
|
||||||
|
- **Micro-goals:** Define tasks as “Under 1 hour” pieces (e.g., _“Add startDate field to form”_ versus _“Build CRUD forms”_).
|
||||||
|
- **Pomodoros with variety:** Alternate coding with light tasks (docs, UI tweaks).
|
||||||
|
- **Celebrate small wins:** Even “icons rendering right” counts.
|
||||||
|
- **Hyperfocus harness:** Save creative coding (like AI prompt design) for your high-focus times.
|
||||||
|
- **Body doubling:** Work in a co-working video call or stream to keep momentum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Timeline
|
||||||
|
If you work ~2–3 focused hours/day:
|
||||||
|
- **Week 1:** Phases 0–2 (setup, PWA, local CRUD)
|
||||||
|
- **Week 2:** Phases 3–4 (iCal import/export, AI integration)
|
||||||
|
- **Week 3:** Phase 5 (polish, deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps for You
|
||||||
|
1. Confirm if you want to **start from plain NextJS** or grab a PWA boilerplate to save time.
|
||||||
|
2. Decide which **AI model via OpenRouter** to use (cheap + fast, or high-quality outputs?).
|
||||||
|
3. Pick a **calendar UI approach** — minimal list view or visual calendar grid from the start?
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
bun.enable = true;
|
bun.enable = true;
|
||||||
};
|
};
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
git --version
|
echo ""
|
||||||
bun --version
|
echo "$(git --version)"
|
||||||
|
echo "bun version $(bun --version)"
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
local-ical:
|
||||||
|
build: .
|
||||||
|
container_name: local-ical
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
labels:
|
||||||
|
'traefik.enable': 'true'
|
||||||
|
'traefik.docker.network': 'traefik'
|
||||||
|
'traefik.http.routers.ical-local.rule': 'Host(`cal.cloud.dmytros.dev`)'
|
||||||
|
'traefik.http.routers.ical-local.entrypoints': 'websecure'
|
||||||
|
'traefik.http.routers.ical-local.tls.certresolver': 'letsencrypt'
|
||||||
|
'traefik.http.routers.ical-local.service': 'ical-local-service'
|
||||||
|
'traefik.http.services.ical-local-service.loadbalancer.server.port': '3000'
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
external: false
|
||||||
|
name: local-ical-network
|
||||||
21
drizzle.config.ts
Normal file
21
drizzle.config.ts
Normal 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,
|
||||||
|
});
|
||||||
56
drizzle/0000_loose_catseye.sql
Normal file
56
drizzle/0000_loose_catseye.sql
Normal 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;
|
||||||
|
*/
|
||||||
344
drizzle/meta/0000_snapshot.json
Normal file
344
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
29
drizzle/relations.ts
Normal 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
72
drizzle/schema.ts
Normal 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"}),
|
||||||
|
]);
|
||||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
49
next.config.ts
Normal file
49
next.config.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
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;
|
||||||
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "ical-pwa",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@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",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"drizzle-orm": "^0.44.4",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
|
"next": "15.4.10",
|
||||||
|
"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-day-picker": "^9.9.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.4.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.20.4",
|
||||||
|
"tw-animate-css": "^1.3.6",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/minimatch": "5.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
20
public/manifest.json
Normal file
20
public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "iCal PWA",
|
||||||
|
"short_name": "iCal",
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1d4ed8",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
69
src/app/api/ai-event/route.ts
Normal file
69
src/app/api/ai-event/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
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 systemPrompt = `
|
||||||
|
You are an assistant that converts natural language into an ARRAY of calendar events.
|
||||||
|
TypeScript type:
|
||||||
|
|
||||||
|
{
|
||||||
|
id?: string,
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
location?: string,
|
||||||
|
url?: string,
|
||||||
|
start: string, // ISO datetime
|
||||||
|
end?: string,
|
||||||
|
allDay?: boolean,
|
||||||
|
recurrenceRule?: string // valid iCal RRULE string like FREQ=WEEKLY;BYDAY=MO;INTERVAL=1
|
||||||
|
}[]
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If the user describes multiple events in one prompt, return multiple objects (one per event).
|
||||||
|
- Always return a valid JSON array of objects, even if there's only one event.
|
||||||
|
- Today is ${new Date().toLocaleString()}.
|
||||||
|
- If no time is given, assume allDay event.
|
||||||
|
- If no end time is given (and event is not allDay), default to 1 hour after start.
|
||||||
|
- If multiple events are described, return multiple.
|
||||||
|
- If recurrence is implied (e.g. "every Monday", "daily for 10 days", "monthly on the 15th"), generate a recurrenceRule.
|
||||||
|
- Output ONLY valid JSON (no prose).
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "openai/gpt-4.1-nano",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
try {
|
||||||
|
const content = data.choices[0].message.content;
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return NextResponse.json(parsed);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to parse AI output", raw: data },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/ai-summary/route.ts
Normal file
45
src/app/api/ai-summary/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { events } = await request.json();
|
||||||
|
|
||||||
|
if (!events || !Array.isArray(events)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid events array" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, // Server-side only
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "@preset/i-cal-editor-summarize", // FREE model
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You summarize a list of events in natural language. Include date, time, and title. Be concise.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: JSON.stringify(events) },
|
||||||
|
],
|
||||||
|
temperature: 0.4,
|
||||||
|
// max_tokens: 300,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const summary =
|
||||||
|
data?.choices?.[0]?.message?.content || "No summary generated.";
|
||||||
|
return NextResponse.json({ summary });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to summarize events" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
38
src/app/auth/error/page.tsx
Normal file
38
src/app/auth/error/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/app/auth/signin/page.tsx
Normal file
45
src/app/auth/signin/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/app/auth/signout/page.tsx
Normal file
49
src/app/auth/signout/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
162
src/app/globals.css
Normal file
162
src/app/globals.css
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--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-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--shadow-2xs: var(--shadow-2xs);
|
||||||
|
--shadow-xs: var(--shadow-xs);
|
||||||
|
--shadow-sm: var(--shadow-sm);
|
||||||
|
--shadow: var(--shadow);
|
||||||
|
--shadow-md: var(--shadow-md);
|
||||||
|
--shadow-lg: var(--shadow-lg);
|
||||||
|
--shadow-xl: var(--shadow-xl);
|
||||||
|
--shadow-2xl: var(--shadow-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/layout.tsx
Normal file
56
src/app/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Magra } from "next/font/google";
|
||||||
|
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 geist = Geist({ subsets: ['latin', 'cyrillic'], variable: "--font-geist-sans" })
|
||||||
|
|
||||||
|
const magra = Magra({ subsets: ["latin"], weight: "400", variable: "--font-cascadia-code" })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Local iCal',
|
||||||
|
description: 'Local iCal editor for calendar events',
|
||||||
|
creator: "Dmytro Stanchiev",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geist.variable} antialiased min-h-screen flex flex-col dark:text-gray-300 --color-background`}
|
||||||
|
>
|
||||||
|
<AuthSessionProvider>
|
||||||
|
<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>
|
||||||
|
<main className="flex-1 p-4">{children}</main>
|
||||||
|
<Toaster closeButton richColors />
|
||||||
|
</ThemeProvider>
|
||||||
|
</AuthSessionProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/manifest.ts
Normal file
25
src/app/manifest.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
309
src/app/page.tsx
Normal file
309
src/app/page.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
|
||||||
|
import { parseICS, generateICS } from '@/lib/ical'
|
||||||
|
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() {
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [start, setStart] = useState('')
|
||||||
|
const [end, setEnd] = useState('')
|
||||||
|
const [allDay, setAllDay] = useState(false)
|
||||||
|
const [recurrenceRule, setRecurrenceRule] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// AI
|
||||||
|
const [aiPrompt, setAiPrompt] = useState('')
|
||||||
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
const [summary, setSummary] = useState<string | null>(null)
|
||||||
|
const [summaryUpdated, setSummaryUpdated] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const stored = await getAllEvents()
|
||||||
|
setEvents(stored)
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setLocation('')
|
||||||
|
setUrl('')
|
||||||
|
setStart('')
|
||||||
|
setEnd('')
|
||||||
|
setAllDay(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setRecurrenceRule(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const eventData: CalendarEvent = {
|
||||||
|
id: editingId || nanoid(),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
url,
|
||||||
|
recurrenceRule,
|
||||||
|
start,
|
||||||
|
end: end || undefined,
|
||||||
|
allDay,
|
||||||
|
createdAt: editingId
|
||||||
|
? events.find(e => e.id === editingId)?.createdAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (editingId) {
|
||||||
|
await updateEvent(eventData)
|
||||||
|
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
|
||||||
|
} else {
|
||||||
|
await addEvent(eventData)
|
||||||
|
setEvents(prev => [...prev, eventData])
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await deleteEvent(id)
|
||||||
|
setEvents(prev => prev.filter(e => e.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
await clearEvents()
|
||||||
|
setEvents([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = async (file: File) => {
|
||||||
|
const text = await file.text()
|
||||||
|
const parsed = parseICS(text)
|
||||||
|
for (const ev of parsed) {
|
||||||
|
await addEvent(ev)
|
||||||
|
}
|
||||||
|
const stored = await getAllEvents()
|
||||||
|
setEvents(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const icsData = generateICS(events)
|
||||||
|
const blob = new Blob([icsData], { type: 'text/calendar' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `icallocal-export-${new Date().toLocaleTimeString()}.ics`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Create Event
|
||||||
|
const handleAiCreate = async () => {
|
||||||
|
if (!aiPrompt.trim()) return
|
||||||
|
setAiLoading(true)
|
||||||
|
|
||||||
|
const promise = (): Promise<{ message: string }> => new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-event', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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()
|
||||||
|
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
if (data.length === 1) {
|
||||||
|
// Prefill dialog directly (same as before)
|
||||||
|
const ev = data[0]
|
||||||
|
setTitle(ev.title || '')
|
||||||
|
setDescription(ev.description || '')
|
||||||
|
setLocation(ev.location || '')
|
||||||
|
setUrl(ev.url || '')
|
||||||
|
setStart(ev.start || '')
|
||||||
|
setEnd(ev.end || '')
|
||||||
|
setAllDay(ev.allDay || false)
|
||||||
|
setEditingId(null)
|
||||||
|
setAiPrompt("")
|
||||||
|
setDialogOpen(true)
|
||||||
|
setRecurrenceRule(ev.recurrenceRule || undefined)
|
||||||
|
resolve({
|
||||||
|
message: 'Event has been created!'
|
||||||
|
})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Save them all directly to DB
|
||||||
|
for (const ev of data) {
|
||||||
|
const newEvent = {
|
||||||
|
id: nanoid(),
|
||||||
|
...ev,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastModified: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
await addEvent(newEvent)
|
||||||
|
}
|
||||||
|
const stored = await getAllEvents()
|
||||||
|
setEvents(stored)
|
||||||
|
setAiPrompt("")
|
||||||
|
setSummary(`Added ${data.length} AI-generated events.`)
|
||||||
|
setSummaryUpdated(new Date().toLocaleString())
|
||||||
|
resolve({
|
||||||
|
message: 'Event has been created!'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
message: 'AI did not return event data.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
reject({
|
||||||
|
message: 'Error from AI service.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "Generating event...",
|
||||||
|
success: ({ message }) => {
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
error: ({ message }) => {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setAiLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Summarize Events
|
||||||
|
const handleAiSummarize = async () => {
|
||||||
|
if (!events.length) {
|
||||||
|
setSummary("No events to summarize.")
|
||||||
|
setSummaryUpdated(new Date().toLocaleString())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAiLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-summary', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ events })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.summary) {
|
||||||
|
setSummary(data.summary)
|
||||||
|
setSummaryUpdated(new Date().toLocaleString())
|
||||||
|
} else {
|
||||||
|
setSummary("No summary generated.")
|
||||||
|
setSummaryUpdated(new Date().toLocaleString())
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSummary("Error summarizing events")
|
||||||
|
setSummaryUpdated(new Date().toLocaleString())
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (eventData: CalendarEvent) => {
|
||||||
|
setTitle(eventData.title)
|
||||||
|
setDescription(eventData.description || "")
|
||||||
|
setLocation(eventData.location || "")
|
||||||
|
setUrl(eventData.url || "")
|
||||||
|
setStart(eventData.start)
|
||||||
|
setEnd(eventData.end || "")
|
||||||
|
setAllDay(eventData.allDay || false)
|
||||||
|
setEditingId(eventData.id)
|
||||||
|
setRecurrenceRule(eventData.recurrenceRule)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContainer
|
||||||
|
isDragOver={isDragOver}
|
||||||
|
setIsDragOver={setIsDragOver}
|
||||||
|
onImport={handleImport}
|
||||||
|
>
|
||||||
|
<AIToolbar
|
||||||
|
session={session}
|
||||||
|
status={status}
|
||||||
|
aiPrompt={aiPrompt}
|
||||||
|
setAiPrompt={setAiPrompt}
|
||||||
|
aiLoading={aiLoading}
|
||||||
|
onAiCreate={handleAiCreate}
|
||||||
|
onAiSummarize={handleAiSummarize}
|
||||||
|
summary={summary}
|
||||||
|
summaryUpdated={summaryUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventActionsToolbar
|
||||||
|
events={events}
|
||||||
|
onAddEvent={() => setDialogOpen(true)}
|
||||||
|
onImport={handleImport}
|
||||||
|
onExport={handleExport}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventsList
|
||||||
|
events={events}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
editingId={editingId}
|
||||||
|
title={title}
|
||||||
|
setTitle={setTitle}
|
||||||
|
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
35
src/auth.ts
Normal 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);
|
||||||
12
src/components/SessionProvider.tsx
Normal file
12
src/components/SessionProvider.tsx
Normal 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>
|
||||||
|
}
|
||||||
83
src/components/ai-toolbar.tsx
Normal file
83
src/components/ai-toolbar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/components/drag-drop-container.tsx
Normal file
55
src/components/drag-drop-container.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/event-actions-toolbar.tsx
Normal file
36
src/components/event-actions-toolbar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/components/event-card.tsx
Normal file
92
src/components/event-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/components/event-dialog.tsx
Normal file
96
src/components/event-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/events-list.tsx
Normal file
34
src/components/events-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/ics-file-picker.tsx
Normal file
58
src/components/ics-file-picker.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
77
src/components/mode-toggle.tsx
Normal file
77
src/components/mode-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
153
src/components/recurrence-picker.tsx
Normal file
153
src/components/recurrence-picker.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
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 = {
|
||||||
|
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY"
|
||||||
|
interval: number
|
||||||
|
byDay?: string[]
|
||||||
|
count?: number
|
||||||
|
until?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string
|
||||||
|
onChange: (rrule: string | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurrencePicker({ value, onChange }: Props) {
|
||||||
|
const [rec, setRec] = useState<Recurrence>(() => {
|
||||||
|
// If existing rrule, parse minimally (for simplicity we only rehydrate FREQ and INTERVAL)
|
||||||
|
if (value) {
|
||||||
|
const parts = Object.fromEntries(value.split(";").map((p) => p.split("=")))
|
||||||
|
return {
|
||||||
|
freq: parts.FREQ || "NONE",
|
||||||
|
interval: parts.INTERVAL ? Number.parseInt(parts.INTERVAL, 10) : 1,
|
||||||
|
byDay: parts.BYDAY ? parts.BYDAY.split(",") : [],
|
||||||
|
count: parts.COUNT ? Number.parseInt(parts.COUNT, 10) : undefined,
|
||||||
|
until: parts.UNTIL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { freq: "NONE", interval: 1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (updates: Partial<Recurrence>) => {
|
||||||
|
const newRec = { ...rec, ...updates }
|
||||||
|
setRec(newRec)
|
||||||
|
|
||||||
|
if (newRec.freq === "NONE") {
|
||||||
|
onChange(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build RRULE string
|
||||||
|
let rrule = `FREQ=${newRec.freq};INTERVAL=${newRec.interval}`
|
||||||
|
if (newRec.freq === "WEEKLY" && newRec.byDay?.length) {
|
||||||
|
rrule += `;BYDAY=${newRec.byDay.join(",")}`
|
||||||
|
}
|
||||||
|
if (newRec.count) rrule += `;COUNT=${newRec.count}`
|
||||||
|
if (newRec.until) rrule += `;UNTIL=${newRec.until.replace(/-/g, "")}T000000Z`
|
||||||
|
|
||||||
|
onChange(rrule)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDay = (day: string) => {
|
||||||
|
const byDay = rec.byDay || []
|
||||||
|
const newByDay = byDay.includes(day) ? byDay.filter((d) => d !== day) : [...byDay, day]
|
||||||
|
update({ byDay: newByDay })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayLabels = {
|
||||||
|
MO: "Mon",
|
||||||
|
TU: "Tue",
|
||||||
|
WE: "Wed",
|
||||||
|
TH: "Thu",
|
||||||
|
FR: "Fri",
|
||||||
|
SA: "Sat",
|
||||||
|
SU: "Sun",
|
||||||
|
}
|
||||||
|
|
||||||
|
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" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="interval">
|
||||||
|
Interval (every {rec.interval} {rec.freq === "DAILY" ? "day" : rec.freq === "WEEKLY" ? "week" : "month"}
|
||||||
|
{rec.interval > 1 ? "s" : ""})
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="interval"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={rec.interval}
|
||||||
|
onChange={(e) => update({ interval: Number.parseInt(e.target.value, 10) || 1 })}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rec.freq === "WEEKLY" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Days of the week</Label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{["MO", "TU", "WE", "TH", "FR", "SA", "SU"].map((day) => (
|
||||||
|
<div key={day} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={day}
|
||||||
|
checked={rec.byDay?.includes(day) || false}
|
||||||
|
onCheckedChange={() => toggleDay(day)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={day} className="text-sm font-normal">
|
||||||
|
{dayLabels[day as keyof typeof dayLabels]}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="count">End after (occurrences)</Label>
|
||||||
|
<Input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 10"
|
||||||
|
value={rec.count || ""}
|
||||||
|
onChange={(e) => update({ count: e.target.value ? Number.parseInt(e.target.value, 10) : undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="until">End by date</Label>
|
||||||
|
<Input
|
||||||
|
id="until"
|
||||||
|
type="date"
|
||||||
|
value={rec.until || ""}
|
||||||
|
onChange={(e) => update({ until: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
src/components/rrule-display.tsx
Normal file
244
src/components/rrule-display.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/components/sign-in.tsx
Normal file
40
src/components/sign-in.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal 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>
|
||||||
|
}
|
||||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 buttonVariants = cva(
|
||||||
|
"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: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal 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 }
|
||||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal 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 }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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
12
src/db/index.ts
Normal 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
55
src/db/schema.ts
Normal 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"
|
||||||
|
})
|
||||||
|
}));
|
||||||
62
src/lib/events-db.ts
Normal file
62
src/lib/events-db.ts
Normal 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);
|
||||||
|
}
|
||||||
29
src/lib/ical-helpers.ts
Normal file
29
src/lib/ical-helpers.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import ICAL from "ical.js";
|
||||||
|
|
||||||
|
export function isRecur(val: unknown): val is ICAL.Recur {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.Recur;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTime(val: unknown): val is ICAL.Time {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// export function isGeo(val: unknown): val is ICAL.Geo {
|
||||||
|
// return typeof val === "object" && val instanceof ICAL.Geo;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export function isUtcOffset(val: unknown): val is ICAL.UtcOffset {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.UtcOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBinary(val: unknown): val is ICAL.Binary {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.Binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuration(val: unknown): val is ICAL.Duration {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPeriod(val: unknown): val is ICAL.Period {
|
||||||
|
return typeof val === "object" && val instanceof ICAL.Period;
|
||||||
|
}
|
||||||
107
src/lib/ical.ts
Normal file
107
src/lib/ical.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import ICAL from "ical.js";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
isRecur,
|
||||||
|
isTime,
|
||||||
|
isUtcOffset,
|
||||||
|
isBinary,
|
||||||
|
isDuration,
|
||||||
|
isPeriod,
|
||||||
|
} from "./ical-helpers";
|
||||||
|
|
||||||
|
function safeValueToString(
|
||||||
|
val: ReturnType<ICAL.Component["getFirstPropertyValue"]>,
|
||||||
|
): string | undefined {
|
||||||
|
if (val === undefined) return undefined;
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
if (isTime(val)) return val.toJSDate().toISOString();
|
||||||
|
if (isRecur(val)) return val.toString();
|
||||||
|
if (isUtcOffset(val)) return val.toString(); // already "±HHMM"
|
||||||
|
if (isBinary(val) || isDuration(val) || isPeriod(val)) return val.toString();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseICS(icsString: string): CalendarEvent[] {
|
||||||
|
const jcalData = ICAL.parse(icsString);
|
||||||
|
const comp = new ICAL.Component(jcalData);
|
||||||
|
const vevents = comp.getAllSubcomponents("vevent");
|
||||||
|
|
||||||
|
return vevents.map((v) => {
|
||||||
|
const ev = new ICAL.Event(v);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ev.uid || crypto.randomUUID(),
|
||||||
|
title: ev.summary || "Untitled Event",
|
||||||
|
description: ev.description || "",
|
||||||
|
location: ev.location || "",
|
||||||
|
url: safeValueToString(v.getFirstPropertyValue("url")),
|
||||||
|
start: ev.startDate.toJSDate().toISOString(),
|
||||||
|
end: ev.endDate ? ev.endDate.toJSDate().toISOString() : undefined,
|
||||||
|
allDay: ev.startDate.isDate,
|
||||||
|
createdAt: safeValueToString(v.getFirstPropertyValue("dtstamp")),
|
||||||
|
lastModified: safeValueToString(v.getFirstPropertyValue("last-modified")),
|
||||||
|
recurrenceRule: safeValueToString(v.getFirstPropertyValue("rrule")),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateICS(events: CalendarEvent[]): string {
|
||||||
|
const comp = new ICAL.Component(["vcalendar", [], []]);
|
||||||
|
comp.addPropertyWithValue("version", "2.0");
|
||||||
|
comp.addPropertyWithValue("prodid", "-//iCalPWA//EN");
|
||||||
|
|
||||||
|
events.forEach((ev) => {
|
||||||
|
const vevent = new ICAL.Component("vevent");
|
||||||
|
vevent.addPropertyWithValue("uid", ev.id);
|
||||||
|
vevent.addPropertyWithValue("summary", ev.title);
|
||||||
|
if (ev.description)
|
||||||
|
vevent.addPropertyWithValue("description", ev.description);
|
||||||
|
if (ev.location) vevent.addPropertyWithValue("location", ev.location);
|
||||||
|
if (ev.url) vevent.addPropertyWithValue("url", ev.url);
|
||||||
|
|
||||||
|
if (ev.allDay) {
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtstart",
|
||||||
|
ICAL.Time.fromDateString(ev.start.split("T")[0]),
|
||||||
|
);
|
||||||
|
if (ev.end)
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtend",
|
||||||
|
ICAL.Time.fromDateString(ev.end.split("T")[0]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtstart",
|
||||||
|
ICAL.Time.fromJSDate(new Date(ev.start)),
|
||||||
|
);
|
||||||
|
if (ev.end) {
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtend",
|
||||||
|
ICAL.Time.fromJSDate(new Date(ev.end)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"dtstamp",
|
||||||
|
ICAL.Time.fromJSDate(ev.createdAt ? new Date(ev.createdAt) : new Date()),
|
||||||
|
);
|
||||||
|
if (ev.lastModified) {
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"last-modified",
|
||||||
|
ICAL.Time.fromJSDate(new Date(ev.lastModified)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.recurrenceRule) {
|
||||||
|
vevent.addPropertyWithValue(
|
||||||
|
"rrule",
|
||||||
|
ICAL.Recur.fromString(ev.recurrenceRule),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
comp.addSubcomponent(vevent);
|
||||||
|
});
|
||||||
|
|
||||||
|
return comp.toString();
|
||||||
|
}
|
||||||
23
src/lib/rfc5545-types.ts
Normal file
23
src/lib/rfc5545-types.ts
Normal 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
|
||||||
|
}
|
||||||
14
src/lib/types.ts
Normal file
14
src/lib/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
url?: string;
|
||||||
|
start: string;
|
||||||
|
end?: string;
|
||||||
|
allDay?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
|
||||||
|
recurrenceRule?: string;
|
||||||
|
};
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user