From 72c3f6c17f274ca4f2edb53c70af3b74265f84aa Mon Sep 17 00:00:00 2001 From: Micha Date: Fri, 27 Jun 2025 09:33:05 +0200 Subject: [PATCH 01/49] style: update calendar styling to to make it pretty in both light- and dark-mode --- src/components/custom-toolbar.css | 2 +- src/components/react-big-calendar.css | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css index 3fba69f..9721364 100644 --- a/src/components/custom-toolbar.css +++ b/src/components/custom-toolbar.css @@ -5,7 +5,7 @@ gap: 12px; padding: calc(var(--spacing) * 2); padding-left: calc(50px + var(--spacing)); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-shadow: none; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } diff --git a/src/components/react-big-calendar.css b/src/components/react-big-calendar.css index 675898c..ee7afdd 100644 --- a/src/components/react-big-calendar.css +++ b/src/components/react-big-calendar.css @@ -83,7 +83,7 @@ button.rbc-input::-moz-focus-inner { } .rbc-off-range-bg { - background: #e6e6e6; + background: var(--color-neutral-700); } .rbc-header { @@ -596,7 +596,7 @@ button.rbc-input::-moz-focus-inner { min-height: 100%; /*Own changes 06*/ - background-color: #383838; + background-color: var(--color-neutral-700); /*Own changes 06*/ } .rbc-time-column .rbc-timeslot-group { @@ -606,7 +606,7 @@ button.rbc-input::-moz-focus-inner { } .rbc-timeslot-group { - border-bottom: 1px solid #8d8d8d; /*#ddd*/ + border-bottom: 1px solid var(--color-neutral-300); /*#ddd*/ min-height: 40px; display: -webkit-box; display: -ms-flexbox; @@ -624,7 +624,7 @@ button.rbc-input::-moz-focus-inner { flex: none; /*Own changes 07*/ - background-color: #8d8d8d; + background-color: var(--color-neutral-500); /*Own changes 07*/ } @@ -686,7 +686,7 @@ button.rbc-input::-moz-focus-inner { min-height: 1em; } .rbc-day-slot .rbc-time-slot { - border-top: 1px solid #383838; /*#f7f7f7*/ + border-top: 1px solid transparent; /*#f7f7f7*/ } .rbc-time-view-resources .rbc-time-gutter, @@ -782,7 +782,7 @@ button.rbc-input::-moz-focus-inner { position: relative; /*Own changes 05*/ - background-color: #555555; + background-color: var(--color-neutral-500); /*Own changes 05*/ } .rbc-time-view .rbc-allday-cell + .rbc-allday-cell { @@ -872,7 +872,7 @@ button.rbc-input::-moz-focus-inner { } .rbc-time-header-content { - border-bottom: 2px solid #717171; /*#ddd*/ + border-bottom: 2px solid var(--color-neutral-400); /*#ddd*/ } .rbc-time-column :last-child { @@ -890,7 +890,7 @@ button.rbc-input::-moz-focus-inner { /*Own changes 09*/ } .rbc-time-content > * + * > * { - border-left: 1px solid #c6c6c6; /*#ddd*/ + border-left: 1px solid var(--color-neutral-300); /*#ddd*/ } .rbc-rtl .rbc-time-content > * + * > * { border-left-width: 0; From 6c734f2d19d8f1839f44b62859dc13630203e128 Mon Sep 17 00:00:00 2001 From: Micha Date: Fri, 27 Jun 2025 10:06:57 +0200 Subject: [PATCH 02/49] style: improve layout and responsiveness of custom toolbar --- src/app/(main)/home/page.tsx | 12 +++++++----- src/components/custom-toolbar.css | 20 +++++++++++++++++--- src/components/custom-toolbar.tsx | 7 ++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 1cf8a90..a3f904d 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -7,11 +7,13 @@ export default function Home() { const { data } = useGetApiUserMe(); return ( -
- +
+
+ +
); } diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css index 9721364..37a2a4d 100644 --- a/src/components/custom-toolbar.css +++ b/src/components/custom-toolbar.css @@ -1,10 +1,14 @@ /* Container der Toolbar */ .custom-toolbar { display: flex; - flex-direction: column; - gap: 12px; + gap: 8px; padding: calc(var(--spacing) * 2); padding-left: calc(50px + var(--spacing)); + + @media (max-width: 870px) { + padding-left: 0; + flex-direction: column; + } box-shadow: none; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } @@ -25,6 +29,7 @@ display: flex; gap: 8px; justify-content: center; + align-items: center; } .custom-toolbar .navigation-controls button { @@ -77,6 +82,8 @@ border-radius: 11px; justify-items: center; align-items: center; + display: flex; + gap: 8px; } .custom-toolbar .navigation-controls .handleWeek button { @@ -93,6 +100,9 @@ padding: 0 8px; border-radius: 11px; justify-items: center; + display: flex; + justify-content: center; + align-items: center; } .right-section .datepicker-box { @@ -100,8 +110,12 @@ background-color: #c6c6c6; height: 36px; border-radius: 11px; - font-size: 12px; + font-size: 14px; align-self: center; + font-family: 'Varela Round', sans-serif; + display: flex; + align-items: center; + justify-content: center; } .datepicker { diff --git a/src/components/custom-toolbar.tsx b/src/components/custom-toolbar.tsx index 36c8fff..76e59ee 100644 --- a/src/components/custom-toolbar.tsx +++ b/src/components/custom-toolbar.tsx @@ -171,12 +171,9 @@ const CustomToolbar: React.FC = ({ }; return ( -
+
-
+
- - - - Delete Event - - Are you sure you want to delete the event “ - {event.title}”? This action cannot be undone. - - - - - - - - - ) : null} +
+ + +
+
+ + +
+
+
+ +
-
- {session.data?.user?.id === event.organizer.id ? ( - - ) : null} +
+ +
+
+
+
+
+ + +
+
+
+ + +
+
+
+ {' '} +
+ {event.participants?.map((user) => ( + + ))} +
+
+
+ +
+
+ {session.data?.user?.id === event.organizer.id ? ( + + + + + + + Delete Event + + Are you sure you want to delete the event “ + {event.title}”? This action cannot be undone. + + + + + + + + + ) : null} +
+
+ {session.data?.user?.id === event.organizer.id ? ( + + ) : null} +
+
- - -
+
+ + ); } diff --git a/src/app/(main)/events/edit/[eventID]/page.tsx b/src/app/(main)/events/edit/[eventID]/page.tsx index 42c6e8b..b099f10 100644 --- a/src/app/(main)/events/edit/[eventID]/page.tsx +++ b/src/app/(main)/events/edit/[eventID]/page.tsx @@ -9,16 +9,14 @@ export default async function Page({ }) { const eventID = (await params).eventID; return ( -
- - + + - - - - - - -
+ + + + + + ); } diff --git a/src/app/api/user/[user]/calendar/route.ts b/src/app/api/calendar/route.ts similarity index 73% rename from src/app/api/user/[user]/calendar/route.ts rename to src/app/api/calendar/route.ts index 62142e9..fa6dd47 100644 --- a/src/app/api/user/[user]/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -15,7 +15,7 @@ import { } from '@/app/api/validation'; import { z } from 'zod/v4'; -export const GET = auth(async function GET(req, { params }) { +export const GET = auth(async function GET(req) { const authCheck = userAuthenticated(req); if (!authCheck.continue) return returnZodTypeCheckedResponse( @@ -24,7 +24,22 @@ export const GET = auth(async function GET(req, { params }) { authCheck.metadata, ); - const dataRaw = Object.fromEntries(new URL(req.url).searchParams); + 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 userCalendarQuerySchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( @@ -36,15 +51,15 @@ export const GET = auth(async function GET(req, { params }) { }, { status: 400 }, ); - const { end, start } = data.data; + const { end, start, userIds } = data.data; const requestUserId = authCheck.user.id; - const requestedUserId = (await params).user; - - const requestedUser = await prisma.user.findFirst({ + const requestedUser = await prisma.user.findMany({ where: { - id: requestedUserId, + id: { + in: userIds, + }, }, select: { meetingParts: { @@ -64,6 +79,7 @@ export const GET = auth(async function GET(req, { params }) { }, }, select: { + user_id: true, meeting: { select: { id: true, @@ -136,6 +152,7 @@ export const GET = auth(async function GET(req, { params }) { start_time: 'asc', }, select: { + user_id: true, id: true, reason: true, start_time: true, @@ -153,46 +170,64 @@ export const GET = auth(async function GET(req, { params }) { if (!requestedUser) return returnZodTypeCheckedResponse( ErrorResponseSchema, - { success: false, message: 'User not found' }, + { success: false, message: 'User/s not found' }, { status: 404 }, ); const calendar: z.input = []; - for (const event of requestedUser.meetingParts) { + for (const event of requestedUser.map((r) => r.meetingParts).flat()) { if ( event.meeting.participants.some((p) => p.user.id === requestUserId) || event.meeting.organizer_id === requestUserId ) { - calendar.push({ ...event.meeting, type: 'event' }); + calendar.push({ + ...event.meeting, + type: 'event', + users: event.meeting.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), + }); } else { calendar.push({ id: event.meeting.id, start_time: event.meeting.start_time, end_time: event.meeting.end_time, type: 'blocked_private', + users: event.meeting.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), }); } } - for (const event of requestedUser.meetingsOrg) { + for (const event of requestedUser.map((r) => r.meetingsOrg).flat()) { if ( event.participants.some((p) => p.user.id === requestUserId) || event.organizer_id === requestUserId ) { - calendar.push({ ...event, type: 'event' }); + calendar.push({ + ...event, + type: 'event', + users: event.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), + }); } else { calendar.push({ id: event.id, start_time: event.start_time, end_time: event.end_time, type: 'blocked_private', + users: event.participants + .map((p) => p.user.id) + .filter((id) => userIds.includes(id)), }); } } - for (const slot of requestedUser.blockedSlots) { - if (requestUserId === requestedUserId) { + for (const slot of requestedUser.map((r) => r.blockedSlots).flat()) { + if (requestUserId === userIds[0] && userIds.length === 1) { calendar.push({ start_time: slot.start_time, end_time: slot.end_time, @@ -204,6 +239,7 @@ export const GET = auth(async function GET(req, { params }) { created_at: slot.created_at, updated_at: slot.updated_at, type: 'blocked_owned', + users: [requestUserId], }); } else { calendar.push({ @@ -211,6 +247,7 @@ export const GET = auth(async function GET(req, { params }) { end_time: slot.end_time, id: slot.id, type: 'blocked_private', + users: [slot.user_id], }); } } diff --git a/src/app/api/user/[user]/calendar/swagger.ts b/src/app/api/calendar/swagger.ts similarity index 77% rename from src/app/api/user/[user]/calendar/swagger.ts rename to src/app/api/calendar/swagger.ts index fb48629..b4f5898 100644 --- a/src/app/api/user/[user]/calendar/swagger.ts +++ b/src/app/api/calendar/swagger.ts @@ -7,17 +7,12 @@ import { userNotFoundResponse, } from '@/lib/defaultApiResponses'; import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; -import zod from 'zod/v4'; -import { UserIdParamSchema } from '@/app/api/validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ method: 'get', - path: '/api/user/{user}/calendar', + path: '/api/calendar', request: { - params: zod.object({ - user: UserIdParamSchema, - }), query: userCalendarQuerySchema, }, responses: { @@ -32,6 +27,6 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) { ...notAuthenticatedResponse, ...userNotFoundResponse, }, - tags: ['User'], + tags: ['Calendar'], }); } diff --git a/src/app/api/user/[user]/calendar/validation.ts b/src/app/api/calendar/validation.ts similarity index 91% rename from src/app/api/user/[user]/calendar/validation.ts rename to src/app/api/calendar/validation.ts index 1572793..5bf45a6 100644 --- a/src/app/api/user/[user]/calendar/validation.ts +++ b/src/app/api/calendar/validation.ts @@ -14,6 +14,8 @@ export const BlockedSlotSchema = zod end_time: eventEndTimeSchema, type: zod.literal('blocked_private'), id: zod.string(), + users: zod.string().array(), + user_id: zod.string().optional(), }) .openapi('BlockedSlotSchema', { description: 'Blocked time slot in the user calendar', @@ -31,17 +33,21 @@ export const OwnedBlockedSlotSchema = zod created_at: zod.date().nullish(), updated_at: zod.date().nullish(), type: zod.literal('blocked_owned'), + users: zod.string().array(), + user_id: zod.string().optional(), }) .openapi('OwnedBlockedSlotSchema', { description: 'Blocked slot owned by the user', }); export const VisibleSlotSchema = EventSchema.omit({ - organizer: true, participants: true, + organizer: true, }) .extend({ type: zod.literal('event'), + users: zod.string().array(), + user_id: zod.string().optional(), }) .openapi('VisibleSlotSchema', { description: 'Visible time slot in the user calendar', @@ -86,6 +92,7 @@ export const userCalendarQuerySchema = zod ); return endOfWeek; }), + userIds: zod.string().array(), }) .openapi('UserCalendarQuerySchema', { description: 'Query parameters for filtering the user calendar', diff --git a/src/app/api/search/user/route.ts b/src/app/api/search/user/route.ts index a8b6414..0bcb6cf 100644 --- a/src/app/api/search/user/route.ts +++ b/src/app/api/search/user/route.ts @@ -19,7 +19,7 @@ export const GET = auth(async function GET(req) { authCheck.metadata, ); - const dataRaw = Object.fromEntries(new URL(req.url).searchParams); + const dataRaw = Object.fromEntries(req.nextUrl.searchParams); const data = await searchUserSchema.safeParseAsync(dataRaw); if (!data.success) return returnZodTypeCheckedResponse( diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index a8d6005..ba9e730 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -7,7 +7,6 @@ import '@/components/react-big-calendar.css'; import 'react-big-calendar/lib/addons/dragAndDrop/styles.css'; import CustomToolbar from '@/components/custom-toolbar'; import React from 'react'; -import { useGetApiUserUserCalendar } from '@/generated/api/user/user'; import { useRouter } from 'next/navigation'; import { usePatchApiEventEventID } from '@/generated/api/event/event'; import { useSession } from 'next-auth/react'; @@ -17,6 +16,11 @@ import { ErrorBoundary } from 'react-error-boundary'; 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 { +// generateColor, +// generateSecondaryColor, +//} from '@marko19907/string-to-color'; moment.updateLocale('en', { week: { @@ -25,12 +29,31 @@ moment.updateLocale('en', { }, }); +function eventPropGetter() { + // event: { + // id: string; + // start: Date; + // end: Date; + // type: UserCalendarSchemaItem['type']; + // userId?: string; + // } + return { + // style: { + // backgroundColor: generateColor(event.userId || 'defaultColor', { + // saturation: 0.7, + // lightness: 0.5, + // }), + // }, + }; +} + const DaDRBCalendar = withDragAndDrop< { id: string; start: Date; end: Date; type: UserCalendarSchemaItem['type']; + userId?: string; }, { id: string; @@ -44,9 +67,20 @@ const localizer = momentLocalizer(moment); export default function Calendar({ userId, height, + additionalEvents = [], + className, }: { - userId?: string; + userId?: string | string[]; height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + }[]; + className?: string; }) { return ( @@ -67,10 +101,26 @@ export default function Calendar({
)} > - {userId ? ( - + {typeof userId === 'string' ? ( + + ) : Array.isArray(userId) && userId.length > 0 ? ( + ) : ( - + )} )} @@ -81,9 +131,20 @@ export default function Calendar({ function CalendarWithUserEvents({ userId, height, + additionalEvents, + className, }: { userId: string; height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + }[]; + className?: string; }) { const sesstion = useSession(); const [currentView, setCurrentView] = React.useState< @@ -92,9 +153,9 @@ function CalendarWithUserEvents({ const [currentDate, setCurrentDate] = React.useState(new Date()); const router = useRouter(); - const { data, refetch, error, isError } = useGetApiUserUserCalendar( - userId, + const { data, refetch, error, isError } = useGetApiCalendar( { + userIds: [userId, userId + '_blocked'], start: moment(currentDate) .startOf( currentView === 'agenda' @@ -137,6 +198,8 @@ function CalendarWithUserEvents({ return ( { setCurrentDate(date); }} - events={ - data?.data.calendar.map((event) => ({ + events={[ + ...(data?.data.calendar.map((event) => ({ id: event.id, title: event.type === 'event' ? event.title : 'Blocker', start: new Date(event.start_time), end: new Date(event.end_time), type: event.type, - })) ?? [] - } + userId: event.users[0], + })) ?? []), + ...(additionalEvents ?? []), + ]} onSelectEvent={(event) => { router.push(`/events/${event.id}`); }} @@ -228,7 +293,114 @@ function CalendarWithUserEvents({ ); } -function CalendarWithoutUserEvents({ height }: { height: string }) { +function CalendarWithMultiUserEvents({ + userIds, + height, + additionalEvents, + className, +}: { + userIds: string[]; + height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + }[]; + className?: string; +}) { + const [currentView, setCurrentView] = React.useState< + 'month' | 'week' | 'day' | 'agenda' | 'work_week' + >('week'); + const [currentDate, setCurrentDate] = React.useState(new Date()); + + const { data, error, isError } = useGetApiCalendar( + { + userIds: userIds, + start: moment(currentDate) + .startOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + end: moment(currentDate) + .endOf( + currentView === 'agenda' + ? 'month' + : currentView === 'work_week' + ? 'week' + : currentView, + ) + .toISOString(), + }, + { + query: { + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: true, + }, + }, + ); + + if (isError) { + throw error.response?.data || 'Failed to fetch calendar data'; + } + + return ( + { + setCurrentDate(date); + }} + events={[ + ...(data?.data.calendar.map((event) => ({ + id: event.id, + title: event.type === 'event' ? event.title : 'Blocker', + start: new Date(event.start_time), + end: new Date(event.end_time), + type: event.type, + userId: event.users[0], + })) ?? []), + ...(additionalEvents ?? []), + ]} + /> + ); +} + +function CalendarWithoutUserEvents({ + height, + additionalEvents, + className, +}: { + height: string; + additionalEvents?: { + id: string; + title: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + }[]; + className?: string; +}) { const [currentView, setCurrentView] = React.useState< 'month' | 'week' | 'day' | 'agenda' | 'work_week' >('week'); @@ -236,6 +408,8 @@ function CalendarWithoutUserEvents({ height }: { height: string }) { return ( { setCurrentDate(date); }} + events={additionalEvents} /> ); } diff --git a/src/components/custom-toolbar.css b/src/components/custom-toolbar.css index 37a2a4d..1230384 100644 --- a/src/components/custom-toolbar.css +++ b/src/components/custom-toolbar.css @@ -32,6 +32,11 @@ align-items: center; } +.custom-toolbar .navigation-controls .handleWeek { + display: grid; + grid-template-columns: 1fr 1fr; +} + .custom-toolbar .navigation-controls button { padding: 8px 12px; color: #ffffff; diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index f823970..4188141 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -75,7 +75,7 @@ export function AppSidebar() { className='group-data-[collapsible=]:hidden group-data-[mobile=true]/mobile:hidden' > - + diff --git a/src/components/forms/event-form.tsx b/src/components/forms/event-form.tsx index a445080..00322cc 100644 --- a/src/components/forms/event-form.tsx +++ b/src/components/forms/event-form.tsx @@ -21,6 +21,16 @@ import { useSearchParams } from 'next/navigation'; import zod from 'zod/v4'; import { PublicUserSchema } from '@/app/api/user/validation'; +import Calendar from '@/components/calendar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../ui/dialog'; type User = zod.output; @@ -68,6 +78,8 @@ const EventForm: React.FC = (props) => { const [location, setLocation] = React.useState(''); const [description, setDescription] = React.useState(''); + const [calendarOpen, setCalendarOpen] = React.useState(false); + // Update state when event data loads React.useEffect(() => { if (props.type === 'edit' && event) { @@ -194,149 +206,182 @@ const EventForm: React.FC = (props) => { return
Error loading event.
; return ( -
-
-
-
- -
-
- setTitle(e.target.value)} - /> -
-
-
-
-
- -
-
- -
-
- setLocation(e.target.value)} - /> -
-
-
- - + <> + + +
+
+
+ +
+
+ setTitle(e.target.value)} + /> +
+
-
- -

- {updatedAtDisplay} -

-
-
-
-
-
-
-
- - +
+
+ +
+
+ +
+
+ setLocation(e.target.value)} + /> +
+
+
+ + +
+
+ +

+ {updatedAtDisplay} +

+
-
- setDescription(e.target.value)} - > -
-
-
- - { - setSelectedParticipants((current) => - current.find((u) => u.id === user.id) - ? current - : [...current, user], - ); - }} - removeUserAction={(user) => { - setSelectedParticipants((current) => - current.filter((u) => u.id !== user.id), - ); - }} - /> -
- {selectedParticipants.map((user) => ( - +
+
+
+ + +
+
+
+ setDescription(e.target.value)} + > +
+
+
+ + { + setSelectedParticipants((current) => + current.find((u) => u.id === user.id) + ? current + : [...current, user], + ); + }} + removeUserAction={(user) => { + setSelectedParticipants((current) => + current.filter((u) => u.id !== user.id), + ); + }} /> - ))} + + + +
+ {selectedParticipants.map((user) => ( + + ))} +
+
-
-
-
-
- +
+
+ +
+
+ +
+
+ {isSuccess &&

Event created!

} + {error &&

Error: {error.message}

}
-
- -
-
- {isSuccess &&

Event created!

} - {error &&

Error: {error.message}

} -
- + + + + Calendar + + Calendar for selected participants + + + + u.id)} + additionalEvents={[ + { + id: 'temp-event', + title: title || 'New Event', + start: startDate ? new Date(startDate) : new Date(), + end: endDate ? new Date(endDate) : new Date(), + type: 'event', + userId: 'create-event', + }, + ]} + height='600px' + /> + + + + ); }; diff --git a/yarn.lock b/yarn.lock index 816d896..b241026 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,6 +1013,15 @@ __metadata: languageName: node linkType: hard +"@marko19907/string-to-color@npm:^1.0.0": + version: 1.0.0 + resolution: "@marko19907/string-to-color@npm:1.0.0" + dependencies: + esm-seedrandom: "npm:^3.0.5" + checksum: 10c0/fd297937fb5a8a0c3372bc614f9a4e21842e3392ef54e0d323f0fe730ec5b5a441f921e63b3b151bd39ca64a120ddfe905d1a67ca62ed637ade69c96c55fe641 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.11 resolution: "@napi-rs/wasm-runtime@npm:0.2.11" @@ -5717,6 +5726,13 @@ __metadata: languageName: node linkType: hard +"esm-seedrandom@npm:^3.0.5": + version: 3.0.5 + resolution: "esm-seedrandom@npm:3.0.5" + checksum: 10c0/6fe5a33f31bce0e733814df884cdfc27812e4b04ce973f7cf008b7b8500ecab6706f54591841bd12dd3bdd9bbb153abf04fb8efd4934a5b0ab273bff114cdb3b + languageName: node + linkType: hard + "espree@npm:^10.0.1, espree@npm:^10.4.0": version: 10.4.0 resolution: "espree@npm:10.4.0" @@ -7683,6 +7699,7 @@ __metadata: "@fortawesome/free-solid-svg-icons": "npm:^6.7.2" "@fortawesome/react-fontawesome": "npm:^0.2.2" "@hookform/resolvers": "npm:^5.0.1" + "@marko19907/string-to-color": "npm:^1.0.0" "@prisma/client": "npm:^6.9.0" "@radix-ui/react-avatar": "npm:^1.1.10" "@radix-ui/react-collapsible": "npm:^1.1.11" From 1cb2019298795d513b520b14e558c4ad8907959c Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Sun, 29 Jun 2025 18:32:32 +0200 Subject: [PATCH 13/49] feat(event): setting user status --- src/app/(main)/home/page.tsx | 2 +- src/components/calendar.tsx | 35 ++++++------ src/components/custom-ui/app-sidebar.tsx | 6 +-- src/components/custom-ui/event-list-entry.tsx | 54 +++++++++++++++++++ .../custom-ui/participant-list-entry.tsx | 2 + src/components/forms/event-form.tsx | 1 + 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 38237f6..69e5be6 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -22,7 +22,7 @@ export default function Home() {
diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index ba9e730..9d8e255 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -17,10 +17,6 @@ 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 { -// generateColor, -// generateSecondaryColor, -//} from '@marko19907/string-to-color'; moment.updateLocale('en', { week: { @@ -29,21 +25,18 @@ moment.updateLocale('en', { }, }); -function eventPropGetter() { - // event: { - // id: string; - // start: Date; - // end: Date; - // type: UserCalendarSchemaItem['type']; - // userId?: string; - // } +function eventPropGetter(event: { + id: string; + start: Date; + end: Date; + type: UserCalendarSchemaItem['type']; + userId?: string; + colorOverride?: string; +}) { return { - // style: { - // backgroundColor: generateColor(event.userId || 'defaultColor', { - // saturation: 0.7, - // lightness: 0.5, - // }), - // }, + style: event.colorOverride + ? { backgroundColor: event.colorOverride } + : undefined, }; } @@ -79,6 +72,7 @@ export default function Calendar({ end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + colorOverride?: string; }[]; className?: string; }) { @@ -143,6 +137,7 @@ function CalendarWithUserEvents({ end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + colorOverride?: string; }[]; className?: string; }) { @@ -238,7 +233,7 @@ function CalendarWithUserEvents({ resourceTitleAccessor={(event) => event.title} startAccessor={(event) => event.start} endAccessor={(event) => event.end} - selectable={sesstion.data?.user?.id === userId} + selectable={sesstion.data?.user?.id === userId && !additionalEvents} onEventDrop={(event) => { const { start, end, event: droppedEvent } = event; if (droppedEvent.type === 'blocked_private') return; @@ -308,6 +303,7 @@ function CalendarWithMultiUserEvents({ end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + colorOverride?: string; }[]; className?: string; }) { @@ -398,6 +394,7 @@ function CalendarWithoutUserEvents({ end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + colorOverride?: string; }[]; className?: string; }) { diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index 4188141..50e88c2 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -37,7 +37,7 @@ import { const items = [ { title: 'Calendar', - url: '#', + url: '/home', icon: CalendarDays, }, { @@ -52,7 +52,7 @@ const items = [ }, { title: 'Events', - url: '#', + url: '/events', icon: CalendarClock, }, ]; @@ -114,7 +114,7 @@ export function AppSidebar() { diff --git a/src/components/custom-ui/event-list-entry.tsx b/src/components/custom-ui/event-list-entry.tsx index 197649b..edc4a2f 100644 --- a/src/components/custom-ui/event-list-entry.tsx +++ b/src/components/custom-ui/event-list-entry.tsx @@ -1,9 +1,20 @@ +'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 { EventSchema } from '@/app/api/event/validation'; +import { useSession } from 'next-auth/react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { usePatchApiEventEventIDParticipantUser } from '@/generated/api/event-participant/event-participant'; type EventListEntryProps = zod.output; @@ -13,7 +24,11 @@ export default function EventListEntry({ start_time, end_time, location, + participants, }: EventListEntryProps) { + const session = useSession(); + const updateAttendance = usePatchApiEventEventIDParticipantUser(); + const formatDate = (isoString?: string) => { if (!isoString) return '-'; return new Date(isoString).toLocaleDateString(); @@ -60,6 +75,45 @@ export default function EventListEntry({
)} + {participants && + participants.some( + (p) => p.user.id === session.data?.user?.id, + ) && ( +
+ +
+ )}
diff --git a/src/components/custom-ui/participant-list-entry.tsx b/src/components/custom-ui/participant-list-entry.tsx index 27827cf..2ec9c02 100644 --- a/src/components/custom-ui/participant-list-entry.tsx +++ b/src/components/custom-ui/participant-list-entry.tsx @@ -10,6 +10,7 @@ type ParticipantListEntryProps = zod.output; export default function ParticipantListEntry({ user, + status, }: ParticipantListEntryProps) { const { resolvedTheme } = useTheme(); const defaultImage = @@ -21,6 +22,7 @@ export default function ParticipantListEntry({
Avatar {user.name} + {status}
); } diff --git a/src/components/forms/event-form.tsx b/src/components/forms/event-form.tsx index 00322cc..2a2912c 100644 --- a/src/components/forms/event-form.tsx +++ b/src/components/forms/event-form.tsx @@ -374,6 +374,7 @@ const EventForm: React.FC = (props) => { end: endDate ? new Date(endDate) : new Date(), type: 'event', userId: 'create-event', + colorOverride: '#ff9800', }, ]} height='600px' From 9f0a5813bed06d471322ecf1fe42db7ddd179f9d Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Sun, 29 Jun 2025 19:10:05 +0200 Subject: [PATCH 14/49] feat(calendar): hide declined events --- src/app/api/calendar/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts index fa6dd47..440bbd7 100644 --- a/src/app/api/calendar/route.ts +++ b/src/app/api/calendar/route.ts @@ -72,6 +72,9 @@ export const GET = auth(async function GET(req) { gte: start, }, }, + status: { + not: 'DECLINED', + }, }, orderBy: { meeting: { From f6b094bbb21592d954148eeb217a6bb1bf1978cf Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 08:54:43 +0200 Subject: [PATCH 15/49] fix: remove testing user id --- src/components/calendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index 9d8e255..e19101a 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -150,7 +150,7 @@ function CalendarWithUserEvents({ const { data, refetch, error, isError } = useGetApiCalendar( { - userIds: [userId, userId + '_blocked'], + userIds: [userId], start: moment(currentDate) .startOf( currentView === 'agenda' From 1d17f7329ef895ea53a2f4736e2b8025408355f5 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 09:49:10 +0200 Subject: [PATCH 16/49] chore: update readme with up to date infos --- README.md | 219 +++++++++++++++++++++++++++++------------------------- 1 file changed, 117 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 56fa41d..24990d2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ # MeetUP +## Table of contents + +- [Description](#description) +- [Project Status](#project-status) +- [Features](#features) + - [Implemented Features](#implemented-features) + - [Planned Features](#planned-features-roadmap) +- [Technologies Used](#technologies-used) +- [Development environment setup](#development-environment-setup) + - [Without Docker](#without-docker) + - [With Docker](#with-docker) +- [Production Deployment using Docker](#production-deployment-using-docker) +- [Contributing](#contributing) + ## Description MeetUP is a social calendar application designed to make coordinating schedules with friends seamless and intuitive. It was created because it can be a hassle coordinating meetings between multiple friends and across different friend groups. MeetUP aims to simplify the process of finding mutual availability without endless back-and-forth messaging. @@ -12,52 +26,54 @@ MeetUP is a social calendar application designed to make coordinating schedules ### Implemented Features -- Core infrastructure setup in progress. No user-facing features are implemented yet. +- Event creation, deletion, and editing +- SSO and credentials login and signup +- Participant invitation and status +- Calendar of your own events +- Calendar of other users' availability (only in event creation form) ### Planned Features (Roadmap) -- **Friendships:** Connect with friends to share calendars. -- **Group Calendars:** Create and manage shared calendars for groups. -- **iCal Import:** Import existing calendars from iCalendar (.ics) files. -- **iCal Export:** Export personal or shared calendars in iCalendar (.ics) format. -- **Email Notifications:** Receive email alerts for event bookings, reminders, and updates. -- **View Blocked Slots:** See when friends are busy without revealing event details. -- **Book Timeslots:** Request and confirm meeting times in friends' available slots. -- **SSO Compatibility:** Planning for Single Sign-On integration. +- Friendships +- Group calendars +- iCal import and export +- Notifications (in-app and external/mail) ## Technologies Used This project is built with a modern tech stack: - **Package Manager:** [Yarn](https://yarnpkg.com/) -- **Framework:** [Next.js](https://nextjs.org/) - React framework for server-side rendering and static site generation. -- **Language:** [TypeScript](https://www.typescriptlang.org/) - Superset of JavaScript that adds static typing. -- **ORM:** [Prisma](https://www.prisma.io/) - Next-generation ORM for Node.js and TypeScript. -- **Authentication:** [Auth.js](https://authjs.dev/) (formerly NextAuth.js) - Authentication for Next.js. -- **Styling:** [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework. -- **UI Components:** [shadcn/ui](https://ui.shadcn.com/) - Re-usable components built using Radix UI and Tailwind CSS. -- **Containerization:** [Docker](https://www.docker.com/) (for planned self-hosting option) -- _(You can also list related tools here, e.g., ESLint, Prettier, testing libraries if you plan to use them)_ +- **Framework:** [Next.js](https://nextjs.org/) +- **Language:** [TypeScript](https://www.typescriptlang.org/) +- **ORM:** [Prisma](https://www.prisma.io/) +- **Authentication:** [Auth.js](https://authjs.dev/) +- **Styling:** [Tailwind CSS](https://tailwindcss.com/) +- **UI Components:** [shadcn/ui](https://ui.shadcn.com/) +- **Containerization:** [Docker](https://www.docker.com/) +- **API Docs:** [Swagger](https://swagger.io/) +- **React hook API client:** [orval](https://orval.dev/) -## Getting Started +## Development environment setup + +### Without Docker **Prerequisites:** -- Node.js: Version is continually upgraded. It's recommended to use the latest LTS or a recent stable version. (Check `.nvmrc` if available). -- Yarn: Version is continually upgraded. (Check `package.json` engines field if specified). -- A database supported by Prisma (e.g., PostgreSQL, MySQL, SQLite). Ensure your database server is running. +- **Node.js**: version 22+ +- **corepack**: enable using `corepack enable` -**Installation & Running Locally:** +**Installation & Running:** 1. **Clone the repository:** - - Using SSH: + - Using HTTPS: + ```bash + git clone https://git.dominikstahl.dev/DHBW-WE/MeetUp.git + ``` + - Or using SSH: ```bash git clone ssh://git@git.dominikstahl.dev/DHBW-WE/MeetUp.git ``` - - Or using HTTPS (recommended for most users): - ```bash - git clone [https://git.dominikstahl.dev/DHBW-WE/MeetUp.git](https://git.dominikstahl.dev/DHBW-WE/MeetUp.git) - ``` ```bash cd MeetUp ``` @@ -66,53 +82,44 @@ This project is built with a modern tech stack: yarn install ``` 3. **Set up environment variables:** - - You will need to create an `AUTH_SECRET`. You can generate one using the following command: - ```bash - npx auth secret - ``` - - Copy the `.env.example` file (if it exists) to `.env.local`. If not, create `.env.local`. - ```bash - # If .env.example exists: - cp .env.example .env.local - # Otherwise, create .env.local and add the following: - ``` - - Ensure the following environment variables are set in your `.env.local` file. Adjust `DATABASE_URL` for your specific database provider and credentials. + You will need to create an `AUTH_SECRET`. You can generate one using the following command: - ```env - # Database Connection String (Prisma) - # Example for PostgreSQL: DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" - DATABASE_URL="your_database_connection_string" + ```bash + npx auth secret + ``` - # Generated with npx auth secret - AUTH_SECRET="your_generated_auth_secret" + Add any additional needed environment variables into the generated `.env.local` file. + Example variables can be found in the `.env.example` file. The following variables are required: - # Authentik SSO Variables (if you are using this provider) - AUTH_AUTHENTIK_ID= - AUTH_AUTHENTIK_SECRET= - AUTH_AUTHENTIK_ISSUER= + ```env + # Generated with npx auth secret + AUTH_SECRET="your_generated_auth_secret" - # Base URL of your application - NEXT_PUBLIC_APP_URL="http://localhost:3000" - ``` + DATABASE_URL="file:./dev.db" + ``` 4. **Apply database migrations (Prisma):** - - Ensure your Prisma schema (`prisma/schema.prisma`) is defined. - - Setup/update the database with these commands: - ```bash - yarn prisma:generate - ``` - ```bash - yarn prisma:db:push - ``` - - Run the following command to apply migrations and generate Prisma Client: - ```bash - npx prisma migrate dev - # You might be prompted to name your first migration. - ``` + Set up/update the database with these commands: - Tipp: You can open the prisma database UI with `yarn prisma:studio` + ```bash + yarn prisma:generate + ``` -5. **Run the development server:** + ```bash + yarn prisma:db:push + ``` + + Tip: You can open the Prisma database UI with `yarn prisma:studio` + +5. **Generate needed TypeScript files:** + Generate the `swagger.json` file and the API client using: + + ```bash + yarn swagger:generate + yarn orval:generate + ``` + +6. **Run the development server:** ```bash yarn dev @@ -120,56 +127,64 @@ This project is built with a modern tech stack: Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. - The test user for the application is: +### With Docker - ```bash - email: test@example.com - password: password - ``` +**Prerequisites:** -**Docker Development Environment:** +- **Docker** +- **Docker Compose** -- The docker development environment can be started with the following command: +**Running:** ```bash yarn dev_container ``` -**Self-Hosting with Docker (Planned):** +## Production Deployment using Docker -- A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available. +The application can be hosted using the [Docker container](https://git.dominikstahl.dev/DHBW-WE/-/packages/container/meetup/main). + +There is an example Docker Compose file provided [here](https://git.dominikstahl.dev/DHBW-WE/MeetUp/src/branch/main/docker-compose.yml). ## Contributing -Contributions are welcome! If you'd like to contribute, please: +Contributions are welcome! If you'd like to contribute, please follow these steps: 1. Fork the repository. -2. Create a new branch (`git checkout -b /-action_name`). +2. Create a new branch: + + ```bash + git checkout -b /- + ``` + + - Example: `feat/42-add_login_form` + 3. Make your changes. -4. Commit your changes (`git commit -m ': add some feature'`). -5. Push to the branch (`git push origin /-action_name`). +4. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): + - The commit message should be structured as follows: + + ``` + (optional scope): + ``` + + - Example: `feat(auth): add login form` + - Example: `fix(events): correct event time calculation` + - Example: `docs: update README with setup instructions` + + - Used types: + - `feat`: Feature added + - `fix`: Bug fix + - `test`: Add or modify tests + - `docs`: Documentation changes + - `chore`: Changes to non-code files (workflows, lock files, etc.) + - `refactor`: Code refactoring without changing functionality + - `style`: Code style changes (formatting, etc.) + - `revert`: Revert a previous commit + +5. Push to your branch: + ```bash + git push origin /- + ``` 6. Open a Pull Request against the `main` branch. -Possible actions are: - - *feat* -> Feature added - *fix* -> Fixed a bug - *test* -> Modified or added tests - *docs* -> Modified documentation - *chore* -> changes to non code files (workflows, lock files, ...) - *refactor* -> rewritten code without changing functionality - *style* -> code style (yarn format) - *revert* -> reverts a previous commit - -Please ensure your code adheres to the project's coding standards (e.g., run linters/formatters if configured) and that any database schema changes are accompanied by a Prisma migration. - ---- - -**(Optional Sections You Might Want to Add Later):** - -- **Screenshots/Demo:** (Once you have UI to show) -- **API Reference:** (If you plan to expose an API) -- **Detailed Deployment Guides:** (For various platforms beyond Docker) -- **License:** (e.g., MIT, GPL - Important for open source projects) -- **Contact:** (How to get in touch with the maintainers) -- **Acknowledgements:** (Credit to any libraries, inspirations, or contributors) +Please ensure your code adheres to the project's coding standards (run `yarn format`) and that any database schema changes are accompanied by a Prisma migration. From 0815440f720f9c269a476da950abe2fd2703aca2 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 09:49:28 +0200 Subject: [PATCH 17/49] feat(auth): add additional sso providers --- .env.example | 19 +++++++++++++++++++ src/auth.ts | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1feabf8..41d13b5 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,23 @@ AUTH_AUTHENTIK_ID= AUTH_AUTHENTIK_SECRET= AUTH_AUTHENTIK_ISSUER= +AUTH_DISCORD_ID= +AUTH_DISCORD_SECRET= + +AUTH_FACEBOOK_ID= +AUTH_FACEBOOK_SECRET= + +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= + +AUTH_GITLAB_ID= +AUTH_GITLAB_SECRET= + +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +AUTH_KEYCLOAK_ID= +AUTH_KEYCLOAK_SECRET= +AUTH_KEYCLOAK_ISSUER= + NEXT_PUBLIC_APP_URL= diff --git a/src/auth.ts b/src/auth.ts index 405b729..51c2e9c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,8 +2,15 @@ import NextAuth, { CredentialsSignin } from 'next-auth'; import { Prisma } from '@/generated/prisma'; import type { Provider } from 'next-auth/providers'; + import Credentials from 'next-auth/providers/credentials'; -import Authentik from 'next-auth/providers/authentik'; +import AuthentikProvider from 'next-auth/providers/authentik'; +import DiscordProvider from 'next-auth/providers/discord'; +import FacebookProvider from 'next-auth/providers/facebook'; +import GithubProvider from 'next-auth/providers/github'; +import GitlabProvider from 'next-auth/providers/gitlab'; +import GoogleProvider from 'next-auth/providers/google'; +import KeycloakProvider from 'next-auth/providers/keycloak'; import { PrismaAdapter } from '@auth/prisma-adapter'; import { prisma } from '@/prisma'; @@ -88,7 +95,13 @@ const providers: Provider[] = [ } }, }), - process.env.AUTH_AUTHENTIK_ID && Authentik, + 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, ].filter(Boolean) as Provider[]; export const providerMap = providers From 9af74e4df491134628881605bf1c8fefa0e8b400 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 11:16:06 +0200 Subject: [PATCH 18/49] fix: define eventID for toaster button on event creation --- src/components/forms/event-form.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/forms/event-form.tsx b/src/components/forms/event-form.tsx index 2a2912c..d4fb216 100644 --- a/src/components/forms/event-form.tsx +++ b/src/components/forms/event-form.tsx @@ -51,7 +51,12 @@ const EventForm: React.FC = (props) => { const startFromUrl = searchParams.get('start'); const endFromUrl = searchParams.get('end'); - const { mutate: createEvent, status, isSuccess, error } = usePostApiEvent(); + const { + mutateAsync: createEvent, + status, + isSuccess, + error, + } = usePostApiEvent(); const { data, isLoading, error: fetchError } = useGetApiUserMe(); const { data: eventData } = useGetApiEventEventID(props.eventId!, { query: { enabled: props.type === 'edit' }, @@ -150,8 +155,10 @@ const EventForm: React.FC = (props) => { participants: selectedParticipants.map((u) => u.id), }; + let eventID: string | undefined; + if (props.type === 'edit' && props.eventId) { - await patchEvent.mutateAsync({ + const mutationResult = await patchEvent.mutateAsync({ eventID: props.eventId, data: { title: data.title, @@ -162,9 +169,12 @@ const EventForm: React.FC = (props) => { participants: data.participants, }, }); + eventID = mutationResult.data.event.id; console.log('Updating event'); } else { console.log('Creating event'); + const mutationResult = await createEvent({ data }); + eventID = mutationResult.data.event.id; createEvent({ data }); } @@ -173,7 +183,7 @@ const EventForm: React.FC = (props) => { toastId={t} title='Event saved' description={event?.title} - onAction={() => router.push(`/events/${event?.id}`)} + onAction={() => router.push(`/events/${eventID}`)} variant='success' buttonText='show' /> From 1c082b9eb17ae3a3d341e3f29fab02e572a28577 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 11:16:38 +0200 Subject: [PATCH 19/49] style: move event-view-card to the middle of the screen --- src/app/(main)/events/[eventID]/page.tsx | 284 ++++++++++++----------- 1 file changed, 143 insertions(+), 141 deletions(-) diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx index 81b98cf..f28184b 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -70,167 +70,169 @@ export default function ShowEvent() { }; return ( - - +
+ + - -
-
-
-
- + +
+
+
+
+ +
+
+

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

+
+
-
-

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

-
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - + + +
); } From 4c07c0466e09259e667b0d4dd2b177d9f7da3293 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 11:23:47 +0200 Subject: [PATCH 20/49] style: adjust height classes with h-full instat of h-screen --- src/app/(main)/events/[eventID]/page.tsx | 8 +++----- src/app/(main)/events/new/page.tsx | 2 +- src/app/(main)/events/page.tsx | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx index f28184b..c11e028 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -40,14 +40,12 @@ export default function ShowEvent() { if (isLoading || userLoading) { return ( -
- Loading... -
+
Loading...
); } if (error || !eventData?.data?.event) { return ( -
+
Error loading event.
); @@ -70,7 +68,7 @@ export default function ShowEvent() { }; return ( -
+
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..bcd1e57 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -17,7 +17,7 @@ export default function Events() { const events = eventsData?.data?.events || []; return ( -
+
{/* Heading */}

My Events From a8eb6a7a920b217fc69f8ce0bdc091f13a979b1a Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 11:38:13 +0200 Subject: [PATCH 21/49] feat: add Not Found page component --- src/app/not-found.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/app/not-found.tsx 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. +

+
+ +
+ + +
+
+
+ ); +} From fdc556afc0ed45af593bdb6f6bdefe06f0c86995 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 11:44:00 +0200 Subject: [PATCH 22/49] fix: update selectable condition in CalendarWithUserEvents to remove additionalEvents check --- src/components/calendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index e19101a..ab23c35 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -233,7 +233,7 @@ 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; From 94f89fea890d0fb0ac8212a1668b2dbdd2b2ec01 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 30 Jun 2025 12:25:19 +0200 Subject: [PATCH 23/49] fix: enhance user greeting to include fallback for name --- src/app/(main)/home/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 69e5be6..735f19b 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -14,7 +14,9 @@ export default function Home() { {isLoading ? 'Loading...' - : data?.data.user?.first_name || 'Unknown User'}{' '} + : data?.data.user?.first_name || + data?.data.user?.name || + 'Unknown User'}{' '} 👋

From 016b4371c2fed41655eadc5d57e79886f13be8eb Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 20:13:56 +0200 Subject: [PATCH 24/49] feat(blocked_slots): add blocked slots --- .../(main)/blocked_slots/[slotId]/page.tsx | 10 + src/app/(main)/blocked_slots/new/page.tsx | 5 + src/app/(main)/blocked_slots/page.tsx | 56 ++++ src/app/(main)/events/[eventID]/page.tsx | 6 +- src/app/api/blocked_slots/[slotID]/route.ts | 165 ++++++++++++ src/app/api/blocked_slots/[slotID]/swagger.ts | 90 +++++++ src/app/api/blocked_slots/route.ts | 127 +++++++++ src/app/api/blocked_slots/swagger.ts | 66 +++++ src/app/api/blocked_slots/validation.ts | 52 ++++ src/app/api/calendar/validation.ts | 1 + src/app/api/validation.ts | 11 + src/components/calendar.tsx | 131 ++++++--- .../custom-ui/blocked-slot-list-entry.tsx | 56 ++++ src/components/custom-ui/labeled-input.tsx | 1 - .../custom-ui/participant-list-entry.tsx | 47 +++- src/components/forms/blocked-slot-form.tsx | 248 ++++++++++++++++++ src/components/wrappers/query-provider.tsx | 2 +- 17 files changed, 1038 insertions(+), 36 deletions(-) create mode 100644 src/app/(main)/blocked_slots/[slotId]/page.tsx create mode 100644 src/app/(main)/blocked_slots/new/page.tsx create mode 100644 src/app/(main)/blocked_slots/page.tsx create mode 100644 src/app/api/blocked_slots/[slotID]/route.ts create mode 100644 src/app/api/blocked_slots/[slotID]/swagger.ts create mode 100644 src/app/api/blocked_slots/route.ts create mode 100644 src/app/api/blocked_slots/swagger.ts create mode 100644 src/app/api/blocked_slots/validation.ts create mode 100644 src/components/custom-ui/blocked-slot-list-entry.tsx create mode 100644 src/components/forms/blocked-slot-form.tsx diff --git a/src/app/(main)/blocked_slots/[slotId]/page.tsx b/src/app/(main)/blocked_slots/[slotId]/page.tsx new file mode 100644 index 0000000..893253c --- /dev/null +++ b/src/app/(main)/blocked_slots/[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)/blocked_slots/new/page.tsx b/src/app/(main)/blocked_slots/new/page.tsx new file mode 100644 index 0000000..a7c1bc7 --- /dev/null +++ b/src/app/(main)/blocked_slots/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)/blocked_slots/page.tsx b/src/app/(main)/blocked_slots/page.tsx new file mode 100644 index 0000000..2331737 --- /dev/null +++ b/src/app/(main)/blocked_slots/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 Blocked Slots +

+ + {/* 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 c11e028..ce39d19 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -155,7 +155,11 @@ export default function ShowEvent() { {' '}
{event.participants?.map((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..d761700 --- /dev/null +++ b/src/app/api/blocked_slots/validation.ts @@ -0,0 +1,52 @@ +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 = BlockedSlotsSchema.omit({ + id: true, + created_at: true, + updated_at: true, +}); + +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/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/components/calendar.tsx b/src/components/calendar.tsx index ab23c35..56885e0 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(`/blocked_slots/${event.id}`); + return; + } + if (event.type === 'event') { + router.push(`/events/${event.id}`); + } }} onSelectSlot={(slotInfo) => { router.push( @@ -236,53 +253,105 @@ function CalendarWithUserEvents({ 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/blocked-slot-list-entry.tsx b/src/components/custom-ui/blocked-slot-list-entry.tsx new file mode 100644 index 0000000..4690eea --- /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/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 4746a31..8adb8a5 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -12,7 +12,6 @@ export default function LabeledInput({ error, ...rest }: { - type: 'text' | 'email' | 'password'; label: string; placeholder?: string; value?: string; diff --git a/src/components/custom-ui/participant-list-entry.tsx b/src/components/custom-ui/participant-list-entry.tsx index 2ec9c02..6f21ee2 100644 --- a/src/components/custom-ui/participant-list-entry.tsx +++ b/src/components/custom-ui/participant-list-entry.tsx @@ -5,16 +5,28 @@ import { user_default_light } from '@/assets/usericon/default/defaultusericon-ex import { useTheme } from 'next-themes'; import zod from 'zod/v4'; import { ParticipantSchema } from '@/app/api/event/[eventID]/participant/validation'; +import { usePatchApiEventEventIDParticipantUser } from '@/generated/api/event-participant/event-participant'; +import { useSession } from 'next-auth/react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; type ParticipantListEntryProps = zod.output; export default function ParticipantListEntry({ user, status, -}: ParticipantListEntryProps) { + eventID, +}: ParticipantListEntryProps & { eventID?: string }) { + const session = useSession(); const { resolvedTheme } = useTheme(); const defaultImage = resolvedTheme === 'dark' ? user_default_dark : user_default_light; + const updateAttendance = usePatchApiEventEventIDParticipantUser(); const finalImageSrc = user.image ?? defaultImage; @@ -22,7 +34,38 @@ export default function ParticipantListEntry({
Avatar {user.name} - {status} + {user.id === session.data?.user?.id && eventID ? ( + + ) : ( + {status} + )}
); } diff --git a/src/components/forms/blocked-slot-form.tsx b/src/components/forms/blocked-slot-form.tsx new file mode 100644 index 0000000..c6ed109 --- /dev/null +++ b/src/components/forms/blocked-slot-form.tsx @@ -0,0 +1,248 @@ +'use client'; + +import useZodForm from '@/lib/hooks/useZodForm'; +import { + updateBlockedSlotSchema, + createBlockedSlotSchema, +} from '@/app/api/blocked_slots/validation'; +import { + useGetApiBlockedSlotsSlotID, + usePatchApiBlockedSlotsSlotID, + useDeleteApiBlockedSlotsSlotID, + usePostApiBlockedSlots, +} from '@/generated/api/blocked-slots/blocked-slots'; +import { useRouter } from 'next/navigation'; +import React from 'react'; +import LabeledInput from '../custom-ui/labeled-input'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader } from '../ui/card'; +import Logo from '../misc/logo'; +import { eventStartTimeSchema } from '@/app/api/event/validation'; +import zod from 'zod/v4'; + +const dateForDateTimeInputValue = (date: Date) => + new Date(date.getTime() + new Date().getTimezoneOffset() * -60 * 1000) + .toISOString() + .slice(0, 19); + +export default function BlockedSlotForm({ + existingBlockedSlotId, +}: { + existingBlockedSlotId?: string; +}) { + const router = useRouter(); + + const { data: existingBlockedSlot, isLoading: isLoadingExisting } = + useGetApiBlockedSlotsSlotID(existingBlockedSlotId || ''); + + const { + register: registerCreate, + handleSubmit: handleCreateSubmit, + formState: formStateCreate, + reset: resetCreate, + } = useZodForm( + createBlockedSlotSchema.extend({ + start_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + end_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + }), + ); + + const { + register: registerUpdate, + handleSubmit: handleUpdateSubmit, + formState: formStateUpdate, + reset: resetUpdate, + setValue: setValueUpdate, + } = useZodForm( + updateBlockedSlotSchema.extend({ + start_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + end_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + }), + ); + + const { mutateAsync: updateBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + onSuccess: () => { + resetUpdate(); + }, + }, + }); + + const { mutateAsync: deleteBlockedSlot } = useDeleteApiBlockedSlotsSlotID({ + mutation: { + onSuccess: () => { + router.push('/blocked_slots'); + }, + }, + }); + + const { mutateAsync: createBlockedSlot } = usePostApiBlockedSlots({ + mutation: { + onSuccess: () => { + resetCreate(); + router.push('/blocked_slots'); + }, + }, + }); + + React.useEffect(() => { + if (existingBlockedSlot?.data) { + setValueUpdate( + 'start_time', + dateForDateTimeInputValue( + new Date(existingBlockedSlot?.data.blocked_slot.start_time), + ), + ); + setValueUpdate( + 'end_time', + dateForDateTimeInputValue( + new Date(existingBlockedSlot?.data.blocked_slot.end_time), + ), + ); + setValueUpdate( + 'reason', + existingBlockedSlot?.data.blocked_slot.reason || '', + ); + } + }, [ + existingBlockedSlot?.data, + resetUpdate, + setValueUpdate, + isLoadingExisting, + ]); + + const onUpdateSubmit = handleUpdateSubmit(async (data) => { + await updateBlockedSlot( + { + data: { + ...data, + start_time: new Date(data.start_time).toISOString(), + end_time: new Date(data.end_time).toISOString(), + }, + slotID: existingBlockedSlotId || '', + }, + { + onSuccess: () => { + router.back(); + }, + }, + ); + }); + + const onDeleteSubmit = async () => { + if (existingBlockedSlotId) { + await deleteBlockedSlot({ slotID: existingBlockedSlotId }); + } + }; + + const onCreateSubmit = handleCreateSubmit(async (data) => { + await createBlockedSlot({ + data: { + ...data, + start_time: new Date(data.start_time).toISOString(), + end_time: new Date(data.end_time).toISOString(), + }, + }); + }); + + return ( +
+ + +
+
+ +
+
+

+ {existingBlockedSlotId + ? 'Update Blocked Slot' + : 'Create Blocked Slot'} +

+
+
+
+
+ +
+
+ + + +
+
+ + {existingBlockedSlotId && ( + + )} +
+ {formStateCreate.errors.root && ( +

+ {formStateCreate.errors.root.message} +

+ )} + {formStateUpdate.errors.root && ( +

+ {formStateUpdate.errors.root.message} +

+ )} +
+
+
+
+ ); +} diff --git a/src/components/wrappers/query-provider.tsx b/src/components/wrappers/query-provider.tsx index 4d05c6c..0120caf 100644 --- a/src/components/wrappers/query-provider.tsx +++ b/src/components/wrappers/query-provider.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as React from 'react'; -const queryClient = new QueryClient(); +export const queryClient = new QueryClient(); export function QueryProvider({ children }: { children: React.ReactNode }) { return ( From 53cc8cb2b74d7f804bc46a27e4fbadd5bfe8dba8 Mon Sep 17 00:00:00 2001 From: SomeCodecat <88855796+SomeCodecat@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:01:20 +0200 Subject: [PATCH 25/49] feat: add Radix UI components and implement sidebar functionality - Added new Radix UI components: Dialog, Tooltip, Separator, and updated existing components. - Introduced a Sidebar component with collapsible functionality and mobile responsiveness. - Implemented a custom hook `useIsMobile` to manage mobile state. - Updated package dependencies in package.json and yarn.lock for new components. - Created utility components such as Button, Skeleton, and Input for consistent styling. feat: add AppSidebar component with collapsible functionality and sidebar menu - Introduced AppSidebar component for a customizable sidebar layout. - Implemented collapsible sections using Radix UI's Collapsible component. - Added sidebar menu items with icons and links for navigation. - Created Sidebar UI components including SidebarHeader, SidebarFooter, and SidebarMenu. - Integrated ThemePicker for theme selection within the sidebar. - Updated sidebar styles and layout for better responsiveness. chore: add @radix-ui/react-collapsible dependency - Added @radix-ui/react-collapsible package to manage collapsible UI elements. --- src/components/ui/sidebar.tsx | 725 ++++++++++++++++++++++++++++++++++ yarn.lock | 18 +- 2 files changed, 734 insertions(+), 9 deletions(-) create mode 100644 src/components/ui/sidebar.tsx diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..6b68b8f --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,725 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, VariantProps } from 'class-variance-authority'; +import { PanelLeftIcon } from 'lucide-react'; + +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '4rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContextProps = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.'); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar(); + + return ( + {existingBlockedSlotId && ( )}
diff --git a/src/components/forms/event-form.tsx b/src/components/forms/event-form.tsx index d4fb216..11bf820 100644 --- a/src/components/forms/event-form.tsx +++ b/src/components/forms/event-form.tsx @@ -175,7 +175,6 @@ const EventForm: React.FC = (props) => { console.log('Creating event'); const mutationResult = await createEvent({ data }); eventID = mutationResult.data.event.id; - createEvent({ data }); } toast.custom((t) => ( From 9191eb3df00a6a9b1e5e968ec607dcd9438bfd6c Mon Sep 17 00:00:00 2001 From: Maximilian Liebmann Date: Mon, 30 Jun 2025 23:33:36 +0200 Subject: [PATCH 27/49] feat: Implement settings dropdown and page components - Added `SettingsDropdown` component for selecting settings sections with icons and descriptions. - Created `SettingsPage` component to manage user settings, including account details, notifications, calendar availability, privacy, and appearance. - Introduced `SettingsSwitcher` for selecting options within settings. - Integrated command and dialog components for improved user interaction. - Updated `UserDropdown` to include links for settings and logout. - Refactored button styles and card footer layout for consistency. - Added popover functionality for dropdown menus. - Updated dependencies in `yarn.lock` for new components. feat: tempcommit feat: tempcommit --- src/app/api/logout/route.ts | 8 + src/app/api/user/me/validation.ts | 11 + src/app/settings/page.tsx | 483 +----------------- src/auth.ts | 16 +- src/components/buttons/icon-button.tsx | 13 +- src/components/buttons/sso-login-button.tsx | 4 +- src/components/custom-ui/app-sidebar.tsx | 26 +- src/components/custom-ui/labeled-input.tsx | 85 ++- src/components/forms/login-form.tsx | 64 ++- .../misc/profile-picture-upload.tsx | 36 ++ src/components/misc/user-card.tsx | 2 +- src/components/misc/user-dropdown.tsx | 11 +- src/components/settings/settings-dropdown.tsx | 165 ++++++ src/components/settings/settings-page.tsx | 59 +++ src/components/settings/tabs/account.tsx | 287 +++++++++++ src/components/settings/tabs/appearance.tsx | 55 ++ src/components/settings/tabs/calendar.tsx | 226 ++++++++ .../settings/tabs/notifications.tsx | 134 +++++ src/components/settings/tabs/password.tsx | 151 ++++++ src/components/settings/tabs/privacy.tsx | 143 ++++++ src/components/ui/button.tsx | 2 +- src/components/ui/card.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/wrappers/group-wrapper.tsx | 23 + src/components/wrappers/settings-scroll.tsx | 20 +- 25 files changed, 1476 insertions(+), 552 deletions(-) create mode 100644 src/app/api/logout/route.ts create mode 100644 src/components/misc/profile-picture-upload.tsx create mode 100644 src/components/settings/settings-dropdown.tsx create mode 100644 src/components/settings/settings-page.tsx create mode 100644 src/components/settings/tabs/account.tsx create mode 100644 src/components/settings/tabs/appearance.tsx create mode 100644 src/components/settings/tabs/calendar.tsx create mode 100644 src/components/settings/tabs/notifications.tsx create mode 100644 src/components/settings/tabs/password.tsx create mode 100644 src/components/settings/tabs/privacy.tsx create mode 100644 src/components/wrappers/group-wrapper.tsx 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/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..ae0238a 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, ScanEye } 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/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index 50e88c2..37fa84f 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -62,18 +62,20 @@ export function AppSidebar() { <> - - + + + + diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 4746a31..5505e14 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -1,29 +1,56 @@ 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, ...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; } & React.InputHTMLAttributes) { + const [passwordVisible, setPasswordVisible] = React.useState(false); + const [inputValue, setInputValue] = React.useState( + value || defaultValue || '', + ); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + if (rest.onChange) { + rest.onChange(e); + } + }; + return (
+ {subtext && ( + + )} {variantSize === 'textarea' ? (