diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b0d8710..a7ae637 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: name: Tests runs-on: docker container: - image: cypress/browsers:latest@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb + image: cypress/browsers:latest@sha256:95587c1ce688ce6f59934cc234a753a32a1782ca1c7959707a7d2332e69f6f63 options: --user 1001 steps: - name: Checkout diff --git a/cypress/e2e/auth-user.ts b/cypress/e2e/auth-user.ts deleted file mode 100644 index 5b02ab9..0000000 --- a/cypress/e2e/auth-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -export default function authUser() { - cy.visit('http://127.0.0.1:3000/login'); - cy.getBySel('login-header').should('exist'); - cy.getBySel('login-form').should('exist'); - cy.getBySel('email-input').should('exist'); - cy.getBySel('password-input').should('exist'); - cy.getBySel('login-button').should('exist'); - cy.getBySel('email-input').type('cypress@example.com'); - cy.getBySel('password-input').type('Password123!'); - cy.getBySel('login-button').click(); - cy.url().should('include', '/home'); -} diff --git a/cypress/e2e/event-create.cy.ts b/cypress/e2e/event-create.cy.ts index a74f770..2a8385a 100644 --- a/cypress/e2e/event-create.cy.ts +++ b/cypress/e2e/event-create.cy.ts @@ -1,9 +1,40 @@ -import authUser from './auth-user'; - describe('event creation', () => { it('loads', () => { - authUser(); + cy.login(); - // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests + cy.visit('http://127.0.0.1:3000/events/new'); + cy.getBySel('event-form').should('exist'); + cy.getBySel('event-form').within(() => { + cy.getBySel('event-name-input').should('exist'); + cy.getBySel('event-start-time-picker').should('exist'); + cy.getBySel('event-end-time-picker').should('exist'); + cy.getBySel('event-location-input').should('exist'); + cy.getBySel('event-description-input').should('exist'); + cy.getBySel('event-save-button').should('exist'); + }); + }); + + it('creates an event', () => { + cy.login(); + cy.visit( + 'http://127.0.0.1:3000/events/new?start=2025-07-01T01:00:00.000Z&end=2025-07-01T04:30:00.000Z', + ); + + cy.getBySel('event-form').should('exist'); + cy.getBySel('event-form').within(() => { + cy.getBySel('event-name-input').type('Cypress Test Event'); + cy.getBySel('event-location-input').type('Cypress Park'); + cy.getBySel('event-description-input').type( + 'This is a test event created by Cypress.', + ); + cy.getBySel('event-save-button').click(); + }); + cy.wait(1000); + cy.visit('http://127.0.0.1:3000/events'); + cy.getBySel('event-list-entry').should('exist'); + cy.getBySel('event-list-entry') + .contains('Cypress Test Event') + .should('exist'); + cy.getBySel('event-list-entry').contains('Cypress Park').should('exist'); }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 59717f5..994b7ef 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-namespace */ /// // *********************************************** // This example commands.ts shows you how to @@ -44,6 +46,22 @@ Cypress.Commands.add('getBySelLike', (selector, ...args) => { return cy.get(`[data-cy*=${selector}]`, ...args); }); +Cypress.Commands.add('login', () => { + cy.session('auth', () => { + cy.visit('http://127.0.0.1:3000/login'); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('cypress@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); + cy.getBySel('header').should('exist'); + }); +}); + declare global { namespace Cypress { interface Chainable { @@ -55,6 +73,7 @@ declare global { selector: string, ...args: any[] ): Chainable>; + login(): Chainable; } } } diff --git a/next.config.ts b/next.config.ts index 164b423..b9574f9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const nextConfig: NextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'img1.wikia.nocookie.net', + hostname: 'i.gifer.com', port: '', pathname: '/**', }, diff --git a/package.json b/package.json index e5e8c9e..95266fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meetup", - "version": "0.1.0", + "version": "0.1.3", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -73,15 +73,15 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.11", - "@types/node": "22.15.34", + "@types/node": "22.16.0", "@types/react": "19.1.8", "@types/react-big-calendar": "1.16.2", "@types/react-dom": "19.1.6", "@types/swagger-ui-react": "5.18.0", "@types/webpack-env": "1.18.8", - "cypress": "14.5.0", + "cypress": "14.5.1", "dotenv-cli": "8.0.0", - "eslint": "9.30.0", + "eslint": "9.30.1", "eslint-config-next": "15.3.4", "eslint-config-prettier": "10.1.5", "orval": "7.10.0", diff --git a/prisma/migrations/20250701092705_v0_1_3/migration.sql b/prisma/migrations/20250701092705_v0_1_3/migration.sql new file mode 100644 index 0000000..b103c30 --- /dev/null +++ b/prisma/migrations/20250701092705_v0_1_3/migration.sql @@ -0,0 +1,170 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_blocked_slots" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "reason" TEXT, + "is_recurring" BOOLEAN NOT NULL DEFAULT false, + "rrule" TEXT, + "recurrence_end_date" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "blocked_slots_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_blocked_slots" ("created_at", "end_time", "id", "is_recurring", "reason", "recurrence_end_date", "rrule", "start_time", "updated_at", "user_id") SELECT "created_at", "end_time", "id", "is_recurring", "reason", "recurrence_end_date", "rrule", "start_time", "updated_at", "user_id" FROM "blocked_slots"; +DROP TABLE "blocked_slots"; +ALTER TABLE "new_blocked_slots" RENAME TO "blocked_slots"; +CREATE INDEX "blocked_slots_user_id_start_time_end_time_idx" ON "blocked_slots"("user_id", "start_time", "end_time"); +CREATE INDEX "blocked_slots_user_id_is_recurring_idx" ON "blocked_slots"("user_id", "is_recurring"); +CREATE TABLE "new_calendar_export_tokens" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "scope" TEXT NOT NULL DEFAULT 'MEETINGS_ONLY', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_accessed_at" DATETIME, + CONSTRAINT "calendar_export_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_calendar_export_tokens" ("created_at", "id", "is_active", "last_accessed_at", "scope", "token", "user_id") SELECT "created_at", "id", "is_active", "last_accessed_at", "scope", "token", "user_id" FROM "calendar_export_tokens"; +DROP TABLE "calendar_export_tokens"; +ALTER TABLE "new_calendar_export_tokens" RENAME TO "calendar_export_tokens"; +CREATE UNIQUE INDEX "calendar_export_tokens_token_key" ON "calendar_export_tokens"("token"); +CREATE INDEX "calendar_export_tokens_user_id_idx" ON "calendar_export_tokens"("user_id"); +CREATE TABLE "new_calendar_subscriptions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "feed_url" TEXT NOT NULL, + "name" TEXT, + "color" TEXT, + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "last_synced_at" DATETIME, + "last_sync_error" TEXT, + "sync_frequency_minutes" INTEGER DEFAULT 60, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "calendar_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_calendar_subscriptions" ("color", "created_at", "feed_url", "id", "is_enabled", "last_sync_error", "last_synced_at", "name", "sync_frequency_minutes", "updated_at", "user_id") SELECT "color", "created_at", "feed_url", "id", "is_enabled", "last_sync_error", "last_synced_at", "name", "sync_frequency_minutes", "updated_at", "user_id" FROM "calendar_subscriptions"; +DROP TABLE "calendar_subscriptions"; +ALTER TABLE "new_calendar_subscriptions" RENAME TO "calendar_subscriptions"; +CREATE INDEX "calendar_subscriptions_user_id_is_enabled_idx" ON "calendar_subscriptions"("user_id", "is_enabled"); +CREATE TABLE "new_email_queue" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "body_html" TEXT NOT NULL, + "body_text" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "scheduled_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "attempts" INTEGER NOT NULL DEFAULT 0, + "last_attempt_at" DATETIME, + "sent_at" DATETIME, + "error_message" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "email_queue_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_email_queue" ("attempts", "body_html", "body_text", "created_at", "error_message", "id", "last_attempt_at", "scheduled_at", "sent_at", "status", "subject", "updated_at", "user_id") SELECT "attempts", "body_html", "body_text", "created_at", "error_message", "id", "last_attempt_at", "scheduled_at", "sent_at", "status", "subject", "updated_at", "user_id" FROM "email_queue"; +DROP TABLE "email_queue"; +ALTER TABLE "new_email_queue" RENAME TO "email_queue"; +CREATE INDEX "idx_email_queue_pending_jobs" ON "email_queue"("status", "scheduled_at"); +CREATE INDEX "idx_email_queue_user_history" ON "email_queue"("user_id", "created_at"); +CREATE TABLE "new_external_events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "subscription_id" TEXT NOT NULL, + "ical_uid" TEXT NOT NULL, + "summary" TEXT, + "description" TEXT, + "start_time" DATETIME NOT NULL, + "end_time" DATETIME NOT NULL, + "is_all_day" BOOLEAN NOT NULL DEFAULT false, + "location" TEXT, + "rrule" TEXT, + "dtstamp" DATETIME, + "sequence" INTEGER, + "show_as_free" BOOLEAN NOT NULL DEFAULT false, + "last_fetched_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "external_events_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_external_events" ("description", "dtstamp", "end_time", "ical_uid", "id", "is_all_day", "last_fetched_at", "location", "rrule", "sequence", "show_as_free", "start_time", "subscription_id", "summary") SELECT "description", "dtstamp", "end_time", "ical_uid", "id", "is_all_day", "last_fetched_at", "location", "rrule", "sequence", "show_as_free", "start_time", "subscription_id", "summary" FROM "external_events"; +DROP TABLE "external_events"; +ALTER TABLE "new_external_events" RENAME TO "external_events"; +CREATE INDEX "external_events_subscription_id_start_time_end_time_idx" ON "external_events"("subscription_id", "start_time", "end_time"); +CREATE INDEX "external_events_subscription_id_show_as_free_idx" ON "external_events"("subscription_id", "show_as_free"); +CREATE UNIQUE INDEX "external_events_subscription_id_ical_uid_key" ON "external_events"("subscription_id", "ical_uid"); +CREATE TABLE "new_friendships" ( + "user_id_1" TEXT NOT NULL, + "user_id_2" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "requested_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accepted_at" DATETIME, + + PRIMARY KEY ("user_id_1", "user_id_2"), + CONSTRAINT "friendships_user_id_1_fkey" FOREIGN KEY ("user_id_1") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "friendships_user_id_2_fkey" FOREIGN KEY ("user_id_2") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_friendships" ("accepted_at", "requested_at", "status", "user_id_1", "user_id_2") SELECT "accepted_at", "requested_at", "status", "user_id_1", "user_id_2" FROM "friendships"; +DROP TABLE "friendships"; +ALTER TABLE "new_friendships" RENAME TO "friendships"; +CREATE INDEX "idx_friendships_user2_status" ON "friendships"("user_id_2", "status"); +CREATE TABLE "new_group_members" ( + "group_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'MEMBER', + "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("group_id", "user_id"), + CONSTRAINT "group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_group_members" ("added_at", "group_id", "role", "user_id") SELECT "added_at", "group_id", "role", "user_id" FROM "group_members"; +DROP TABLE "group_members"; +ALTER TABLE "new_group_members" RENAME TO "group_members"; +CREATE INDEX "group_members_user_id_idx" ON "group_members"("user_id"); +CREATE TABLE "new_meeting_participants" ( + "meeting_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "added_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("meeting_id", "user_id"), + CONSTRAINT "meeting_participants_meeting_id_fkey" FOREIGN KEY ("meeting_id") REFERENCES "meetings" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "meeting_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_meeting_participants" ("added_at", "meeting_id", "status", "user_id") SELECT "added_at", "meeting_id", "status", "user_id" FROM "meeting_participants"; +DROP TABLE "meeting_participants"; +ALTER TABLE "new_meeting_participants" RENAME TO "meeting_participants"; +CREATE INDEX "idx_participants_user_status" ON "meeting_participants"("user_id", "status"); +CREATE TABLE "new_notifications" ( + "id" TEXT NOT NULL PRIMARY KEY, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "related_entity_type" TEXT, + "related_entity_id" TEXT, + "message" TEXT NOT NULL, + "is_read" BOOLEAN NOT NULL DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_notifications" ("created_at", "id", "is_read", "message", "related_entity_id", "related_entity_type", "type", "user_id") SELECT "created_at", "id", "is_read", "message", "related_entity_id", "related_entity_type", "type", "user_id" FROM "notifications"; +DROP TABLE "notifications"; +ALTER TABLE "new_notifications" RENAME TO "notifications"; +CREATE INDEX "idx_notifications_user_read_time" ON "notifications"("user_id", "is_read", "created_at"); +CREATE TABLE "new_user_notification_preferences" ( + "user_id" TEXT NOT NULL, + "notification_type" TEXT NOT NULL, + "email_enabled" BOOLEAN NOT NULL DEFAULT false, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY ("user_id", "notification_type"), + CONSTRAINT "user_notification_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_user_notification_preferences" ("email_enabled", "notification_type", "updated_at", "user_id") SELECT "email_enabled", "notification_type", "updated_at", "user_id" FROM "user_notification_preferences"; +DROP TABLE "user_notification_preferences"; +ALTER TABLE "new_user_notification_preferences" RENAME TO "user_notification_preferences"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/src/app/(main)/blocker/[slotId]/page.tsx b/src/app/(main)/blocker/[slotId]/page.tsx new file mode 100644 index 0000000..893253c --- /dev/null +++ b/src/app/(main)/blocker/[slotId]/page.tsx @@ -0,0 +1,10 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default async function NewBlockedSlotPage({ + params, +}: { + params: Promise<{ slotId?: string }>; +}) { + const resolvedParams = await params; + return ; +} diff --git a/src/app/(main)/blocker/new/page.tsx b/src/app/(main)/blocker/new/page.tsx new file mode 100644 index 0000000..a7c1bc7 --- /dev/null +++ b/src/app/(main)/blocker/new/page.tsx @@ -0,0 +1,5 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default function NewBlockedSlotPage() { + return ; +} diff --git a/src/app/(main)/blocker/page.tsx b/src/app/(main)/blocker/page.tsx new file mode 100644 index 0000000..aebc807 --- /dev/null +++ b/src/app/(main)/blocker/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { RedirectButton } from '@/components/buttons/redirect-button'; +import BlockedSlotListEntry from '@/components/custom-ui/blocked-slot-list-entry'; +import { Label } from '@/components/ui/label'; +import { useGetApiBlockedSlots } from '@/generated/api/blocked-slots/blocked-slots'; + +export default function BlockedSlots() { + const { data: blockedSlotsData, isLoading, error } = useGetApiBlockedSlots(); + + if (isLoading) return
Loading...
; + if (error) + return ( +
+ Error loading blocked slots +
+ ); + + const blockedSlots = blockedSlotsData?.data?.blocked_slots || []; + + return ( +
+ {/* Heading */} +

+ My Blockers +

+ + {/* Scrollable blocked slot list */} +
+
+ {blockedSlots.length > 0 ? ( + blockedSlots.map((slot) => ( + + )) + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx index 81b98cf..b2c4005 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -8,7 +8,6 @@ import { useDeleteApiEventEventID, useGetApiEventEventID, } from '@/generated/api/event/event'; -import { useGetApiUserMe } from '@/generated/api/user/user'; import { RedirectButton } from '@/components/buttons/redirect-button'; import { useSession } from 'next-auth/react'; import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; @@ -35,27 +34,21 @@ export default function ShowEvent() { // Fetch event data const { data: eventData, isLoading, error } = useGetApiEventEventID(eventID); - const { data: userData, isLoading: userLoading } = useGetApiUserMe(); const deleteEvent = useDeleteApiEventEventID(); - if (isLoading || userLoading) { + if (isLoading) { return ( -
- Loading... -
+
Loading...
); } if (error || !eventData?.data?.event) { return ( -
+
Error loading event.
); } - const event = eventData.data.event; - const organiserName = userData?.data.user?.name || 'Unknown User'; - // Format dates & times for display const formatDate = (isoString?: string) => { if (!isoString) return '-'; @@ -70,167 +63,186 @@ export default function ShowEvent() { }; return ( - - +
+ + - -
-
-
-
- + +
+
+
+
+ +
+
+

+ {eventData.data.event.title || 'Untitled Event'} +

+
+
-
-

- {event.title || 'Untitled Event'} -

-
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - + + +
); } diff --git a/src/app/(main)/events/new/page.tsx b/src/app/(main)/events/new/page.tsx index 997a9d6..1dc1bde 100644 --- a/src/app/(main)/events/new/page.tsx +++ b/src/app/(main)/events/new/page.tsx @@ -4,7 +4,7 @@ import { Suspense } from 'react'; export default function NewEvent() { return ( -
+
diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx index f0391dd..c22b8bd 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -17,7 +17,10 @@ export default function Events() { const events = eventsData?.data?.events || []; return ( -
+
{/* Heading */}

My Events @@ -25,7 +28,7 @@ export default function Events() { {/* Scrollable event list */}
-
+
{events.length > 0 ? ( events.map((event) => ( {isLoading ? 'Loading...' - : data?.data.user?.first_name || 'Unknown User'}{' '} + : data?.data.user?.first_name || + data?.data.user?.name || + 'Unknown User'}{' '} 👋

diff --git a/src/app/api/blocked_slots/[slotID]/route.ts b/src/app/api/blocked_slots/[slotID]/route.ts new file mode 100644 index 0000000..908324e --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/route.ts @@ -0,0 +1,165 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + ErrorResponseSchema, + SuccessResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const blockedSlot = await prisma.blockedSlot.findUnique({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { + blocked_slot: blockedSlot, + }, + { + status: 200, + }, + ); +}); + +export const PATCH = auth(async function PATCH(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const dataRaw = await req.json(); + const data = await updateBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const blockedSlot = await prisma.blockedSlot.update({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + data: data.data, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { success: true, blocked_slot: blockedSlot }, + { status: 200 }, + ); +}); + +export const DELETE = auth(async function DELETE(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const deletedSlot = await prisma.blockedSlot.delete({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + }); + + if (!deletedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + SuccessResponseSchema, + { success: true }, + { + status: 200, + }, + ); +}); diff --git a/src/app/api/blocked_slots/[slotID]/swagger.ts b/src/app/api/blocked_slots/[slotID]/swagger.ts new file mode 100644 index 0000000..16f2637 --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/swagger.ts @@ -0,0 +1,90 @@ +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, + invalidRequestDataResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { SlotIdParamSchema } from '@/app/api/validation'; +import zod from 'zod/v4'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 200: { + description: 'Blocked slot retrieved successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'delete', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 204: { + description: 'Blocked slot deleted successfully', + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'patch', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + body: { + content: { + 'application/json': { + schema: updateBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Blocked slot updated successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/route.ts b/src/app/api/blocked_slots/route.ts new file mode 100644 index 0000000..afd4f87 --- /dev/null +++ b/src/app/api/blocked_slots/route.ts @@ -0,0 +1,127 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + blockedSlotsQuerySchema, + BlockedSlotsResponseSchema, + BlockedSlotsSchema, + createBlockedSlotSchema, +} from './validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw: Record = {}; + for (const [key, value] of req.nextUrl.searchParams.entries()) { + if (key.endsWith('[]')) { + const cleanKey = key.slice(0, -2); + if (!dataRaw[cleanKey]) { + dataRaw[cleanKey] = []; + } + if (Array.isArray(dataRaw[cleanKey])) { + (dataRaw[cleanKey] as string[]).push(value); + } else { + dataRaw[cleanKey] = [dataRaw[cleanKey] as string, value]; + } + } else { + dataRaw[key] = value; + } + } + const data = await blockedSlotsQuerySchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { start, end } = data.data; + + const requestUserId = authCheck.user.id; + + const blockedSlots = await prisma.blockedSlot.findMany({ + where: { + user_id: requestUserId, + start_time: { gte: start }, + end_time: { lte: end }, + }, + orderBy: { start_time: 'asc' }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + }, + }); + + return returnZodTypeCheckedResponse( + BlockedSlotsResponseSchema, + { success: true, blocked_slots: blockedSlots }, + { status: 200 }, + ); +}); + +export const POST = auth(async function POST(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw = await req.json(); + const data = await createBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const requestUserId = authCheck.user.id; + + if (!requestUserId) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not authenticated', + }, + { status: 401 }, + ); + } + + const blockedSlot = await prisma.blockedSlot.create({ + data: { + ...data.data, + user_id: requestUserId, + }, + }); + + return returnZodTypeCheckedResponse(BlockedSlotsSchema, blockedSlot, { + status: 201, + }); +}); diff --git a/src/app/api/blocked_slots/swagger.ts b/src/app/api/blocked_slots/swagger.ts new file mode 100644 index 0000000..4be89a9 --- /dev/null +++ b/src/app/api/blocked_slots/swagger.ts @@ -0,0 +1,66 @@ +import { + BlockedSlotResponseSchema, + BlockedSlotsResponseSchema, + blockedSlotsQuerySchema, + createBlockedSlotSchema, +} from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots', + request: { + query: blockedSlotsQuerySchema, + }, + responses: { + 200: { + description: 'Blocked slots retrieved successfully.', + content: { + 'application/json': { + schema: BlockedSlotsResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'post', + path: '/api/blocked_slots', + request: { + body: { + content: { + 'application/json': { + schema: createBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Blocked slot created successfully.', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/validation.ts b/src/app/api/blocked_slots/validation.ts new file mode 100644 index 0000000..1cbe42c --- /dev/null +++ b/src/app/api/blocked_slots/validation.ts @@ -0,0 +1,78 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { + eventEndTimeSchema, + eventStartTimeSchema, +} from '@/app/api/event/validation'; + +extendZodWithOpenApi(zod); + +export const blockedSlotsQuerySchema = zod.object({ + start: eventStartTimeSchema.optional(), + end: eventEndTimeSchema.optional(), +}); + +export const blockedSlotRecurrenceEndDateSchema = zod.iso + .datetime() + .or(zod.date().transform((date) => date.toISOString())); + +export const BlockedSlotsSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + id: zod.string(), + reason: zod.string().nullish(), + created_at: zod.date(), + updated_at: zod.date(), + }) + .openapi('BlockedSlotsSchema', { + description: 'Blocked time slot in the user calendar', + }); + +export const BlockedSlotsResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slots: zod.array(BlockedSlotsSchema), +}); + +export const BlockedSlotResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slot: BlockedSlotsSchema, +}); + +export const createBlockedSlotSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + reason: zod.string().nullish(), + }) + .refine( + (data) => { + return new Date(data.start_time) < new Date(data.end_time); + }, + { + message: 'Start time must be before end time', + path: ['end_time'], + }, + ); + +export const createBlockedSlotClientSchema = zod + .object({ + start_time: zod.iso.datetime({ local: true }), + end_time: zod.iso.datetime({ local: true }), + reason: zod.string().nullish(), + }) + .refine( + (data) => { + return new Date(data.start_time) < new Date(data.end_time); + }, + { + message: 'Start time must be before end time', + path: ['end_time'], + }, + ); + +export const updateBlockedSlotSchema = zod.object({ + start_time: eventStartTimeSchema.optional(), + end_time: eventEndTimeSchema.optional(), + reason: zod.string().optional(), +}); diff --git a/src/app/api/calendar/validation.ts b/src/app/api/calendar/validation.ts index 5bf45a6..bc51489 100644 --- a/src/app/api/calendar/validation.ts +++ b/src/app/api/calendar/validation.ts @@ -48,6 +48,7 @@ export const VisibleSlotSchema = EventSchema.omit({ type: zod.literal('event'), users: zod.string().array(), user_id: zod.string().optional(), + organizer_id: zod.string().optional(), }) .openapi('VisibleSlotSchema', { description: 'Visible time slot in the user calendar', diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts new file mode 100644 index 0000000..ba89440 --- /dev/null +++ b/src/app/api/logout/route.ts @@ -0,0 +1,8 @@ +import { signOut } from '@/auth'; +import { NextResponse } from 'next/server'; + +export const GET = async () => { + await signOut(); + + return NextResponse.redirect('/login'); +}; diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts index 66f07cc..4a1d20e 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -1,11 +1,13 @@ import zod from 'zod/v4'; import { + emailSchema, firstNameSchema, lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, passwordSchema, timezoneSchema, + userNameSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -22,6 +24,15 @@ export const updateUserServerSchema = zod.object({ timezone: timezoneSchema.optional(), }); +export const updateUserClientSchema = zod.object({ + name: userNameSchema.optional(), + first_name: firstNameSchema.optional(), + last_name: lastNameSchema.optional(), + email: emailSchema.optional(), + image: zod.url().optional(), + timezone: timezoneSchema.optional(), +}); + export const updateUserPasswordServerSchema = zod .object({ current_password: zod.string().min(1, 'Current password is required'), diff --git a/src/app/api/validation.ts b/src/app/api/validation.ts index 38b95bd..518121d 100644 --- a/src/app/api/validation.ts +++ b/src/app/api/validation.ts @@ -85,3 +85,14 @@ export const EventIdParamSchema = registry.registerParameter( example: '67890', }), ); + +export const SlotIdParamSchema = registry.registerParameter( + 'SlotIdParam', + zod.string().openapi({ + param: { + name: 'slotID', + in: 'path', + }, + example: 'abcde12345', + }), +); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index dcd207d..9fa84ba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -42,7 +42,9 @@ export default async function LoginPage() { - + {providerMap.length > 0 && !process.env.DISABLE_PASSWORD_LOGIN ? ( + + ) : null} {providerMap.map((provider) => ( dancing penguin diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..6a3c299 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; + +export default function NotFound() { + return ( +
+
+
+

404

+

Page Not Found

+

+ Sorry, we couldn't find the page you're looking for. It + might have been moved, deleted, or doesn't exist. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 563ebab..a2c5b35 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,482 +1,5 @@ -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll'; -import { Switch } from '@/components/ui/switch'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import SettingsPage from '@/components/settings/settings-page'; -export default function SettingsPage() { - return ( -
-
- - - Account - Notifications - Calendar - Privacy - Appearance - - - - - - - Account Settings - - Manage your account details and preferences. - - - -
- - -
-
- - -

- Email is managed by your SSO provider. -

-
-
- - -

- Upload a new profile picture. -

-
-
- - -
- -
- - -
-
- -

- Permanently delete your account and all associated data. -

-
-
-
- - - - -
-
- - - - - - Notification Preferences - - Choose how you want to be notified. - - - -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - - -
-
- - - - - - Calendar & Availability - - Manage your calendar display, default availability, and iCal - integrations. - - - -
- - Display - -
- - -
-
- - -
-
- - -
-
- -
- - Availability - -
- -

- Define your typical available hours (e.g., - Monday-Friday, 9 AM - 5 PM). -

- -
-
- -

- Min time before a booking can be made. -

-
- -
-
-
- -

- Max time in advance a booking can be made. -

- -
-
- -
- - iCalendar Integration - -
- - - -
-
- - - -
-
-
-
- - - - -
-
- - - - - - Sharing & Privacy - - Control who can see your calendar and book time with you. - - - -
- - -
-
- -

- (Override for Default Visibility) -
- - This setting will override the default visibility for - your calendar. You can set specific friends or groups to - see your full calendar details. - -

- -
-
- - -
-
- - -

- Prevent specific users from seeing your calendar or - booking time. -

-
-
-
- - - - -
-
- - - - - - Appearance - - Customize the look and feel of the application. - - - -
- - -
-
- - -
-
- - -
-
-
- - - - -
-
-
-
-
- ); +export default function Page() { + return ; } diff --git a/src/auth.ts b/src/auth.ts index 51c2e9c..18b3b2d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -95,13 +95,27 @@ const providers: Provider[] = [ } }, }), - process.env.AUTH_AUTHENTIK_ID && AuthentikProvider, + process.env.AUTH_DISCORD_ID && DiscordProvider, process.env.AUTH_FACEBOOK_ID && FacebookProvider, process.env.AUTH_GITHUB_ID && GithubProvider, process.env.AUTH_GITLAB_ID && GitlabProvider, process.env.AUTH_GOOGLE_ID && GoogleProvider, process.env.AUTH_KEYCLOAK_ID && KeycloakProvider, + + process.env.AUTH_AUTHENTIK_ID && + AuthentikProvider({ + profile(profile) { + return { + id: profile.sub, + name: profile.preferred_username, + first_name: profile.given_name.split(' ')[0] || '', + last_name: profile.given_name.split(' ')[1] || '', + email: profile.email, + image: profile.picture, + }; + }, + }), ].filter(Boolean) as Provider[]; export const providerMap = providers diff --git a/src/components/buttons/icon-button.tsx b/src/components/buttons/icon-button.tsx index 17f9945..4b50e90 100644 --- a/src/components/buttons/icon-button.tsx +++ b/src/components/buttons/icon-button.tsx @@ -1,19 +1,20 @@ import { Button } from '@/components/ui/button'; - -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { LucideProps } from 'lucide-react'; +import React, { ForwardRefExoticComponent, RefAttributes } from 'react'; export function IconButton({ icon, children, ...props }: { - icon: IconProp; - children: React.ReactNode; + icon?: ForwardRefExoticComponent< + Omit & RefAttributes + >; + children?: React.ReactNode; } & React.ComponentProps) { return ( ); diff --git a/src/components/buttons/sso-login-button.tsx b/src/components/buttons/sso-login-button.tsx index 013ef73..b5cde0f 100644 --- a/src/components/buttons/sso-login-button.tsx +++ b/src/components/buttons/sso-login-button.tsx @@ -1,6 +1,6 @@ import { signIn } from '@/auth'; import { IconButton } from '@/components/buttons/icon-button'; -import { faOpenid } from '@fortawesome/free-brands-svg-icons'; +import { Fingerprint } from 'lucide-react'; export default function SSOLogin({ provider, @@ -22,7 +22,7 @@ export default function SSOLogin({ className='w-full' type='submit' variant='secondary' - icon={faOpenid} + icon={Fingerprint} {...props} > Login with {providerDisplayName} diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index e19101a..d77b00a 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'; import { fromZodIssue } from 'zod-validation-error/v4'; import type { $ZodIssue } from 'zod/v4/core'; import { useGetApiCalendar } from '@/generated/api/calendar/calendar'; +import { usePatchApiBlockedSlotsSlotID } from '@/generated/api/blocked-slots/blocked-slots'; moment.updateLocale('en', { week: { @@ -47,6 +48,7 @@ const DaDRBCalendar = withDragAndDrop< end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + organizer?: string; }, { id: string; @@ -190,6 +192,13 @@ function CalendarWithUserEvents({ }, }, }); + const { mutate: patchBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + throwOnError(error) { + throw error.response?.data || 'Failed to update blocked slot'; + }, + }, + }); return ( { - router.push(`/events/${event.id}`); + if (event.type === 'blocked_private') return; + if (event.type === 'blocked_owned') { + router.push(`/blocker/${event.id}`); + return; + } + if (event.type === 'event') { + router.push(`/events/${event.id}`); + } }} onSelectSlot={(slotInfo) => { router.push( @@ -233,56 +250,108 @@ function CalendarWithUserEvents({ resourceTitleAccessor={(event) => event.title} startAccessor={(event) => event.start} endAccessor={(event) => event.end} - selectable={sesstion.data?.user?.id === userId && !additionalEvents} + selectable={sesstion.data?.user?.id === userId} onEventDrop={(event) => { const { start, end, event: droppedEvent } = event; - if (droppedEvent.type === 'blocked_private') return; + if ( + droppedEvent.type === 'blocked_private' || + (droppedEvent.organizer && + droppedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); - patchEvent( - { - eventID: droppedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (droppedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error updating event:', error); + ); + return; + } else if (droppedEvent.type === 'event') { + patchEvent( + { + eventID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating event:', error); + }, + }, + ); + } }} onEventResize={(event) => { const { start, end, event: resizedEvent } = event; - if (resizedEvent.type === 'blocked_private') return; + if ( + resizedEvent.type === 'blocked_private' || + (resizedEvent.organizer && + resizedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); if (startISO === endISO) { console.warn('Start and end times are the same, skipping resize.'); return; } - patchEvent( - { - eventID: resizedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (resizedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error resizing event:', error); + ); + return; + } else if (resizedEvent.type === 'event') { + patchEvent( + { + eventID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing event:', error); + }, + }, + ); + } }} /> ); diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index 50e88c2..cbef9d6 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from '@/components/custom-ui/sidebar'; -import { ChevronDown } from 'lucide-react'; +import { CalendarMinus, CalendarMinus2, ChevronDown } from 'lucide-react'; import { Collapsible, CollapsibleContent, @@ -28,8 +28,8 @@ import Link from 'next/link'; import { Star, CalendarDays, - User, - Users, + //User, + //Users, CalendarClock, CalendarPlus, } from 'lucide-react'; @@ -40,7 +40,7 @@ const items = [ url: '/home', icon: CalendarDays, }, - { + /*{ title: 'Friends', url: '#', icon: User, @@ -49,12 +49,17 @@ const items = [ title: 'Groups', url: '#', icon: Users, - }, + },*/ { title: 'Events', url: '/events', icon: CalendarClock, }, + { + title: 'Blockers', + url: '/blocker', + icon: CalendarMinus, + }, ]; export function AppSidebar() { @@ -62,25 +67,27 @@ export function AppSidebar() { <> - - + + + + - - + + {' '} Favorites @@ -123,6 +130,17 @@ export function AppSidebar() { + + + + + New Blocker + + + diff --git a/src/components/custom-ui/blocked-slot-list-entry.tsx b/src/components/custom-ui/blocked-slot-list-entry.tsx new file mode 100644 index 0000000..9d1acdf --- /dev/null +++ b/src/components/custom-ui/blocked-slot-list-entry.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import Logo from '@/components/misc/logo'; +import { Label } from '@/components/ui/label'; +import Link from 'next/link'; +import zod from 'zod/v4'; +import { BlockedSlotsSchema } from '@/app/api/blocked_slots/validation'; + +type BlockedSlotListEntryProps = zod.output; + +export default function BlockedSlotListEntry(slot: BlockedSlotListEntryProps) { + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + return ( + + +
+
+ +
+
+

{slot.reason}

+
+
+
+ + +
+
+ + +
+
+
+
+ + ); +} diff --git a/src/components/custom-ui/event-list-entry.tsx b/src/components/custom-ui/event-list-entry.tsx index edc4a2f..b52d438 100644 --- a/src/components/custom-ui/event-list-entry.tsx +++ b/src/components/custom-ui/event-list-entry.tsx @@ -43,7 +43,10 @@ export default function EventListEntry({ return ( -
+
diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 4746a31..38a6c56 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -1,29 +1,64 @@ import { Input, Textarea } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import React, { ForwardRefExoticComponent, RefAttributes } from 'react'; +import { Button } from '../ui/button'; +import { Eye, EyeOff, LucideProps } from 'lucide-react'; +import { cn } from '@/lib/utils'; export default function LabeledInput({ type, label, + subtext, placeholder, value, + defaultValue, name, + icon, variantSize = 'default', autocomplete, error, + 'data-cy': dataCy, ...rest }: { - type: 'text' | 'email' | 'password'; label: string; + subtext?: string; placeholder?: string; value?: string; name?: string; + icon?: ForwardRefExoticComponent< + Omit & RefAttributes + >; variantSize?: 'default' | 'big' | 'textarea'; autocomplete?: string; error?: string; + 'data-cy'?: string; } & React.InputHTMLAttributes) { + const [passwordVisible, setPasswordVisible] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + value || defaultValue || '', + ); + + React.useEffect(() => { + if (value !== undefined) { + setInputValue(value); + } + }, [value]); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + if (rest.onChange) { + rest.onChange(e); + } + }; + return (
+ {subtext && ( + + )} {variantSize === 'textarea' ? (