diff --git a/platforms/calendar-api/package.json b/platforms/calendar-api/package.json new file mode 100644 index 000000000..e617bda5d --- /dev/null +++ b/platforms/calendar-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "calendar-api", + "version": "1.0.0", + "description": "W3DS auth and eVault-backed calendar events API", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "build": "tsc" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "graphql-request": "^6.1.0", + "jsonwebtoken": "^9.0.2", + "signature-validator": "workspace:*", + "uuid": "^9.0.1", + "axios": "^1.6.7" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.11.19", + "@types/uuid": "^9.0.8", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/platforms/calendar-api/src/constants.ts b/platforms/calendar-api/src/constants.ts new file mode 100644 index 000000000..0a34d66dd --- /dev/null +++ b/platforms/calendar-api/src/constants.ts @@ -0,0 +1,25 @@ +export const CALENDAR_EVENT_ONTOLOGY_ID = + "880e8400-e29b-41d4-a716-446655440099"; + +const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes + +export interface StoredSession { + createdAt: number; +} + +export const sessionStore = new Map(); + +export function addSession(sessionId: string): void { + sessionStore.set(sessionId, { createdAt: Date.now() }); +} + +export function isSessionValid(sessionId: string): boolean { + const s = sessionStore.get(sessionId); + if (!s) return false; + if (Date.now() - s.createdAt > SESSION_TTL_MS) { + sessionStore.delete(sessionId); + return false; + } + sessionStore.delete(sessionId); // one-time use + return true; +} diff --git a/platforms/calendar-api/src/controllers/AuthController.ts b/platforms/calendar-api/src/controllers/AuthController.ts new file mode 100644 index 000000000..e39bb3a5d --- /dev/null +++ b/platforms/calendar-api/src/controllers/AuthController.ts @@ -0,0 +1,136 @@ +import { Request, Response } from "express"; +import { EventEmitter } from "events"; +import { v4 as uuidv4 } from "uuid"; +import jwt from "jsonwebtoken"; +import { verifySignature } from "signature-validator"; +import { isVersionValid } from "../utils/version"; +import { addSession, isSessionValid } from "../constants"; + +const MIN_REQUIRED_VERSION = "0.4.0"; +const JWT_EXPIRES_IN = "7d"; + +export class AuthController { + private eventEmitter = new EventEmitter(); + + getOffer = async (_req: Request, res: Response) => { + console.log("[auth] GET /api/auth/offer hit"); + const baseUrl = process.env.NEXT_PUBLIC_CALENDAR_APP_URL; + if (!baseUrl) { + console.error("[auth] NEXT_PUBLIC_CALENDAR_APP_URL is not set"); + return res.status(500).json({ error: "Server configuration error: NEXT_PUBLIC_CALENDAR_APP_URL not set" }); + } + + let redirectUri: string; + try { + redirectUri = new URL("/api/auth", baseUrl).toString(); + } catch (err) { + console.error("[auth] Invalid NEXT_PUBLIC_CALENDAR_APP_URL:", baseUrl, err); + return res.status(500).json({ error: "Server configuration error: invalid base URL" }); + } + + const session = uuidv4(); + addSession(session); + const offer = `w3ds://auth?redirect=${encodeURIComponent(redirectUri)}&session=${session}&platform=${encodeURIComponent(baseUrl)}`; + console.log("[auth] offer created, redirectUri=", redirectUri, "platform=", baseUrl); + res.json({ uri: offer, sessionId: session }); + }; + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + console.log("[auth] GET /api/auth/sessions/:id hit, sessionId=", id); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + const handler = (data: unknown) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + this.eventEmitter.on(id, handler); + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 30000); + req.on("close", () => { + clearInterval(heartbeat); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + login = async (req: Request, res: Response) => { + console.log("[auth] POST /api/auth hit"); + try { + const { ename, session, signature, appVersion } = req.body; + console.log("[auth] body: ename=", ename, "session=", session?.slice(0, 8) + "...", "appVersion=", appVersion, "signature present=", !!signature); + + if (!ename) { + console.log("[auth] reject: ename missing"); + return res.status(400).json({ error: "ename is required" }); + } + if (!session) { + console.log("[auth] reject: session missing"); + return res.status(400).json({ error: "session is required" }); + } + if (!signature) { + console.log("[auth] reject: signature missing"); + return res.status(400).json({ error: "signature is required" }); + } + + if (!isSessionValid(session)) { + console.log("[auth] reject: invalid or expired session"); + return res + .status(400) + .json({ error: "Invalid or expired session", message: "Please request a new login offer." }); + } + + if (!appVersion || !isVersionValid(appVersion, MIN_REQUIRED_VERSION)) { + console.log("[auth] reject: app version too old", appVersion); + return res.status(400).json({ + error: "App version too old", + message: `Please update eID Wallet to version ${MIN_REQUIRED_VERSION} or later.`, + }); + } + + const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; + if (!registryBaseUrl) { + console.log("[auth] reject: PUBLIC_REGISTRY_URL not set"); + return res.status(500).json({ error: "Server configuration error" }); + } + + console.log("[auth] verifying signature with registry", registryBaseUrl); + const verificationResult = await verifySignature({ + eName: ename, + signature, + payload: session, + registryBaseUrl, + }); + + if (!verificationResult.valid) { + console.log("[auth] reject: signature invalid", verificationResult.error); + return res.status(401).json({ + error: "Invalid signature", + message: verificationResult.error, + }); + } + + const secret = process.env.JWT_SECRET || "calendar-api-dev-secret"; + const token = jwt.sign( + { ename }, + secret, + { expiresIn: JWT_EXPIRES_IN } + ); + + console.log("[auth] login success, ename=", ename); + this.eventEmitter.emit(session, { token }); + res.status(200).json({ token }); + } catch (error) { + console.error("[auth] login error:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/calendar-api/src/controllers/EventsController.ts b/platforms/calendar-api/src/controllers/EventsController.ts new file mode 100644 index 000000000..3051d7521 --- /dev/null +++ b/platforms/calendar-api/src/controllers/EventsController.ts @@ -0,0 +1,109 @@ +import { Response } from "express"; +import { EVaultService } from "../services/EVaultService"; +import { AuthenticatedRequest } from "../middleware/auth"; + +const evaultService = new EVaultService(); + +export class EventsController { + list = async (req: AuthenticatedRequest, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const first = Math.min( + parseInt(String(req.query.first), 10) || 100, + 500 + ); + const after = (req.query.after as string) || undefined; + const events = await evaultService.listEvents(ename, first, after); + res.json(events); + } catch (error) { + console.error("List events error:", error); + res.status(500).json({ + error: "Failed to list events", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + create = async (req: AuthenticatedRequest, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const { title, color, start, end } = req.body; + if (!title || !start || !end) { + res.status(400).json({ + error: "Missing required fields", + message: "title, start, and end are required", + }); + return; + } + const event = await evaultService.createEvent(ename, { + title, + color: color ?? "", + start, + end, + }); + res.status(201).json(event); + } catch (error) { + console.error("Create event error:", error); + res.status(500).json({ + error: "Failed to create event", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + update = async (req: AuthenticatedRequest, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const id = req.params.id; + const { title, color, start, end } = req.body; + const payload: Record = {}; + if (title !== undefined) payload.title = title; + if (color !== undefined) payload.color = color; + if (start !== undefined) payload.start = start; + if (end !== undefined) payload.end = end; + if (Object.keys(payload).length === 0) { + res.status(400).json({ error: "No fields to update" }); + return; + } + const event = await evaultService.updateEvent(ename, id, payload); + res.json(event); + } catch (error) { + console.error("Update event error:", error); + res.status(500).json({ + error: "Failed to update event", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + remove = async (req: AuthenticatedRequest, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const id = req.params.id; + await evaultService.removeEvent(ename, id); + res.status(204).send(); + } catch (error) { + console.error("Remove event error:", error); + res.status(500).json({ + error: "Failed to delete event", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + } +} diff --git a/platforms/calendar-api/src/index.ts b/platforms/calendar-api/src/index.ts new file mode 100644 index 000000000..a736e16ce --- /dev/null +++ b/platforms/calendar-api/src/index.ts @@ -0,0 +1,54 @@ +import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; +import express from "express"; +import cors from "cors"; + +// Load .env: try monorepo root, then calendar-api parent, then cwd (no fallback) +const candidates = [ + path.resolve(__dirname, "../../../.env"), // repo root from dist/ + path.resolve(__dirname, "../../.env"), // repo root from src/ or platforms/.env + path.resolve(process.cwd(), ".env"), +]; +const envPath = candidates.find((p) => fs.existsSync(p)); +if (envPath) { + dotenv.config({ path: envPath }); +} else { + console.warn( + "No .env found at", + candidates.join(", "), + "- env vars must be set by shell or elsewhere" + ); +} +import { AuthController } from "./controllers/AuthController"; +import { EventsController } from "./controllers/EventsController"; +import { authMiddleware } from "./middleware/auth"; + +const app = express(); +const port = process.env.PORT ?? 4001; + +app.use( + cors({ + origin: true, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + }) +); +app.use(express.json()); + +const authController = new AuthController(); +const eventsController = new EventsController(); + +app.get("/api/auth/offer", authController.getOffer); +app.get("/api/auth/sessions/:id", authController.sseStream); +app.post("/api/auth", authController.login); + +app.get("/api/events", authMiddleware, eventsController.list); +app.post("/api/events", authMiddleware, eventsController.create); +app.patch("/api/events/:id", authMiddleware, eventsController.update); +app.delete("/api/events/:id", authMiddleware, eventsController.remove); + +app.listen(port, () => { + console.log(`Calendar API running on port ${port}`); +}); diff --git a/platforms/calendar-api/src/middleware/auth.ts b/platforms/calendar-api/src/middleware/auth.ts new file mode 100644 index 000000000..28a2c00c7 --- /dev/null +++ b/platforms/calendar-api/src/middleware/auth.ts @@ -0,0 +1,37 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +export interface AuthPayload { + ename: string; +} + +export interface AuthenticatedRequest extends Request { + user?: AuthPayload; +} + +export function authMiddleware( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Missing or invalid Authorization header" }); + return; + } + + const token = authHeader.slice(7); + const secret = process.env.JWT_SECRET || "calendar-api-dev-secret"; + + try { + const decoded = jwt.verify(token, secret) as AuthPayload; + if (!decoded.ename) { + res.status(401).json({ error: "Invalid token payload" }); + return; + } + req.user = { ename: decoded.ename }; + next(); + } catch { + res.status(401).json({ error: "Invalid or expired token" }); + } +} diff --git a/platforms/calendar-api/src/services/EVaultService.ts b/platforms/calendar-api/src/services/EVaultService.ts new file mode 100644 index 000000000..dd6756325 --- /dev/null +++ b/platforms/calendar-api/src/services/EVaultService.ts @@ -0,0 +1,305 @@ +import { GraphQLClient } from "graphql-request"; +import { CALENDAR_EVENT_ONTOLOGY_ID } from "../constants"; + +const META_ENVELOPES_QUERY = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { + metaEnvelopes(filter: $filter, first: $first, after: $after) { + edges { + cursor + node { + id + ontology + parsed + } + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } + } +`; + +const CREATE_MUTATION = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + errors { field message code } + } + } +`; + +const UPDATE_MUTATION = ` + mutation UpdateMetaEnvelope($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { + metaEnvelope { + id + ontology + parsed + } + errors { message code } + } + } +`; + +const META_ENVELOPE_QUERY = ` + query MetaEnvelope($id: ID!) { + metaEnvelope(id: $id) { + id + parsed + } + } +`; + +const REMOVE_MUTATION = ` + mutation RemoveMetaEnvelope($id: ID!) { + removeMetaEnvelope(id: $id) { + deletedId + success + errors { message code } + } + } +`; + +export interface CalendarEventPayload { + title: string; + color?: string; + start: string; + end: string; +} + +export interface CalendarEventResponse { + id: string; + title: string; + color: string; + start: string; + end: string; +} + +function getEvaultGraphqlUrl(): string { + const base = process.env.PUBLIC_EVAULT_SERVER_URI || "http://localhost:4000"; + const normalized = base.replace(/\/$/, ""); + return `${normalized}/graphql`; +} + +interface PlatformTokenResponse { + token: string; + expiresAt?: number; +} + +export class EVaultService { + private platformToken: string | null = null; + private tokenExpiresAt: number = 0; + + private async ensurePlatformToken(): Promise { + const now = Date.now(); + if (this.platformToken && this.tokenExpiresAt > now + 5 * 60 * 1000) { + return this.platformToken; + } + + const registryUrl = process.env.PUBLIC_REGISTRY_URL; + if (!registryUrl) { + throw new Error("PUBLIC_REGISTRY_URL not configured"); + } + + const baseUrl = process.env.NEXT_PUBLIC_CALENDAR_APP_URL; + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_CALENDAR_APP_URL not configured"); + } + + const response = await fetch( + new URL("/platforms/certification", registryUrl).toString(), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform: baseUrl }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to get platform token: HTTP ${response.status}`); + } + + const data = (await response.json()) as PlatformTokenResponse; + this.platformToken = data.token; + this.tokenExpiresAt = data.expiresAt || now + 3600000; + console.log("[EVaultService] Platform token obtained for", baseUrl, "expires at", new Date(this.tokenExpiresAt).toISOString()); + return this.platformToken; + } + + private async getClient(eName: string): Promise { + const endpoint = getEvaultGraphqlUrl(); + const token = await this.ensurePlatformToken(); + console.log("[EVaultService] Creating client for endpoint:", endpoint, "eName:", eName); + return new GraphQLClient(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + "X-ENAME": eName, + }, + }); + } + + async listEvents( + eName: string, + first = 100, + after?: string + ): Promise { + console.log("[EVaultService.listEvents] eName:", eName, "first:", first, "after:", after); + const client = await this.getClient(eName); + const variables: { + filter: { ontologyId: string }; + first: number; + after?: string; + } = { + filter: { ontologyId: CALENDAR_EVENT_ONTOLOGY_ID }, + first, + }; + if (after != null && after !== "") { + variables.after = after; + } + console.log("[EVaultService.listEvents] variables:", JSON.stringify(variables)); + type MetaEnvelopesResult = { + metaEnvelopes: { + edges: Array<{ + node: { id: string; parsed: Record }; + }>; + }; + }; + let result: MetaEnvelopesResult; + try { + result = await client.request(META_ENVELOPES_QUERY, variables); + } catch (err: unknown) { + const msg = + err && + typeof err === "object" && + "response" in err + ? JSON.stringify((err as { response?: unknown }).response) + : String(err); + console.error("[EVaultService] metaEnvelopes request failed:", msg); + throw new Error(`eVault query failed: ${msg}`); + } + + return result.metaEnvelopes.edges.map((edge) => { + const p = edge.node.parsed as Record; + return { + id: edge.node.id, + title: (p.title as string) ?? "", + color: (p.color as string) ?? "", + start: (p.start as string) ?? "", + end: (p.end as string) ?? "", + }; + }); + } + + async createEvent( + eName: string, + payload: CalendarEventPayload + ): Promise { + const client = await this.getClient(eName); + const result = await client.request<{ + createMetaEnvelope: { + metaEnvelope: { id: string; parsed: Record } | null; + errors: Array<{ message: string }>; + }; + }>(CREATE_MUTATION, { + input: { + ontology: CALENDAR_EVENT_ONTOLOGY_ID, + payload: { + title: payload.title, + color: payload.color ?? "", + start: payload.start, + end: payload.end, + }, + acl: ["*"], + }, + }); + + const { metaEnvelope, errors } = result.createMetaEnvelope; + if (errors?.length) { + throw new Error(errors.map((e) => e.message).join("; ")); + } + if (!metaEnvelope) { + throw new Error("Create failed: no metaEnvelope returned"); + } + + const p = metaEnvelope.parsed as Record; + return { + id: metaEnvelope.id, + title: (p.title as string) ?? "", + color: (p.color as string) ?? "", + start: (p.start as string) ?? "", + end: (p.end as string) ?? "", + }; + } + + async updateEvent( + eName: string, + id: string, + partial: Partial + ): Promise { + const client = await this.getClient(eName); + const existing = await client.request<{ + metaEnvelope: { id: string; parsed: Record } | null; + }>(META_ENVELOPE_QUERY, { id }); + if (!existing.metaEnvelope?.parsed) { + throw new Error("Event not found"); + } + const current = existing.metaEnvelope.parsed as Record; + const payload: CalendarEventPayload = { + title: (partial.title as string) ?? (current.title as string), + color: (partial.color as string) ?? (current.color as string) ?? "", + start: (partial.start as string) ?? (current.start as string), + end: (partial.end as string) ?? (current.end as string), + }; + + const result = await client.request<{ + updateMetaEnvelope: { + metaEnvelope: { id: string; parsed: Record } | null; + errors: Array<{ message: string }>; + }; + }>(UPDATE_MUTATION, { + id, + input: { + ontology: CALENDAR_EVENT_ONTOLOGY_ID, + payload, + acl: ["*"], + }, + }); + + const { metaEnvelope, errors } = result.updateMetaEnvelope; + if (errors?.length) { + throw new Error(errors.map((e) => e.message).join("; ")); + } + if (!metaEnvelope) { + throw new Error("Update failed: no metaEnvelope returned"); + } + + const p = metaEnvelope.parsed as Record; + return { + id: metaEnvelope.id, + title: (p.title as string) ?? "", + color: (p.color as string) ?? "", + start: (p.start as string) ?? "", + end: (p.end as string) ?? "", + }; + } + + async removeEvent(eName: string, id: string): Promise { + const client = await this.getClient(eName); + const result = await client.request<{ + removeMetaEnvelope: { success: boolean; errors: Array<{ message: string }> }; + }>(REMOVE_MUTATION, { id }); + + if (result.removeMetaEnvelope.errors?.length) { + throw new Error( + result.removeMetaEnvelope.errors.map((e) => e.message).join("; ") + ); + } + return result.removeMetaEnvelope.success; + } +} diff --git a/platforms/calendar-api/src/utils/version.ts b/platforms/calendar-api/src/utils/version.ts new file mode 100644 index 000000000..3f8569021 --- /dev/null +++ b/platforms/calendar-api/src/utils/version.ts @@ -0,0 +1,19 @@ +export function compareVersions(version1: string, version2: string): number { + const v1Parts = version1.split(".").map(Number); + const v2Parts = version2.split(".").map(Number); + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const v1Part = v1Parts[i] ?? 0; + const v2Part = v2Parts[i] ?? 0; + if (v1Part < v2Part) return -1; + if (v1Part > v2Part) return 1; + } + return 0; +} + +export function isVersionValid( + appVersion: string, + minVersion: string +): boolean { + return compareVersions(appVersion, minVersion) >= 0; +} diff --git a/platforms/calendar-api/tsconfig.json b/platforms/calendar-api/tsconfig.json new file mode 100644 index 000000000..1b1efaaf3 --- /dev/null +++ b/platforms/calendar-api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/platforms/calendar/.gitignore b/platforms/calendar/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/platforms/calendar/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/platforms/calendar/README.md b/platforms/calendar/README.md new file mode 100644 index 000000000..235e446eb --- /dev/null +++ b/platforms/calendar/README.md @@ -0,0 +1,77 @@ +# React/ShadCN Calendar + +This calendar is built with shadcn & tailwind. Has all the aspects you'd want in a calendar for a dashboard/app! + +Make sure to give this repo a star! + +## Features + +- 🎨 Fully customizable with Tailwind CSS +- πŸŒ“ Dark mode support +- 🎯 Accessible components using Radix UI +- πŸ“± Responsive design +- πŸ”„ Multiple view modes +- πŸ“… Advanced event management + +## Modes + +### Day + +- Detailed single day view +- Hour-by-hour breakdown +- Event management +- Time-slot selection + +### Week + +- 7-day view +- Multiple event display +- Quick navigation + +### Month + +- Full month overview +- Event preview +- Quick date selection +- Today highlighting + +## Dialog Features + +### Create Event Dialog + +- πŸ“ Event title input +- πŸ•’ Date/time picker for start and end times +- 🎨 Color picker for event categorization +- βœ… Form validation +- πŸ”„ Real-time preview + +### Manage Event Dialog + +- ✏️ Edit existing events +- πŸ—‘οΈ Delete events +- πŸ•’ Modify date/time +- 🎨 Update event color +- ⚑ Quick actions + +### Date/Time Picker + +- πŸ“… Calendar date selection +- ⏰ Hour selection (24-hour format) + +[From RDSX](https://time.rdsx.dev/) + +## Contributing + +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +## Original Inspiriation + +[Synergy CRM](https://synergy-platform.vercel.app/calendar) + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=charlietlamb/calendar&type=Date)](https://star-history.com/#charlietlamb/calendar&Date) + +## License + +MIT diff --git a/platforms/calendar/app/favicon.ico b/platforms/calendar/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/platforms/calendar/app/favicon.ico differ diff --git a/platforms/calendar/app/globals.css b/platforms/calendar/app/globals.css new file mode 100644 index 000000000..8e1928398 --- /dev/null +++ b/platforms/calendar/app/globals.css @@ -0,0 +1,113 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root, + .light { + --background: 0 0% 95.69%; + --foreground: 0 0% 14.51%; + + --card: var(--background); + --card-foreground: var(--foreground); + + --popover: 0 0% 91.37%; + --popover-foreground: 0 0% 3.92%; + + --primary: 194 100% 44%; + --primary-foreground: 210 40% 98.04%; + + --secondary: 0 0% 86.27%; + --secondary-foreground: 0 0% 10.2%; + + --muted: 210 15.38% 89.8%; + --muted-foreground: 216 10.2% 48.04%; + + --accent: 0 0% 91.37%; + --accent-foreground: 0 0% 10.2%; + + --destructive: 0 63.87% 53.33%; + --destructive-foreground: 0 0% 98.04%; + + --border: 0 0% 81.96%; + --input: 0 0% 81.96%; + + --ring: 0 0% 3.92%; + + --chart-1: var(--primary); + --chart-2: 220 85% 65%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + + --sidebar-background: 0 0% 91.37%; + --sidebar-foreground: 0 0% 3.92%; + --sidebar-primary: 267 85.86% 38.82%; + --sidebar-primary-foreground: 210 40% 98.04%; + + --sidebar-accent: 0 0% 91.37%; + --sidebar-accent-foreground: 0 0% 10.2%; + --sidebar-border: 0 0% 81.96%; + --sidebar-ring: 0 0% 3.92%; + + --radius: 0.75rem; + } + .dark { + --background: 0 0% 7.06%; + --foreground: 0 0% 89.8%; + + --card: 0 0% 11.37%; + --card-foreground: 0 0% 94.51%; + + --popover: 0 0% 11.37%; + --popover-foreground: 0 0% 94.51%; + + --primary: 194 100% 56%; + --primary-foreground: 0 0% 100%; + + --secondary: 0 0% 17.25%; + --secondary-foreground: 0 0% 89.8%; + + --muted: 0 0% 22.75%; + --muted-foreground: 0 0% 64.71%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 349 52.24% 60.59%; + --destructive-foreground: 0 0% 100%; + + --border: 0 0% 22.75%; + --input: 0 0% 22.75%; + --ring: 0 0% 89.8%; + + --chart-1: var(--primary); + --chart-2: 220 85% 65%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + + --sidebar-background: 0 0% 11.37%; + --sidebar-foreground: 0 0% 94.51%; + --sidebar-primary: 267 95.16% 75.69%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 0 0% 98%; + --sidebar-border: 0 0% 22.75%; + --sidebar-ring: 0 0% 89.8%; + + --radius: 0.75rem; + } +} +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/platforms/calendar/app/layout.tsx b/platforms/calendar/app/layout.tsx new file mode 100644 index 000000000..3fc7fef9a --- /dev/null +++ b/platforms/calendar/app/layout.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from 'next' +import { Geist, Geist_Mono } from 'next/font/google' +import './globals.css' +import { ThemeProvider } from 'next-themes' +import { AuthProvider } from '@/contexts/auth-context' +import Header from '@/components/header/header' + +const geistSans = Geist({ + subsets: ['latin'], + variable: '--font-geist-sans', + display: 'swap', + adjustFontFallback: false, +}) + +const geistMono = Geist_Mono({ + subsets: ['latin'], + variable: '--font-geist-mono', + display: 'swap', + adjustFontFallback: false, +}) + +export const metadata: Metadata = { + title: 'React/Shadcn Calendar', + description: 'By @charlietlamb', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + +
+
+
+ {children} +
+
+
+
+ + + ) +} diff --git a/platforms/calendar/app/page.tsx b/platforms/calendar/app/page.tsx new file mode 100644 index 000000000..e7ea92fc9 --- /dev/null +++ b/platforms/calendar/app/page.tsx @@ -0,0 +1,5 @@ +import CalendarDemo from '@/components/calendar-demo' + +export default function Home() { + return +} diff --git a/platforms/calendar/components.json b/platforms/calendar/components.json new file mode 100644 index 000000000..dea737b85 --- /dev/null +++ b/platforms/calendar/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/platforms/calendar/components/auth/login-screen.tsx b/platforms/calendar/components/auth/login-screen.tsx new file mode 100644 index 000000000..d3cf17aad --- /dev/null +++ b/platforms/calendar/components/auth/login-screen.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { QRCodeSVG } from 'qrcode.react' +import { + calendarApi, + getCalendarApiUrl, + parseSessionFromUri, +} from '@/lib/calendar-api' +import { useAuth } from '@/contexts/auth-context' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +export default function LoginScreen() { + const { login } = useAuth() + const [uri, setUri] = useState('') + const [sessionId, setSessionId] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [connecting, setConnecting] = useState(false) + + const fetchOffer = useCallback(async () => { + setLoading(true) + setError(null) + try { + const apiUrl = getCalendarApiUrl() + if (process.env.NODE_ENV === 'development') { + console.log('[auth] fetching offer from', `${apiUrl}/api/auth/offer`) + } + const data = await calendarApi.getOffer() + setUri(data.uri) + const sid = data.sessionId ?? parseSessionFromUri(data.uri) + setSessionId(sid ?? '') + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load login') + setUri('') + setSessionId('') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchOffer() + }, [fetchOffer]) + + useEffect(() => { + if (!sessionId) return + const apiUrl = getCalendarApiUrl() + const eventSource = new EventSource( + `${apiUrl}/api/auth/sessions/${sessionId}` + ) + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { token?: string } + if (data.token) { + setConnecting(true) + login(data.token) + eventSource.close() + } + } catch { + // ignore parse errors + } + } + eventSource.onerror = () => { + eventSource.close() + } + return () => eventSource.close() + }, [sessionId, login]) + + if (loading) { + return ( +
+

Loading login…

+
+ ) + } + + if (error) { + return ( +
+ + + Login unavailable + + +

{error}

+ +
+
+
+ ) + } + + return ( +
+ + + Sign in with W3DS +

+ Use your eID Wallet to sign in. This app does not store your + password; you keep control of your identity. +

+
+ + {connecting && ( +

+ Signing in… +

+ )} + + {/* Desktop: show QR code */} +
+

+ Scan with your eID Wallet app +

+ {uri && ( +
+ +
+ )} +
+ + {/* Mobile: show button */} +
+

+ Open in your eID Wallet app +

+ +
+
+
+
+ ) +} diff --git a/platforms/calendar/components/calendar-demo.tsx b/platforms/calendar/components/calendar-demo.tsx new file mode 100644 index 000000000..a71be68d6 --- /dev/null +++ b/platforms/calendar/components/calendar-demo.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Calendar from './calendar/calendar' +import { CalendarEvent, Mode } from './calendar/calendar-types' +import { useAuth } from '@/contexts/auth-context' +import LoginScreen from '@/components/auth/login-screen' +import { calendarApi } from '@/lib/calendar-api' + +function mapApiToEvent(e: { + id: string + title: string + color: string + start: string + end: string +}): CalendarEvent { + return { + id: e.id, + title: e.title, + color: e.color || 'blue', + start: new Date(e.start), + end: new Date(e.end), + } +} + +export default function CalendarDemo() { + const { isAuthenticated, isReady } = useAuth() + const [events, setEvents] = useState([]) + const [mode, setMode] = useState('week') + const [date, setDate] = useState(new Date()) + const [eventsLoading, setEventsLoading] = useState(false) + const [eventsError, setEventsError] = useState(null) + + const refetchEvents = useCallback(async () => { + if (!isAuthenticated) return + setEventsLoading(true) + setEventsError(null) + try { + const list = await calendarApi.getEvents() + setEvents(list.map(mapApiToEvent)) + } catch (e) { + setEventsError(e instanceof Error ? e.message : 'Failed to load events') + setEvents([]) + } finally { + setEventsLoading(false) + } + }, [isAuthenticated]) + + useEffect(() => { + if (isAuthenticated) refetchEvents() + }, [isAuthenticated, refetchEvents]) + + if (!isReady) { + return ( +
+

Loading…

+
+ ) + } + + if (!isAuthenticated) { + return + } + + return ( +
+ {eventsLoading && events.length === 0 && ( +
+

Loading events…

+
+ )} + {eventsError && ( +
+ {eventsError} +
+ )} +
+ +
+
+ ) +} diff --git a/platforms/calendar/components/calendar/body/calendar-body-header.tsx b/platforms/calendar/components/calendar/body/calendar-body-header.tsx new file mode 100644 index 000000000..dbd6ad64c --- /dev/null +++ b/platforms/calendar/components/calendar/body/calendar-body-header.tsx @@ -0,0 +1,35 @@ +import { format, isSameDay } from 'date-fns' +import { cn } from '../../../lib/utils' + +export default function CalendarBodyHeader({ + date, + onlyDay = false, +}: { + date: Date + onlyDay?: boolean +}) { + const isToday = isSameDay(date, new Date()) + + return ( +
+ + {format(date, 'EEE')} + + {!onlyDay && ( + + {format(date, 'dd')} + + )} +
+ ) +} diff --git a/platforms/calendar/components/calendar/body/calendar-body.tsx b/platforms/calendar/components/calendar/body/calendar-body.tsx new file mode 100644 index 000000000..6a2d8dd39 --- /dev/null +++ b/platforms/calendar/components/calendar/body/calendar-body.tsx @@ -0,0 +1,20 @@ +import { useCalendarContext } from '../calendar-context' +import CalendarBodyDay from './day/calendar-body-day' +import CalendarBodyWeek from './week/calendar-body-week' +import CalendarBodyMonth from './month/calendar-body-month' + +export default function CalendarBody() { + const { mode } = useCalendarContext() + + return ( +
+ {mode === 'day' && } + {mode === 'week' && } + {mode === 'month' && ( +
+ +
+ )} +
+ ) +} diff --git a/platforms/calendar/components/calendar/body/current-time-line.tsx b/platforms/calendar/components/calendar/body/current-time-line.tsx new file mode 100644 index 000000000..ce3b7934f --- /dev/null +++ b/platforms/calendar/components/calendar/body/current-time-line.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useEffect, useState } from 'react' +import { format } from 'date-fns' +import { getCurrentTimeOffsetTop } from './day/calendar-body-margin-day-margin' + +export function CurrentTimeLine() { + const [now, setNow] = useState(new Date()) + + useEffect(() => { + const interval = setInterval(() => { + setNow(new Date()) + }, 60000) // Update every minute + return () => clearInterval(interval) + }, []) + + const top = getCurrentTimeOffsetTop() + + return ( +
+
+ {format(now, 'h:mm a')} +
+
+
+ ) +} + +export { getCurrentTimeOffsetTop } diff --git a/platforms/calendar/components/calendar/body/day/calendar-body-day-calendar.tsx b/platforms/calendar/components/calendar/body/day/calendar-body-day-calendar.tsx new file mode 100644 index 000000000..f73d99f22 --- /dev/null +++ b/platforms/calendar/components/calendar/body/day/calendar-body-day-calendar.tsx @@ -0,0 +1,13 @@ +import { useCalendarContext } from '../../calendar-context' +import { Calendar } from '@/components/ui/calendar' + +export default function CalendarBodyDayCalendar() { + const { date, setDate } = useCalendarContext() + return ( + date && setDate(date)} + mode="single" + /> + ) +} diff --git a/platforms/calendar/components/calendar/body/day/calendar-body-day-content.tsx b/platforms/calendar/components/calendar/body/day/calendar-body-day-content.tsx new file mode 100644 index 000000000..41efd2d76 --- /dev/null +++ b/platforms/calendar/components/calendar/body/day/calendar-body-day-content.tsx @@ -0,0 +1,33 @@ +import { useCalendarContext } from '../../calendar-context' +import { isSameDay } from 'date-fns' +import { hours } from './calendar-body-margin-day-margin' +import CalendarBodyHeader from '../calendar-body-header' +import CalendarEvent from '../../calendar-event' + +export default function CalendarBodyDayContent({ + date, + hideHeader = false, +}: { + date: Date + hideHeader?: boolean +}) { + const { events } = useCalendarContext() + + const dayEvents = events.filter((event) => isSameDay(event.start, date)) + + return ( +
+ {!hideHeader && } + +
+ {hours.map((hour) => ( +
+ ))} + + {dayEvents.map((event) => ( + + ))} +
+
+ ) +} diff --git a/platforms/calendar/components/calendar/body/day/calendar-body-day-events.tsx b/platforms/calendar/components/calendar/body/day/calendar-body-day-events.tsx new file mode 100644 index 000000000..b88677dde --- /dev/null +++ b/platforms/calendar/components/calendar/body/day/calendar-body-day-events.tsx @@ -0,0 +1,35 @@ +import { useCalendarContext } from '../../calendar-context' +import { isSameDay } from 'date-fns' + +export default function CalendarBodyDayEvents() { + const { events, date, setManageEventDialogOpen, setSelectedEvent } = + useCalendarContext() + const dayEvents = events.filter((event) => isSameDay(event.start, date)) + + return !!dayEvents.length ? ( +
+

Events

+
+ {dayEvents.map((event) => ( +
{ + setSelectedEvent(event) + setManageEventDialogOpen(true) + }} + > +
+
+

+ {event.title} +

+
+
+ ))} +
+
+ ) : ( +
No events today...
+ ) +} diff --git a/platforms/calendar/components/calendar/body/day/calendar-body-day.tsx b/platforms/calendar/components/calendar/body/day/calendar-body-day.tsx new file mode 100644 index 000000000..0bc6b14dd --- /dev/null +++ b/platforms/calendar/components/calendar/body/day/calendar-body-day.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { isSameDay } from 'date-fns' +import CalendarBodyDayCalendar from './calendar-body-day-calendar' +import CalendarBodyDayEvents from './calendar-body-day-events' +import { useCalendarContext } from '../../calendar-context' +import CalendarBodyDayContent from './calendar-body-day-content' +import CalendarBodyMarginDayMargin from './calendar-body-margin-day-margin' +import CalendarBodyHeader from '../calendar-body-header' +import { + CurrentTimeLine, + getCurrentTimeOffsetTop, +} from '../current-time-line' +import { + GRID_HEADER_HEIGHT, + PIXELS_PER_HOUR, +} from './calendar-body-margin-day-margin' + +const TOTAL_GRID_HEIGHT = 24 * PIXELS_PER_HOUR // Header is now outside the scrollable area + +export default function CalendarBodyDay() { + const { date } = useCalendarContext() + const scrollRef = useRef(null) + const showTimeline = isSameDay(date, new Date()) + + useEffect(() => { + if (!scrollRef.current) return + const el = scrollRef.current + const scrollToCenter = () => { + if (!el.clientHeight || !el.scrollHeight) return + const top = getCurrentTimeOffsetTop() + const center = el.clientHeight / 2 + const scrollTop = Math.max( + 0, + Math.min(top - center, el.scrollHeight - el.clientHeight) + ) + el.scrollTop = scrollTop + } + + // Try multiple times to ensure DOM is ready + const timeouts: NodeJS.Timeout[] = [] + timeouts.push(setTimeout(scrollToCenter, 0)) + timeouts.push(setTimeout(scrollToCenter, 50)) + timeouts.push(setTimeout(scrollToCenter, 150)) + + return () => timeouts.forEach(clearTimeout) + }, [showTimeline, date]) + + return ( +
+
+ {/* Sticky: single day header */} +
+
+
+ +
+
+ {/* Scrollable: time grid only */} +
+
+ + + {showTimeline && } +
+
+
+
+ + +
+
+ ) +} diff --git a/platforms/calendar/components/calendar/body/day/calendar-body-margin-day-margin.tsx b/platforms/calendar/components/calendar/body/day/calendar-body-margin-day-margin.tsx new file mode 100644 index 000000000..fecc48b69 --- /dev/null +++ b/platforms/calendar/components/calendar/body/day/calendar-body-margin-day-margin.tsx @@ -0,0 +1,38 @@ +import { format } from 'date-fns' +import { cn } from '@/lib/utils' + +export const hours = Array.from({ length: 24 }, (_, i) => i) +export const PIXELS_PER_HOUR = 128 +export const GRID_HEADER_HEIGHT = 48 // Legacy: for calculating total grid height + +export function getCurrentTimeOffsetTop(): number { + const now = new Date() + const hours = now.getHours() + now.getMinutes() / 60 + now.getSeconds() / 3600 + // Header is now outside the scrollable area, so start from 0 + return hours * PIXELS_PER_HOUR +} + +export default function CalendarBodyMarginDayMargin({ + className, +}: { + className?: string +}) { + return ( +
+ {hours.map((hour) => ( +
+ {hour !== 0 && ( + + {format(new Date().setHours(hour, 0, 0, 0), 'h a')} + + )} +
+ ))} +
+ ) +} diff --git a/platforms/calendar/components/calendar/body/month/calendar-body-month.tsx b/platforms/calendar/components/calendar/body/month/calendar-body-month.tsx new file mode 100644 index 000000000..5bbefacd7 --- /dev/null +++ b/platforms/calendar/components/calendar/body/month/calendar-body-month.tsx @@ -0,0 +1,139 @@ +import { useCalendarContext } from '../../calendar-context' +import { + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + format, + isWithinInterval, +} from 'date-fns' +import { cn } from '@/lib/utils' +import CalendarEvent from '../../calendar-event' +import { AnimatePresence, motion } from 'framer-motion' + +export default function CalendarBodyMonth() { + const { date, events, setDate, setMode } = useCalendarContext() + + // Get the first day of the month + const monthStart = startOfMonth(date) + // Get the last day of the month + const monthEnd = endOfMonth(date) + + // Get the first Monday of the first week (may be in previous month) + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }) + // Get the last Sunday of the last week (may be in next month) + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }) + + // Get all days between start and end + const calendarDays = eachDayOfInterval({ + start: calendarStart, + end: calendarEnd, + }) + + const today = new Date() + + // Filter events to only show those within the current month view + const visibleEvents = events.filter( + (event) => + isWithinInterval(event.start, { + start: calendarStart, + end: calendarEnd, + }) || + isWithinInterval(event.end, { start: calendarStart, end: calendarEnd }) + ) + + return ( +
+
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => ( +
+ {day} +
+ ))} +
+ + + + {calendarDays.map((day) => { + const dayEvents = visibleEvents.filter((event) => + isSameDay(event.start, day) + ) + const isToday = isSameDay(day, today) + const isCurrentMonth = isSameMonth(day, date) + + return ( + + ) + })} + + +
+ ) +} diff --git a/platforms/calendar/components/calendar/body/week/calendar-body-week.tsx b/platforms/calendar/components/calendar/body/week/calendar-body-week.tsx new file mode 100644 index 000000000..2259f0481 --- /dev/null +++ b/platforms/calendar/components/calendar/body/week/calendar-body-week.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useCalendarContext } from '../../calendar-context' +import { startOfWeek, addDays, isSameDay } from 'date-fns' +import CalendarBodyMarginDayMargin from '../day/calendar-body-margin-day-margin' +import CalendarBodyDayContent from '../day/calendar-body-day-content' +import CalendarBodyHeader from '../calendar-body-header' +import { + CurrentTimeLine, + getCurrentTimeOffsetTop, +} from '../current-time-line' +import { + GRID_HEADER_HEIGHT, + PIXELS_PER_HOUR, +} from '../day/calendar-body-margin-day-margin' + +const TOTAL_GRID_HEIGHT = 24 * PIXELS_PER_HOUR // Header is now outside the scrollable area + +export default function CalendarBodyWeek() { + const { date } = useCalendarContext() + const scrollRef = useRef(null) + + const weekStart = startOfWeek(date, { weekStartsOn: 1 }) + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) + const today = new Date() + const showTimeline = weekDays.some((day) => isSameDay(day, today)) + + useEffect(() => { + if (!scrollRef.current) return + const el = scrollRef.current + const scrollToCenter = () => { + if (!el.clientHeight || !el.scrollHeight) return + const top = getCurrentTimeOffsetTop() + const center = el.clientHeight / 2 + const scrollTop = Math.max( + 0, + Math.min(top - center, el.scrollHeight - el.clientHeight) + ) + el.scrollTop = scrollTop + } + + // Try multiple times to ensure DOM is ready + const timeouts: NodeJS.Timeout[] = [] + timeouts.push(setTimeout(scrollToCenter, 0)) + timeouts.push(setTimeout(scrollToCenter, 50)) + timeouts.push(setTimeout(scrollToCenter, 150)) + + return () => timeouts.forEach(clearTimeout) + }, [showTimeline, date]) + + return ( +
+ {/* Sticky: day headers row */} +
+
+ {weekDays.map((day) => ( +
+ +
+ ))} +
+ {/* Scrollable: time grid only */} +
+
+ + {weekDays.map((day) => ( +
+ + +
+ ))} + {showTimeline && } +
+
+
+ ) +} diff --git a/platforms/calendar/components/calendar/calendar-context.tsx b/platforms/calendar/components/calendar/calendar-context.tsx new file mode 100644 index 000000000..981575fae --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-context.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react' +import type { CalendarContextType } from './calendar-types' + +export const CalendarContext = createContext( + undefined +) + +export function useCalendarContext() { + const context = useContext(CalendarContext) + if (!context) { + throw new Error('useCalendarContext must be used within a CalendarProvider') + } + return context +} diff --git a/platforms/calendar/components/calendar/calendar-event.tsx b/platforms/calendar/components/calendar/calendar-event.tsx new file mode 100644 index 000000000..1666b99a9 --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-event.tsx @@ -0,0 +1,151 @@ +import { CalendarEvent as CalendarEventType } from '@/components/calendar/calendar-types' +import { useCalendarContext } from '@/components/calendar/calendar-context' +import { format, isSameDay, isSameMonth } from 'date-fns' +import { cn } from '@/lib/utils' +import { motion, MotionConfig, AnimatePresence } from 'framer-motion' + +interface EventPosition { + left: string + width: string + top: string + height: string +} + +function getOverlappingEvents( + currentEvent: CalendarEventType, + events: CalendarEventType[] +): CalendarEventType[] { + return events.filter((event) => { + if (event.id === currentEvent.id) return false + return ( + currentEvent.start < event.end && + currentEvent.end > event.start && + isSameDay(currentEvent.start, event.start) + ) + }) +} + +function calculateEventPosition( + event: CalendarEventType, + allEvents: CalendarEventType[] +): EventPosition { + const overlappingEvents = getOverlappingEvents(event, allEvents) + const group = [event, ...overlappingEvents].sort( + (a, b) => a.start.getTime() - b.start.getTime() + ) + const position = group.indexOf(event) + const width = `${100 / (overlappingEvents.length + 1)}%` + const left = `${(position * 100) / (overlappingEvents.length + 1)}%` + + const startHour = event.start.getHours() + const startMinutes = event.start.getMinutes() + + let endHour = event.end.getHours() + let endMinutes = event.end.getMinutes() + + if (!isSameDay(event.start, event.end)) { + endHour = 23 + endMinutes = 59 + } + + const topPosition = startHour * 128 + (startMinutes / 60) * 128 + const duration = endHour * 60 + endMinutes - (startHour * 60 + startMinutes) + const height = (duration / 60) * 128 + + return { + left, + width, + top: `${topPosition}px`, + height: `${height}px`, + } +} + +export default function CalendarEvent({ + event, + month = false, + className, +}: { + event: CalendarEventType + month?: boolean + className?: string +}) { + const { events, setSelectedEvent, setManageEventDialogOpen, date } = + useCalendarContext() + const style = month ? {} : calculateEventPosition(event, events) + + // Generate a unique key that includes the current month to prevent animation conflicts + const isEventInCurrentMonth = isSameMonth(event.start, date) + const animationKey = `${event.id}-${ + isEventInCurrentMonth ? 'current' : 'adjacent' + }` + + return ( + + + { + e.stopPropagation() + setSelectedEvent(event) + setManageEventDialogOpen(true) + }} + initial={{ + opacity: 0, + y: -3, + scale: 0.98, + }} + animate={{ + opacity: 1, + y: 0, + scale: 1, + }} + exit={{ + opacity: 0, + scale: 0.98, + transition: { + duration: 0.15, + ease: 'easeOut', + }, + }} + transition={{ + duration: 0.2, + ease: [0.25, 0.1, 0.25, 1], + opacity: { + duration: 0.2, + ease: 'linear', + }, + layout: { + duration: 0.2, + ease: 'easeOut', + }, + }} + layoutId={`event-${animationKey}-${month ? 'month' : 'day'}`} + > + +

+ {event.title} +

+

+ {format(event.start, 'h:mm a')} + - + + {format(event.end, 'h:mm a')} + +

+
+
+
+
+ ) +} diff --git a/platforms/calendar/components/calendar/calendar-mode-icon-map.tsx b/platforms/calendar/components/calendar/calendar-mode-icon-map.tsx new file mode 100644 index 000000000..60e83a6e8 --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-mode-icon-map.tsx @@ -0,0 +1,8 @@ +import { Columns2, Grid3X3, List } from 'lucide-react' +import { Mode } from './calendar-types' + +export const calendarModeIconMap: Record = { + day: , + week: , + month: , +} diff --git a/platforms/calendar/components/calendar/calendar-provider.tsx b/platforms/calendar/components/calendar/calendar-provider.tsx new file mode 100644 index 000000000..7eed1a794 --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-provider.tsx @@ -0,0 +1,56 @@ +import { CalendarContext } from './calendar-context' +import { CalendarEvent, Mode } from './calendar-types' +import { useState } from 'react' +import CalendarNewEventDialog from './dialog/calendar-new-event-dialog' +import CalendarManageEventDialog from './dialog/calendar-manage-event-dialog' + +export default function CalendarProvider({ + events, + setEvents, + mode, + setMode, + date, + setDate, + calendarIconIsToday = true, + refetchEvents, + children, +}: { + events: CalendarEvent[] + setEvents: (events: CalendarEvent[]) => void + mode: Mode + setMode: (mode: Mode) => void + date: Date + setDate: (date: Date) => void + calendarIconIsToday: boolean + refetchEvents?: () => void | Promise + children: React.ReactNode +}) { + const [newEventDialogOpen, setNewEventDialogOpen] = useState(false) + const [manageEventDialogOpen, setManageEventDialogOpen] = useState(false) + const [selectedEvent, setSelectedEvent] = useState(null) + + return ( + + + + {children} + + ) +} diff --git a/platforms/calendar/components/calendar/calendar-tailwind-classes.ts b/platforms/calendar/components/calendar/calendar-tailwind-classes.ts new file mode 100644 index 000000000..4c4ff01ed --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-tailwind-classes.ts @@ -0,0 +1,69 @@ +// this is used to generate all tailwind classes for the calendar +// if you want to use your own colors, you can override the classes here + +export const colorOptions = [ + { + value: 'blue', + label: 'Blue', + class: { + base: 'bg-blue-500 border-blue-500 bg-blue-500/10 hover:bg-blue-500/20 text-blue-500', + light: 'bg-blue-300 border-blue-300 bg-blue-300/10 text-blue-300', + dark: 'dark:bg-blue-700 dark:border-blue-700 bg-blue-700/10 text-blue-700', + }, + }, + { + value: 'indigo', + label: 'Indigo', + class: { + base: 'bg-indigo-500 border-indigo-500 bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-500', + light: 'bg-indigo-300 border-indigo-300 bg-indigo-300/10 text-indigo-300', + dark: 'dark:bg-indigo-700 dark:border-indigo-700 bg-indigo-700/10 text-indigo-700', + }, + }, + { + value: 'pink', + label: 'Pink', + class: { + base: 'bg-pink-500 border-pink-500 bg-pink-500/10 hover:bg-pink-500/20 text-pink-500', + light: 'bg-pink-300 border-pink-300 bg-pink-300/10 text-pink-300', + dark: 'dark:bg-pink-700 dark:border-pink-700 bg-pink-700/10 text-pink-700', + }, + }, + { + value: 'red', + label: 'Red', + class: { + base: 'bg-red-500 border-red-500 bg-red-500/10 hover:bg-red-500/20 text-red-500', + light: 'bg-red-300 border-red-300 bg-red-300/10 text-red-300', + dark: 'dark:bg-red-700 dark:border-red-700 bg-red-700/10 text-red-700', + }, + }, + { + value: 'orange', + label: 'Orange', + class: { + base: 'bg-orange-500 border-orange-500 bg-orange-500/10 hover:bg-orange-500/20 text-orange-500', + light: 'bg-orange-300 border-orange-300 bg-orange-300/10 text-orange-300', + dark: 'dark:bg-orange-700 dark:border-orange-700 bg-orange-700/10 text-orange-700', + }, + }, + { + value: 'amber', + label: 'Amber', + class: { + base: 'bg-amber-500 border-amber-500 bg-amber-500/10 hover:bg-amber-500/20 text-amber-500', + light: 'bg-amber-300 border-amber-300 bg-amber-300/10 text-amber-300', + dark: 'dark:bg-amber-700 dark:border-amber-700 bg-amber-700/10 text-amber-700', + }, + }, + { + value: 'emerald', + label: 'Emerald', + class: { + base: 'bg-emerald-500 border-emerald-500 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500', + light: + 'bg-emerald-300 border-emerald-300 bg-emerald-300/10 text-emerald-300', + dark: 'dark:bg-emerald-700 dark:border-emerald-700 bg-emerald-700/10 text-emerald-700', + }, + }, +] diff --git a/platforms/calendar/components/calendar/calendar-types.ts b/platforms/calendar/components/calendar/calendar-types.ts new file mode 100644 index 000000000..f55d8efbf --- /dev/null +++ b/platforms/calendar/components/calendar/calendar-types.ts @@ -0,0 +1,30 @@ +export type CalendarProps = { + events: CalendarEvent[] + setEvents: (events: CalendarEvent[]) => void + mode: Mode + setMode: (mode: Mode) => void + date: Date + setDate: (date: Date) => void + calendarIconIsToday?: boolean + refetchEvents?: () => void | Promise +} + +export type CalendarContextType = CalendarProps & { + newEventDialogOpen: boolean + setNewEventDialogOpen: (open: boolean) => void + manageEventDialogOpen: boolean + setManageEventDialogOpen: (open: boolean) => void + selectedEvent: CalendarEvent | null + setSelectedEvent: (event: CalendarEvent | null) => void + refetchEvents?: () => void | Promise +} +export type CalendarEvent = { + id: string + title: string + color: string + start: Date + end: Date +} + +export const calendarModes = ['day', 'week', 'month'] as const +export type Mode = (typeof calendarModes)[number] diff --git a/platforms/calendar/components/calendar/calendar.tsx b/platforms/calendar/components/calendar/calendar.tsx new file mode 100644 index 000000000..3c598fc02 --- /dev/null +++ b/platforms/calendar/components/calendar/calendar.tsx @@ -0,0 +1,47 @@ +import type { CalendarProps } from './calendar-types' +import CalendarHeader from './header/calendar-header' +import CalendarBody from './body/calendar-body' +import CalendarHeaderActions from './header/actions/calendar-header-actions' +import CalendarHeaderDate from './header/date/calendar-header-date' +import CalendarHeaderActionsMode from './header/actions/calendar-header-actions-mode' +import CalendarHeaderActionsAdd from './header/actions/calendar-header-actions-add' +import CalendarProvider from './calendar-provider' + +export default function Calendar({ + events, + setEvents, + mode, + setMode, + date, + setDate, + calendarIconIsToday = true, + refetchEvents, +}: CalendarProps) { + return ( + +
+
+ + + + + + + +
+
+ +
+
+
+ ) +} diff --git a/platforms/calendar/components/calendar/dialog/calendar-manage-event-dialog.tsx b/platforms/calendar/components/calendar/dialog/calendar-manage-event-dialog.tsx new file mode 100644 index 000000000..b419e8ec4 --- /dev/null +++ b/platforms/calendar/components/calendar/dialog/calendar-manage-event-dialog.tsx @@ -0,0 +1,238 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { useCalendarContext } from '../calendar-context' +import { DateTimePicker } from '@/components/form/date-time-picker' +import { ColorPicker } from '@/components/form/color-picker' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' + +const formSchemaBase = z.object({ + title: z.string().min(1, 'Title is required'), + start: z.string().datetime(), + end: z.string().datetime(), + color: z.string(), +}) + +type FormValues = z.infer + +export default function CalendarManageEventDialog() { + const { + manageEventDialogOpen, + setManageEventDialogOpen, + selectedEvent, + setSelectedEvent, + refetchEvents, + } = useCalendarContext() + const [submitting, setSubmitting] = useState(false) + const [deleteLoading, setDeleteLoading] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const form = useForm({ + resolver: zodResolver(formSchemaBase as any), + defaultValues: { + title: '', + start: '', + end: '', + color: 'blue', + }, + }) + + useEffect(() => { + if (selectedEvent) { + const start = + selectedEvent.start instanceof Date + ? selectedEvent.start + : new Date(selectedEvent.start) + const end = + selectedEvent.end instanceof Date + ? selectedEvent.end + : new Date(selectedEvent.end) + form.reset({ + title: selectedEvent.title, + start: start.toISOString(), + end: end.toISOString(), + color: selectedEvent.color, + }) + } + }, [selectedEvent, form]) + + async function onSubmit(values: FormValues) { + if (!selectedEvent) return + const start = new Date(values.start) + const end = new Date(values.end) + if (end < start) { + form.setError('end', { message: 'End time must be after start time' }) + return + } + setSubmitError(null) + setSubmitting(true) + try { + const { calendarApi } = await import('@/lib/calendar-api') + await calendarApi.updateEvent(selectedEvent.id, { + title: values.title, + start: start.toISOString(), + end: end.toISOString(), + color: values.color, + }) + await refetchEvents?.() + handleClose() + } catch (e) { + setSubmitError(e instanceof Error ? e.message : 'Failed to update event') + } finally { + setSubmitting(false) + } + } + + async function handleDelete() { + if (!selectedEvent) return + setDeleteLoading(true) + try { + const { calendarApi } = await import('@/lib/calendar-api') + await calendarApi.deleteEvent(selectedEvent.id) + await refetchEvents?.() + handleClose() + } catch (e) { + setSubmitError(e instanceof Error ? e.message : 'Failed to delete event') + } finally { + setDeleteLoading(false) + } + } + + function handleClose() { + setManageEventDialogOpen(false) + setSelectedEvent(null) + form.reset() + } + + return ( + + + + Manage event + +
+ + {submitError && ( +

{submitError}

+ )} + ( + + Title + + + + + + )} + /> + + ( + + Start + + + + + + )} + /> + + ( + + End + + + + + + )} + /> + + ( + + Color + + + + + + )} + /> + + + + + + + + + Delete event + + Are you sure you want to delete this event? This action + cannot be undone. + + + + Cancel + + Delete + + + + + + + + +
+
+ ) +} diff --git a/platforms/calendar/components/calendar/dialog/calendar-new-event-dialog.tsx b/platforms/calendar/components/calendar/dialog/calendar-new-event-dialog.tsx new file mode 100644 index 000000000..e98d3e8ce --- /dev/null +++ b/platforms/calendar/components/calendar/dialog/calendar-new-event-dialog.tsx @@ -0,0 +1,159 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { useState } from 'react' +import { useCalendarContext } from '../calendar-context' +import { DateTimePicker } from '@/components/form/date-time-picker' +import { ColorPicker } from '@/components/form/color-picker' + +const formSchemaBase = z.object({ + title: z.string().min(1, 'Title is required'), + start: z.string().datetime(), + end: z.string().datetime(), + color: z.string(), +}) + +type FormValues = z.infer + +export default function CalendarNewEventDialog() { + const { + newEventDialogOpen, + setNewEventDialogOpen, + date, + setEvents, + refetchEvents, + } = useCalendarContext() + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const form = useForm({ + resolver: zodResolver(formSchemaBase as any), + defaultValues: { + title: '', + start: date.toISOString(), + end: date.toISOString(), + color: 'blue', + }, + }) + + async function onSubmit(values: FormValues) { + const startDate = new Date(values.start) + const endDate = new Date(values.end) + if (endDate < startDate) { + form.setError('end', { message: 'End time must be after start time' }) + return + } + setSubmitError(null) + setSubmitting(true) + try { + const { calendarApi } = await import('@/lib/calendar-api') + await calendarApi.createEvent({ + title: values.title, + color: values.color, + start: startDate.toISOString(), + end: endDate.toISOString(), + }) + await refetchEvents?.() + setNewEventDialogOpen(false) + form.reset() + } catch (e) { + setSubmitError(e instanceof Error ? e.message : 'Failed to create event') + } finally { + setSubmitting(false) + } + } + + return ( + + + + Create event + +
+ + ( + + Title + + + + + + )} + /> + + ( + + Start + + + + + + )} + /> + + ( + + End + + + + + + )} + /> + + ( + + Color + + + + + + )} + /> + + {submitError && ( +

{submitError}

+ )} +
+ +
+ + +
+
+ ) +} diff --git a/platforms/calendar/components/calendar/header/actions/calendar-header-actions-add.tsx b/platforms/calendar/components/calendar/header/actions/calendar-header-actions-add.tsx new file mode 100644 index 000000000..01c99e090 --- /dev/null +++ b/platforms/calendar/components/calendar/header/actions/calendar-header-actions-add.tsx @@ -0,0 +1,16 @@ +import { Button } from '@/components/ui/button' +import { Plus } from 'lucide-react' +import { useCalendarContext } from '../../calendar-context' + +export default function CalendarHeaderActionsAdd() { + const { setNewEventDialogOpen } = useCalendarContext() + return ( + + ) +} diff --git a/platforms/calendar/components/calendar/header/actions/calendar-header-actions-mode.tsx b/platforms/calendar/components/calendar/header/actions/calendar-header-actions-mode.tsx new file mode 100644 index 000000000..563ab6199 --- /dev/null +++ b/platforms/calendar/components/calendar/header/actions/calendar-header-actions-mode.tsx @@ -0,0 +1,129 @@ +'use client' + +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { Mode, calendarModes } from '../../calendar-types' +import { useCalendarContext } from '../../calendar-context' +import { calendarModeIconMap } from '../../calendar-mode-icon-map' +import { motion, AnimatePresence, LayoutGroup } from 'framer-motion' +import { cn } from '@/lib/utils' + +export default function CalendarHeaderActionsMode() { + const { mode, setMode } = useCalendarContext() + + return ( + + { + if (value) setMode(value as Mode) + }} + > + {calendarModes.map((modeValue) => { + const isSelected = mode === modeValue + return ( + + + + + {calendarModeIconMap[modeValue]} + + + {isSelected && ( + + {modeValue.charAt(0).toUpperCase() + modeValue.slice(1)} + + )} + + + + + ) + })} + + + ) +} diff --git a/platforms/calendar/components/calendar/header/actions/calendar-header-actions.tsx b/platforms/calendar/components/calendar/header/actions/calendar-header-actions.tsx new file mode 100644 index 000000000..e3b654e7c --- /dev/null +++ b/platforms/calendar/components/calendar/header/actions/calendar-header-actions.tsx @@ -0,0 +1,11 @@ +export default function CalendarHeaderActions({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} diff --git a/platforms/calendar/components/calendar/header/calendar-header.tsx b/platforms/calendar/components/calendar/header/calendar-header.tsx new file mode 100644 index 000000000..628b7eb66 --- /dev/null +++ b/platforms/calendar/components/calendar/header/calendar-header.tsx @@ -0,0 +1,11 @@ +export default function CalendarHeader({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} diff --git a/platforms/calendar/components/calendar/header/date/calendar-header-date-badge.tsx b/platforms/calendar/components/calendar/header/date/calendar-header-date-badge.tsx new file mode 100644 index 000000000..58a105f09 --- /dev/null +++ b/platforms/calendar/components/calendar/header/date/calendar-header-date-badge.tsx @@ -0,0 +1,14 @@ +import { useCalendarContext } from '../../calendar-context' +import { isSameMonth } from 'date-fns' + +export default function CalendarHeaderDateBadge() { + const { events, date } = useCalendarContext() + const monthEvents = events.filter((event) => isSameMonth(event.start, date)) + + if (!monthEvents.length) return null + return ( +
+ {monthEvents.length} events +
+ ) +} diff --git a/platforms/calendar/components/calendar/header/date/calendar-header-date-chevrons.tsx b/platforms/calendar/components/calendar/header/date/calendar-header-date-chevrons.tsx new file mode 100644 index 000000000..6ed6a920d --- /dev/null +++ b/platforms/calendar/components/calendar/header/date/calendar-header-date-chevrons.tsx @@ -0,0 +1,68 @@ +import { Button } from '@/components/ui/button' +import { useCalendarContext } from '../../calendar-context' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { + format, + addDays, + addMonths, + addWeeks, + subDays, + subMonths, + subWeeks, +} from 'date-fns' + +export default function CalendarHeaderDateChevrons() { + const { mode, date, setDate } = useCalendarContext() + + function handleDateBackward() { + switch (mode) { + case 'month': + setDate(subMonths(date, 1)) + break + case 'week': + setDate(subWeeks(date, 1)) + break + case 'day': + setDate(subDays(date, 1)) + break + } + } + + function handleDateForward() { + switch (mode) { + case 'month': + setDate(addMonths(date, 1)) + break + case 'week': + setDate(addWeeks(date, 1)) + break + case 'day': + setDate(addDays(date, 1)) + break + } + } + + return ( +
+ + + + {format(date, 'MMMM d, yyyy')} + + + +
+ ) +} diff --git a/platforms/calendar/components/calendar/header/date/calendar-header-date-icon.tsx b/platforms/calendar/components/calendar/header/date/calendar-header-date-icon.tsx new file mode 100644 index 000000000..f90376dd3 --- /dev/null +++ b/platforms/calendar/components/calendar/header/date/calendar-header-date-icon.tsx @@ -0,0 +1,16 @@ +import { format } from 'date-fns' +import { useCalendarContext } from '../../calendar-context' +export default function CalendarHeaderDateIcon() { + const { calendarIconIsToday, date: calendarDate } = useCalendarContext() + const date = calendarIconIsToday ? new Date() : calendarDate + return ( +
+

+ {format(date, 'MMM')} +

+

+ {format(date, 'dd')} +

+
+ ) +} diff --git a/platforms/calendar/components/calendar/header/date/calendar-header-date.tsx b/platforms/calendar/components/calendar/header/date/calendar-header-date.tsx new file mode 100644 index 000000000..349e60f1d --- /dev/null +++ b/platforms/calendar/components/calendar/header/date/calendar-header-date.tsx @@ -0,0 +1,21 @@ +import { useCalendarContext } from '../../calendar-context' +import { format } from 'date-fns' +import CalendarHeaderDateIcon from './calendar-header-date-icon' +import CalendarHeaderDateChevrons from './calendar-header-date-chevrons' +import CalendarHeaderDateBadge from './calendar-header-date-badge' + +export default function CalendarHeaderDate() { + const { date } = useCalendarContext() + return ( +
+ +
+
+

{format(date, 'MMMM yyyy')}

+ +
+ +
+
+ ) +} diff --git a/platforms/calendar/components/form/color-picker.tsx b/platforms/calendar/components/form/color-picker.tsx new file mode 100644 index 000000000..8f0431f2d --- /dev/null +++ b/platforms/calendar/components/form/color-picker.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { cn } from '@/lib/utils' +import { colorOptions } from '../calendar/calendar-tailwind-classes' + +interface ColorPickerProps { + field: { + value: string + onChange: (value: string) => void + } +} + +export function ColorPicker({ field }: ColorPickerProps) { + return ( + + {colorOptions.map((color) => ( + + ))} + + ) +} diff --git a/platforms/calendar/components/form/date-time-picker.tsx b/platforms/calendar/components/form/date-time-picker.tsx new file mode 100644 index 000000000..043c1cb4b --- /dev/null +++ b/platforms/calendar/components/form/date-time-picker.tsx @@ -0,0 +1,159 @@ +'use client' + +import * as React from 'react' +import { CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' + +interface DateTimePickerProps { + field: { + value: string + onChange: (value: string) => void + } +} + +export function DateTimePicker({ field }: DateTimePickerProps) { + const [date, setDate] = React.useState( + field.value ? new Date(field.value) : new Date() + ) + const [isOpen, setIsOpen] = React.useState(false) + + const hours = Array.from({ length: 12 }, (_, i) => i + 1) + + const handleDateSelect = (selectedDate: Date | undefined) => { + if (selectedDate) { + const newDate = new Date(date) + newDate.setFullYear(selectedDate.getFullYear()) + newDate.setMonth(selectedDate.getMonth()) + newDate.setDate(selectedDate.getDate()) + setDate(newDate) + field.onChange(newDate.toISOString()) + } + } + + const handleTimeChange = ( + type: 'hour' | 'minute' | 'ampm', + value: string + ) => { + const newDate = new Date(date) + if (type === 'hour') { + newDate.setHours( + (parseInt(value) % 12) + (newDate.getHours() >= 12 ? 12 : 0) + ) + } else if (type === 'minute') { + newDate.setMinutes(parseInt(value)) + } else if (type === 'ampm') { + const currentHours = newDate.getHours() + const isPM = value === 'PM' + if (isPM && currentHours < 12) { + newDate.setHours(currentHours + 12) + } else if (!isPM && currentHours >= 12) { + newDate.setHours(currentHours - 12) + } + } + setDate(newDate) + field.onChange(newDate.toISOString()) + } + + return ( + + + + + +
+ +
+ +
+ {hours.map((hour) => ( + + ))} +
+ +
+ +
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => ( + + ))} +
+ +
+ +
+ {['AM', 'PM'].map((ampm) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/platforms/calendar/components/header/header-github.tsx b/platforms/calendar/components/header/header-github.tsx new file mode 100644 index 000000000..ba63f147a --- /dev/null +++ b/platforms/calendar/components/header/header-github.tsx @@ -0,0 +1,18 @@ +'use client' + +import { Github } from 'lucide-react' +import { Button } from '../ui/button' + +export default function HeaderGithub() { + return ( + + ) +} diff --git a/platforms/calendar/components/header/header-theme-toggle.tsx b/platforms/calendar/components/header/header-theme-toggle.tsx new file mode 100644 index 000000000..d5f22f848 --- /dev/null +++ b/platforms/calendar/components/header/header-theme-toggle.tsx @@ -0,0 +1,56 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Monitor, Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' + +export function HeaderThemeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme('light')}> + + setTheme('dark')}> + + setTheme('system')}> + + + + ) +} diff --git a/platforms/calendar/components/header/header.tsx b/platforms/calendar/components/header/header.tsx new file mode 100644 index 000000000..091da5427 --- /dev/null +++ b/platforms/calendar/components/header/header.tsx @@ -0,0 +1,31 @@ +'use client' + +import HeaderGithub from './header-github' +import { HeaderThemeToggle } from './header-theme-toggle' +import { useAuth } from '@/contexts/auth-context' +import { Button } from '@/components/ui/button' + +export default function Header() { + const { isAuthenticated, logout } = useAuth() + return ( +
+
+

+ React, Tailwind and Shadcn Full Calendar +

+

+ By @charlietlamb +

+
+
+ {isAuthenticated && ( + + )} + + +
+
+ ) +} diff --git a/platforms/calendar/components/ui/accordion.tsx b/platforms/calendar/components/ui/accordion.tsx new file mode 100644 index 000000000..2f55a32f4 --- /dev/null +++ b/platforms/calendar/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/platforms/calendar/components/ui/alert-dialog.tsx b/platforms/calendar/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..57760f2ee --- /dev/null +++ b/platforms/calendar/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/platforms/calendar/components/ui/alert.tsx b/platforms/calendar/components/ui/alert.tsx new file mode 100644 index 000000000..5afd41d14 --- /dev/null +++ b/platforms/calendar/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/platforms/calendar/components/ui/aspect-ratio.tsx b/platforms/calendar/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..d6a5226f5 --- /dev/null +++ b/platforms/calendar/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/platforms/calendar/components/ui/avatar.tsx b/platforms/calendar/components/ui/avatar.tsx new file mode 100644 index 000000000..51e507ba9 --- /dev/null +++ b/platforms/calendar/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/platforms/calendar/components/ui/badge.tsx b/platforms/calendar/components/ui/badge.tsx new file mode 100644 index 000000000..e87d62bf1 --- /dev/null +++ b/platforms/calendar/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/platforms/calendar/components/ui/breadcrumb.tsx b/platforms/calendar/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..60e6c96f7 --- /dev/null +++ b/platforms/calendar/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>