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

feat: tempcommit

feat: tempcommit

feat: tempcommit

feat: tempcommit

feat: tempcommit

feat: tempcommit (broken input)

feat: tempcommit

feat: tempcommit

feat: tempcommit
This commit is contained in:
Maximilian Liebmann 2025-06-23 11:20:09 +02:00 committed by Maximilian Liebmann
parent 53cc8cb2b7
commit 7ede267762
25 changed files with 1422 additions and 552 deletions

View file

@ -0,0 +1,8 @@
import { signOut } from '@/auth';
import { NextResponse } from 'next/server';
export const GET = async () => {
await signOut();
return NextResponse.redirect('/login');
};

View file

@ -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'),

View file

@ -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 (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full h-auto max-h-[calc(100vh-2rem)] flex flex-col'>
<Tabs
defaultValue='general'
className='w-full flex flex-col flex-grow min-h-0'
>
<TabsList className='grid w-full grid-cols-3 sm:grid-cols-5'>
<TabsTrigger value='general'>Account</TabsTrigger>
<TabsTrigger value='notifications'>Notifications</TabsTrigger>
<TabsTrigger value='calendarAvailability'>Calendar</TabsTrigger>
<TabsTrigger value='sharingPrivacy'>Privacy</TabsTrigger>
<TabsTrigger value='appearance'>Appearance</TabsTrigger>
</TabsList>
<TabsContent value='general' className='flex-grow overflow-hidden'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
<CardDescription>
Manage your account details and preferences.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='displayName'>Display Name</Label>
<Input id='displayName' placeholder='Your Name' />
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email Address</Label>
<Input
id='email'
type='email'
placeholder='your.email@example.com'
readOnly
value='user-email@example.com'
/>
<p className='text-sm text-muted-foreground'>
Email is managed by your SSO provider.
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='profilePicture'>Profile Picture</Label>
<Input id='profilePicture' type='file' />
<p className='text-sm text-muted-foreground'>
Upload a new profile picture.
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='timezone'>Timezone</Label>
<Input id='displayName' placeholder='Europe/Berlin' />
</div>
<div className='space-y-2'>
<Label htmlFor='language'>Language</Label>
<Select>
<SelectTrigger id='language'>
<SelectValue placeholder='Select language' />
</SelectTrigger>
<SelectContent>
<SelectItem value='en'>English</SelectItem>
<SelectItem value='de'>German</SelectItem>
</SelectContent>
</Select>
</div>
<div className='pt-4'>
<Button variant='secondary'>Delete Account</Button>
<p className='text-sm text-muted-foreground pt-1'>
Permanently delete your account and all associated data.
</p>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='notifications'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>
Choose how you want to be notified.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='flex items-center justify-between space-x-2 p-3 rounded-md border'>
<Label
htmlFor='masterEmailNotifications'
className='font-normal'
>
Enable All Email Notifications
</Label>
<Switch id='masterEmailNotifications' />
</div>
<div className='space-y-4 pl-2 border-l-2 ml-2'>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='newMeetingBookings'
className='font-normal'
>
New Meeting Bookings
</Label>
<Switch id='newMeetingBookings' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='meetingConfirmations'
className='font-normal'
>
Meeting Confirmations/Cancellations
</Label>
<Switch id='meetingConfirmations' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='enableMeetingReminders'
className='font-normal'
>
Meeting Reminders
</Label>
<Switch id='enableMeetingReminders' />
</div>
<div className='space-y-2 pl-6'>
<Label htmlFor='remindBefore'>Remind me before</Label>
<Select>
<SelectTrigger id='remindBefore'>
<SelectValue placeholder='Select reminder time' />
</SelectTrigger>
<SelectContent>
<SelectItem value='15m'>15 minutes</SelectItem>
<SelectItem value='30m'>30 minutes</SelectItem>
<SelectItem value='1h'>1 hour</SelectItem>
<SelectItem value='1d'>1 day</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='friendRequests' className='font-normal'>
Friend Requests
</Label>
<Switch id='friendRequests' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='groupUpdates' className='font-normal'>
Group Invitations/Updates
</Label>
<Switch id='groupUpdates' />
</div>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='calendarAvailability'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Calendar & Availability</CardTitle>
<CardDescription>
Manage your calendar display, default availability, and iCal
integrations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
Display
</legend>
<div className='space-y-2'>
<Label htmlFor='defaultCalendarView'>
Default Calendar View
</Label>
<Select>
<SelectTrigger id='defaultCalendarView'>
<SelectValue placeholder='Select view' />
</SelectTrigger>
<SelectContent>
<SelectItem value='day'>Day</SelectItem>
<SelectItem value='week'>Week</SelectItem>
<SelectItem value='month'>Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='weekStartsOn'>Week Starts On</Label>
<Select>
<SelectTrigger id='weekStartsOn'>
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
<SelectItem value='sunday'>Sunday</SelectItem>
<SelectItem value='monday'>Monday</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='showWeekends' className='font-normal'>
Show Weekends
</Label>
<Switch id='showWeekends' defaultChecked />
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
Availability
</legend>
<div className='space-y-2'>
<Label>Working Hours</Label>
<p className='text-sm text-muted-foreground'>
Define your typical available hours (e.g.,
Monday-Friday, 9 AM - 5 PM).
</p>
<Button variant='outline_muted' size='sm'>
Set Working Hours
</Button>
</div>
<div className='space-y-2'>
<Label htmlFor='minNoticeBooking'>
Minimum Notice for Bookings
</Label>
<p className='text-sm text-muted-foreground'>
Min time before a booking can be made.
</p>
<div className='space-y-2'>
<Input
id='bookingWindow'
type='text'
placeholder='e.g., 1h'
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='bookingWindow'>
Booking Window (days in advance)
</Label>
<p className='text-sm text-muted-foreground'>
Max time in advance a booking can be made.
</p>
<Input
id='bookingWindow'
type='number'
placeholder='e.g., 30d'
/>
</div>
</fieldset>
<fieldset className='space-y-4 p-4 border rounded-md'>
<legend className='text-sm font-medium px-1'>
iCalendar Integration
</legend>
<div className='space-y-2'>
<Label htmlFor='icalImport'>Import iCal Feed URL</Label>
<Input
id='icalImport'
type='url'
placeholder='https://calendar.example.com/feed.ics'
/>
<Button size='sm' className='mt-1'>
Add Feed
</Button>
</div>
<div className='space-y-2'>
<Label>Export Your Calendar</Label>
<Button variant='outline_muted' size='sm'>
Get iCal Export URL
</Button>
<Button
variant='outline_muted'
size='sm'
className='ml-2'
>
Download .ics File
</Button>
</div>
</fieldset>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent
value='sharingPrivacy'
className='flex-grow overflow-hidden'
>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Sharing & Privacy</CardTitle>
<CardDescription>
Control who can see your calendar and book time with you.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='defaultVisibility'>
Default Calendar Visibility
</Label>
<Select>
<SelectTrigger id='defaultVisibility'>
<SelectValue placeholder='Select visibility' />
</SelectTrigger>
<SelectContent>
<SelectItem value='private'>
Private (Only You)
</SelectItem>
<SelectItem value='freebusy'>
Free/Busy for Friends
</SelectItem>
<SelectItem value='fulldetails'>
Full Details for Friends
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanSeeFull'>
Who Can See Your Full Calendar Details?
</Label>
<p className='text-sm text-muted-foreground'>
(Override for Default Visibility)
<br />
<span className='text-sm text-muted-foreground'>
This setting will override the default visibility for
your calendar. You can set specific friends or groups to
see your full calendar details.
</span>
</p>
<Select>
<SelectTrigger id='whoCanSeeFull'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='me'>Only Me</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='whoCanBook'>
Who Can Book Time With You?
</Label>
<Select>
<SelectTrigger id='whoCanBook'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>No One</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Blocked Users</Label>
<Button variant='outline_muted'>
Manage Blocked Users
</Button>
<p className='text-sm text-muted-foreground'>
Prevent specific users from seeing your calendar or
booking time.
</p>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value='appearance' className='flex-grow overflow-hidden'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize the look and feel of the application.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-2'>
<Label htmlFor='theme'>Theme</Label>
<Select>
<SelectTrigger id='theme'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent>
<SelectItem value='light'>Light</SelectItem>
<SelectItem value='dark'>Dark</SelectItem>
<SelectItem value='system'>System Default</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='dateFormat'>Date Format</Label>
<Select>
<SelectTrigger id='dateFormat'>
<SelectValue placeholder='Select date format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ddmmyyyy'>DD/MM/YYYY</SelectItem>
<SelectItem value='mmddyyyy'>MM/DD/YYYY</SelectItem>
<SelectItem value='yyyymmdd'>YYYY-MM-DD</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='timeFormat'>Time Format</Label>
<Select>
<SelectTrigger id='timeFormat'>
<SelectValue placeholder='Select time format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='24h'>24-hour</SelectItem>
<SelectItem value='12h'>12-hour</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='mt-auto border-t pt-4 flex justify-between'>
<Button variant='secondary'>Exit</Button>
<Button>Save Changes</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
export default function Page() {
return <SettingsPage />;
}

View file

@ -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

View file

@ -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<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>
>;
children?: React.ReactNode;
} & React.ComponentProps<typeof Button>) {
return (
<Button type='button' variant='secondary' {...props}>
<FontAwesomeIcon icon={icon} className='mr-2' />
{icon && React.createElement(icon, { className: 'mr-2' })}
{children}
</Button>
);

View file

@ -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}

View file

@ -62,18 +62,20 @@ export function AppSidebar() {
<>
<Sidebar collapsible='icon' variant='sidebar'>
<SidebarHeader className='overflow-hidden'>
<Logo
colorType='colored'
logoType='combo'
height={50}
className='group-data-[collapsible=icon]:hidden min-w-[203px]'
></Logo>
<Logo
colorType='colored'
logoType='submark'
height={50}
className='group-data-[collapsible=]:hidden group-data-[mobile=true]/mobile:hidden'
></Logo>
<Link href='/home'>
<Logo
colorType='colored'
logoType='combo'
height={50}
className='group-data-[collapsible=icon]:hidden min-w-[203px]'
></Logo>
<Logo
colorType='colored'
logoType='submark'
height={50}
className='group-data-[collapsible=]:hidden group-data-[mobile=true]/mobile:hidden'
></Logo>
</Link>
</SidebarHeader>
<SidebarContent className='grid grid-rows-[auto_1fr_auto] overflow-hidden'>
<Collapsible defaultOpen className='group/collapsible'>

View file

@ -1,29 +1,54 @@
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<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>
>;
variantSize?: 'default' | 'big' | 'textarea';
autocomplete?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
const [passwordVisible, setPasswordVisible] = React.useState(false);
const [inputValue, setInputValue] = React.useState(value || defaultValue || '');
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
if (rest.onChange) {
rest.onChange(e);
}
};
return (
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor={name}>{label}</Label>
{subtext && (
<Label className='text-sm text-muted-foreground' htmlFor={name}>
{subtext}
</Label>
)}
{variantSize === 'textarea' ? (
<Textarea
placeholder={placeholder}
@ -33,20 +58,48 @@ export default function LabeledInput({
rows={3}
/>
) : (
<Input
type={type}
placeholder={placeholder}
defaultValue={value}
id={name}
name={name}
className={
variantSize === 'big'
? 'h-12 file:h-10 text-lg gplaceholder:text-lg sm:text-2xl sm:placeholder:text-2xl'
: ''
}
autoComplete={autocomplete}
{...rest}
/>
<span className='relative'>
<Input
className={cn(
type === 'password' ? 'pr-[50px]' : '',
variantSize === 'big'
? 'h-12 file:h-10 text-lg placeholder:text-lg sm:text-2xl sm:placeholder:text-2xl'
: '',
icon && inputValue === '' ? 'pl-10' : '',
'transition-all duration-300 ease-in-out',
)}
type={passwordVisible ? 'text' : type}
placeholder={placeholder}
defaultValue={inputValue}
id={name}
name={name}
autoComplete={autocomplete}
{...rest}
onChange={handleInputChange}
/>
{icon && (
<span
className={cn(
'absolute left-3 top-1/2 -translate-y-1/2 text-muted-input transition-all duration-300 ease-in-out',
inputValue === ''
? 'opacity-100 scale-100'
: 'opacity-0 scale-75 pointer-events-none',
)}
>
{React.createElement(icon)}
</span>
)}
{type === 'password' && (
<Button
className='absolute right-0 top-0 w-[36px] h-[36px]'
type='button'
variant={'outline_muted'}
onClick={() => setPasswordVisible((visible) => !visible)}
>
{passwordVisible ? <Eye /> : <EyeOff />}
</Button>
)}
</span>
)}
{error && <p className='text-red-500 text-sm mt-1'>{error}</p>}
</div>

View file

@ -4,11 +4,21 @@ import React, { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { Button } from '@/components/ui/button';
import useZodForm from '@/lib/hooks/useZodForm';
import { loginSchema, registerSchema } from '@/lib/auth/validation';
import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register';
import { IconButton } from '../buttons/icon-button';
import {
FileKey,
FileKey2,
LogIn,
MailOpen,
RotateCcwKey,
UserCheck,
UserPen,
UserPlus,
} from 'lucide-react';
function LoginFormElement({
setIsSignUp,
@ -56,6 +66,7 @@ function LoginFormElement({
<LabeledInput
type='text'
label='E-Mail or Username'
icon={UserCheck}
placeholder='What you are known as'
error={formState.errors.email?.message}
{...register('email')}
@ -64,16 +75,22 @@ function LoginFormElement({
<LabeledInput
type='password'
label='Password'
icon={FileKey}
placeholder="Let's hope you remember it"
error={formState.errors.password?.message}
{...register('password')}
data-cy='password-input'
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary' data-cy='login-button'>
<IconButton
type='submit'
variant='primary'
data-cy='login-button'
icon={LogIn}
>
Login
</Button>
<Button
</IconButton>
<IconButton
type='button'
variant='outline_primary'
onClick={() => {
@ -81,9 +98,10 @@ function LoginFormElement({
setIsSignUp((v) => !v);
}}
data-cy='register-switch'
icon={UserPlus}
>
Sign Up
</Button>
</IconButton>
</div>
<div>
{formState.errors.root?.message && (
@ -156,27 +174,30 @@ function RegisterFormElement({
{...register('lastName')}
data-cy='last-name-input'
/>
<LabeledInput
type='email'
label='E-Mail'
placeholder='Your email address'
autocomplete='email'
error={formState.errors.email?.message}
{...register('email')}
data-cy='email-input'
/>
<LabeledInput
type='text'
label='Username'
icon={UserPen}
placeholder='Your username'
autocomplete='username'
error={formState.errors.username?.message}
{...register('username')}
data-cy='username-input'
/>
<LabeledInput
type='email'
label='E-Mail'
icon={MailOpen}
placeholder='Your email address'
autocomplete='email'
error={formState.errors.email?.message}
{...register('email')}
data-cy='email-input'
/>
<LabeledInput
type='password'
label='Password'
icon={FileKey2}
placeholder='Create a password'
autocomplete='new-password'
error={formState.errors.password?.message}
@ -186,6 +207,7 @@ function RegisterFormElement({
<LabeledInput
type='password'
label='Confirm Password'
icon={RotateCcwKey}
placeholder='Repeat your password'
autocomplete='new-password'
error={formState.errors.confirmPassword?.message}
@ -193,19 +215,25 @@ function RegisterFormElement({
data-cy='confirm-password-input'
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary' data-cy='register-button'>
<IconButton
type='submit'
variant='primary'
data-cy='register-button'
icon={UserPlus}
>
Sign Up
</Button>
<Button
</IconButton>
<IconButton
type='button'
variant='outline_primary'
onClick={() => {
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
icon={LogIn}
>
Back to Login
</Button>
</IconButton>
</div>
<div>
{formState.errors.root?.message && (

View file

@ -0,0 +1,35 @@
import Image from 'next/image';
import { Avatar } from '../ui/avatar';
import { useGetApiUserMe } from '@/generated/api/user/user';
import { User } from 'lucide-react';
import { Input } from '../ui/input';
export default function ProfilePictureUpload({
className,
}: {
className?: string;
}) {
const { data } = useGetApiUserMe();
return (
<>
<div className='grid grid-cols-1 gap-1'>
<span className='relative flex space-6'>
<Input className={className} id='pic-upload' type='file' />
<Avatar className='flex justify-center items-center ml-6 shadow-md border h-[36px] w-[36px]'>
{data?.data.user.image ? (
<Image
src={data?.data.user.image}
alt='Avatar'
width='20'
height='20'
/>
) : (
<User />
)}
</Avatar>
</span>
</div>
</>
);
}

View file

@ -21,7 +21,7 @@ export default function UserCard() {
)}
</Avatar>
<div className='flex justify-center'>{data?.data.user.name}</div>
<div className='flex justify-center text-text-muted'>
<div className='flex justify-center text-text-muted text-[12px]'>
{data?.data.user.email}
</div>
</div>

View file

@ -17,6 +17,7 @@ import UserCard from '@/components/misc/user-card';
export default function UserDropdown() {
const { data } = useGetApiUserMe();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -41,11 +42,13 @@ export default function UserDropdown() {
<UserCard />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<Link href='/settings'>
<DropdownMenuItem>Settings</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href='/logout'>Logout</Link>
</DropdownMenuItem>
<Link href='/logout'>
<DropdownMenuItem>Logout</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -0,0 +1,165 @@
'use client';
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
Check,
ChevronDown,
User,
Bell,
Calendar,
Shield,
Palette,
Key,
} from 'lucide-react';
interface SettingsSection {
label: string;
value: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}
interface SettingsDropdownProps {
currentSection: string;
onSectionChange: (section: string) => void;
className?: string;
}
const settingsSections: SettingsSection[] = [
{
label: 'Account',
value: 'general',
description: 'Manage account details',
icon: User,
},
{
label: 'Password',
value: 'password',
description: 'Manage your password',
icon: Key,
},
{
label: 'Notifications',
value: 'notifications',
description: 'Choose notification Preferences',
icon: Bell,
},
{
label: 'Calendar',
value: 'calendarAvailability',
description: 'Manage calendar display, availability and iCal integration',
icon: Calendar,
},
{
label: 'Privacy',
value: 'sharingPrivacy',
description: 'Control who can see your calendar and book time with you',
icon: Shield,
},
{
label: 'Appearance',
value: 'appearance',
description: 'Customize the look and feel of the application',
icon: Palette,
},
];
export function SettingsDropdown({
currentSection,
onSectionChange,
className,
}: SettingsDropdownProps) {
const [open, setOpen] = useState(false);
const currentSectionData = settingsSections.find(
(section) => section.value === currentSection,
);
const CurrentIcon = currentSectionData?.icon || User;
const handleSelect = (value: string) => {
onSectionChange(value);
setOpen(false);
};
return (
<div className={cn('w-full max-w-md', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline_muted'
role='combobox'
aria-expanded={open}
className='w-full justify-between bg-popover text-text h-auto py-3'
>
<div className='flex items-center gap-3'>
<CurrentIcon className='h-4 w-4 text-muted-foreground' />
<div className='flex flex-col items-start text-left'>
<span className='font-medium'>{currentSectionData?.label}</span>
<p className='text-xs text-muted-foreground text-wrap'>
{currentSectionData?.description}
</p>
</div>
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-full p-0' align='start'>
<Command>
<CommandInput placeholder='Search settings...' />
<CommandList>
<CommandEmpty>No settings found.</CommandEmpty>
<CommandGroup>
{settingsSections.map((section) => {
const Icon = section.icon;
return (
<CommandItem
key={section.value}
value={section.value}
onSelect={() => handleSelect(section.value)}
className='flex items-center justify-between p-3'
>
<div className='flex items-center gap-3'>
<Icon className='h-4 w-4 text-muted-foreground' />
<div className='flex flex-col'>
<span className='font-medium'>{section.label}</span>
<p className='text-xs text-muted-foreground text-wrap'>
{section.description}
</p>
</div>
</div>
<Check
className={cn(
'ml-2 h-4 w-4',
currentSection === section.value
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View file

@ -0,0 +1,59 @@
'use client';
import { useState } from 'react';
import { SettingsDropdown } from '@/components/settings/settings-dropdown';
import AccountTab from './tabs/account';
import NotificationsTab from './tabs/notifications';
import CalendarTab from './tabs/calendar';
import PrivacyTab from './tabs/privacy';
import AppearanceTab from './tabs/appearance';
import PasswordTab from './tabs/password';
export default function SettingsPage() {
const [currentSection, setCurrentSection] = useState('general');
const renderSettingsContent = () => {
switch (currentSection) {
case 'general':
return <AccountTab />;
case 'password':
return <PasswordTab />;
case 'notifications':
return <NotificationsTab />;
case 'calendarAvailability':
return <CalendarTab />;
case 'sharingPrivacy':
return <PrivacyTab />;
case 'appearance':
return <AppearanceTab />;
default:
return null;
}
};
return (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full max-h-[calc(100vh-2rem)] flex flex-col'>
{/* TODO: Fix overflow */}
<div className='p-6 border-b'>
<div className='flex items-center justify-between mb-4'>
<h1 className='text-2xl font-semibold'>Settings</h1>
</div>
<SettingsDropdown
currentSection={currentSection}
onSectionChange={setCurrentSection}
/>
</div>
{renderSettingsContent()}
</div>
</div>
);
}

View file

@ -0,0 +1,263 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
useDeleteApiUserMe,
useGetApiUserMe,
usePatchApiUserMe,
} from '@/generated/api/user/user';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import ProfilePictureUpload from '@/components/misc/profile-picture-upload';
import { CalendarClock, MailOpen, UserPen } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import useZodForm from '@/lib/hooks/useZodForm';
import { updateUserClientSchema } from '@/app/api/user/me/validation';
import { useRouter } from 'next/navigation';
export default function AccountTab() {
const router = useRouter();
const { data } = useGetApiUserMe();
const deleteUser = useDeleteApiUserMe();
const updateAccount = usePatchApiUserMe();
const { handleSubmit, formState, register, setError } = useZodForm(
updateUserClientSchema,
);
const onSubmit = handleSubmit(async (submitData) => {
await updateAccount.mutateAsync(
{
data: {
first_name:
submitData?.first_name !== data?.data.user.first_name
? submitData?.first_name
: undefined,
last_name:
submitData?.last_name !== data?.data.user.last_name
? submitData?.last_name
: undefined,
email:
submitData?.email !== data?.data.user.email
? submitData?.email
: undefined,
image:
submitData?.image !== data?.data.user.image
? submitData?.image
: undefined,
timezone:
submitData?.timezone !== data?.data.user.timezone
? submitData?.timezone
: undefined,
},
},
{
onSuccess: () => {
router.refresh();
},
onError: (error) => {
if (error instanceof Error) {
setError('root', {
message: error.response?.data.message,
});
} else {
setError('root', {
message: 'An unknown error occurred.',
});
}
},
},
);
});
if (!data) {
return (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full h-auto max-h-[calc(100vh-2rem)] flex flex-col'>
<div className='p-6 border-b'>
<h1 className='text-2xl font-semibold'>Loading Settings...</h1>
</div>
</div>
</div>
);
}
return (
<form onSubmit={onSubmit} className='h-full flex-grow overflow-auto'>
<Card className='pb-0 h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- General Settings --------------------*/}
<GroupWrapper title='General Settings'>
<div className='space-y-4'>
<div>
<LabeledInput
type='text'
label='First Name'
placeholder='First Name'
defaultValue={data.data.user.first_name ?? ''}
{...register('first_name')}
error={formState.errors.first_name?.message}
></LabeledInput>
</div>
<div>
<LabeledInput
type='text'
label='Last Name'
placeholder='Last Name'
defaultValue={data.data.user.last_name ?? ''}
{...register('last_name')}
error={formState.errors.last_name?.message}
></LabeledInput>
</div>
<div className='space-y-2'>
<LabeledInput
type='text'
label='Display Name'
icon={UserPen}
placeholder='Display Name'
defaultValue={data.data.user.name}
{...register('name')}
error={formState.errors.name?.message}
></LabeledInput>
</div>
<div className='space-y-2 space-b-2'>
<LabeledInput
type='email'
label='Email Address'
icon={MailOpen}
placeholder='Your E-Mail'
defaultValue={data.data.user.email ?? ''}
{...register('email')}
error={formState.errors.email?.message}
></LabeledInput>
<span className='text-sm text-muted-foreground'>
Email might be managed by your SSO provider.
</span>
</div>
</div>
{formState.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formState.errors.root.message}
</p>
)}
</GroupWrapper>
{/*-------------------- General Settings --------------------*/}
{/*-------------------- Profile Picture --------------------*/}
<GroupWrapper title='Profile Picture'>
<div className='space-y-2 grid grid-cols-[1fr_auto]'>
<ProfilePictureUpload />
</div>
</GroupWrapper>
{/*-------------------- Profile Picture --------------------*/}
{/*-------------------- Regional Settings --------------------*/}
<GroupWrapper title='Regional Settings'>
<div className='space-y-2 grid sm:grid-cols-[1fr_auto] sm:flex-row gap-4'>
<div className='grid gap-1'>
<LabeledInput
type='text'
label='Timezone'
placeholder='Europe/Berlin'
icon={CalendarClock}
defaultValue={data?.data.user.timezone ?? ''}
{...register('timezone')}
></LabeledInput>
</div>
<div>
<div className='grid gap-1'>
<Label htmlFor='language'>Language</Label>
<Select>
<SelectTrigger id='language'>
<SelectValue placeholder='Select language' />
</SelectTrigger>
<SelectContent>
<SelectItem value='en'>English</SelectItem>
<SelectItem value='de'>German</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</GroupWrapper>
{/*-------------------- Regional Settings --------------------*/}
{/*-------------------- DANGER ZONE --------------------*/}
<GroupWrapper title='DANGER ZONE' className='border-destructive'>
<div className='flex items-center justify-evenly sm:flex-row flex-col gap-6'>
<Dialog>
<DialogTrigger asChild>
<Button variant='destructive'>Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<div className='space-y-4'>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<div className='space-y-4'>
<DialogDescription>
This action cannot be undone. This will permanently
delete your account and remove your data from our
servers.
</DialogDescription>
<Button
variant='destructive'
onClick={() => {
deleteUser.mutate(undefined, {
onSuccess: () => {
router.push('/api/logout');
},
});
}}
>
Confirm Delete
</Button>
</div>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
<span className='text-sm text-muted-foreground pt-1'>
Permanently delete your account and all associated data.
</span>
</div>
</GroupWrapper>
{/*-------------------- DANGER ZONE --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</Card>
</form>
);
}

View file

@ -0,0 +1,51 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import { useRouter } from 'next/navigation';
import { ThemePicker } from '@/components/misc/theme-picker';
export default function AppearanceTab() {
const router = useRouter();
return (
<>
<div className='flex-grow overflow-auto'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- Change Theme --------------------*/}
<GroupWrapper title='Change Theme'>
<div className='space-y-2'>
<Label htmlFor='theme'>Theme</Label>
<ThemePicker />
</div>
</GroupWrapper>
{/*-------------------- Change Theme --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
</Card>
</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</>
);
}

View file

@ -0,0 +1,216 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import { Switch } from '@/components/ui/switch';
import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/custom-ui/labeled-input';
import {
CalendarArrowDown,
CalendarArrowUp,
CalendarCheck,
CalendarPlus,
ClockAlert,
ClockFading,
} from 'lucide-react';
import { IconButton } from '@/components/buttons/icon-button';
export default function CalendarTab() {
const router = useRouter();
return (
<>
<div className='flex-grow overflow-auto'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Calendar & Availability</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- Date & Time Format --------------------*/}
<GroupWrapper title='Date & Time Format'>
<div className='flex flex-col space-y-4'>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='dateFormat'>Date Format</Label>
<Select>
<SelectTrigger id='dateFormat'>
<SelectValue placeholder='Select date format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ddmmyyyy'>DD/MM/YYYY</SelectItem>
<SelectItem value='mmddyyyy'>MM/DD/YYYY</SelectItem>
<SelectItem value='yyyymmdd'>YYYY-MM-DD</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='timeFormat'>Time Format</Label>
<Select>
<SelectTrigger id='timeFormat'>
<SelectValue placeholder='Select time format' />
</SelectTrigger>
<SelectContent>
<SelectItem value='24h'>24-hour</SelectItem>
<SelectItem value='12h'>12-hour</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</GroupWrapper>
{/*-------------------- Date & Time Format --------------------*/}
{/*-------------------- Calendar --------------------*/}
<GroupWrapper title='Calendar'>
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='defaultCalendarView'>
Default Calendar View
</Label>
<Select>
<SelectTrigger id='defaultCalendarView'>
<SelectValue placeholder='Select view' />
</SelectTrigger>
<SelectContent>
<SelectItem value='day'>Day</SelectItem>
<SelectItem value='week'>Week</SelectItem>
<SelectItem value='month'>Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='weekStartsOn'>Week Starts On</Label>
<Select>
<SelectTrigger id='weekStartsOn'>
<SelectValue placeholder='Select day' />
</SelectTrigger>
<SelectContent>
<SelectItem value='sunday'>Sunday</SelectItem>
<SelectItem value='monday'>Monday</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='showWeekends' className='font-normal'>
Show Weekends
</Label>
<Switch id='showWeekends' defaultChecked />
</div>
</div>
</GroupWrapper>
{/*-------------------- Calendar --------------------*/}
{/*-------------------- Availability --------------------*/}
<GroupWrapper title='Availability'>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Working Hours</Label>
<span className='text-sm text-muted-foreground'>
Define your typical available hours (e.g., Monday-Friday,
9 AM - 5 PM).
</span>
<Button variant='outline_muted' size='sm'>
Set Working Hours
</Button>
</div>
<div className='space-y-2'>
<LabeledInput
type='text'
label='Minimum Notice for Bookings'
icon={ClockAlert}
subtext='Min time before a booking can be made.'
placeholder='e.g. 1h'
defaultValue={''}
></LabeledInput>
</div>
<div className='space-y-2'>
<LabeledInput
type='text'
label='Booking Window (days in advance)'
icon={ClockFading}
subtext='Max time in advance a booking can be made.'
placeholder='e.g. 30d'
defaultValue={''}
></LabeledInput>
</div>
</div>
</GroupWrapper>
{/*-------------------- Availability --------------------*/}
{/*-------------------- iCalendar Integration --------------------*/}
<GroupWrapper title='iCalendar Integration'>
<div className='space-y-4'>
<form
onSubmit={(e) => {
e.preventDefault();
}}
className='space-y-2'
>
<LabeledInput
type='url'
label='Import iCal Feed URL'
icon={CalendarCheck}
placeholder='https://calendar.example.com/feed.ics'
defaultValue={''}
name='icalUrl'
required
></LabeledInput>
<IconButton
type='submit'
size='sm'
className='mt-1'
variant={'secondary'}
icon={CalendarPlus}
title='Submit iCal URL'
>
Add Feed
</IconButton>
</form>
<div className='space-y-2'>
<Label>Export Your Calendar</Label>
<IconButton
variant='outline_muted'
size='sm'
icon={CalendarArrowUp}
>
Get iCal Export URL
</IconButton>
<IconButton
variant='outline_muted'
size='sm'
className='ml-2'
icon={CalendarArrowDown}
>
Download .ics File
</IconButton>
</div>
</div>
</GroupWrapper>
{/*-------------------- iCalendar Integration --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
</Card>
</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</>
);
}

View file

@ -0,0 +1,130 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import { Switch } from '@/components/ui/switch';
import { useRouter } from 'next/navigation';
export default function NotificationsTab() {
const router = useRouter();
return (
<>
<div className='flex-grow overflow-auto'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- All --------------------*/}
<GroupWrapper title='All'>
<div className='flex items-center justify-between'>
<Label
htmlFor='masterEmailNotifications'
className='font-normal'
>
Enable All Email Notifications
</Label>
<Switch id='masterEmailNotifications' />
</div>
</GroupWrapper>
{/*-------------------- All --------------------*/}
{/*-------------------- Meetings --------------------*/}
<GroupWrapper title='Meetings'>
<div className='space-y-4'>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='newMeetingBookings' className='font-normal'>
New Meeting Bookings
</Label>
<Switch id='newMeetingBookings' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='meetingConfirmations'
className='font-normal'
>
Meeting Confirmations/Cancellations
</Label>
<Switch id='meetingConfirmations' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label
htmlFor='enableMeetingReminders'
className='font-normal'
>
Meeting Reminders
</Label>
<div>
<Switch id='enableMeetingReminders' />
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<Label className='font-normal' htmlFor='remindBefore'>
Remind me before
</Label>
<Select>
<SelectTrigger id='remindBefore'>
<SelectValue placeholder='Select reminder time' />
</SelectTrigger>
<SelectContent>
<SelectItem value='15m'>15 minutes</SelectItem>
<SelectItem value='30m'>30 minutes</SelectItem>
<SelectItem value='1h'>1 hour</SelectItem>
<SelectItem value='1d'>1 day</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</GroupWrapper>
{/*-------------------- Meetings --------------------*/}
{/*-------------------- Social --------------------*/}
<GroupWrapper title='Social'>
<div className='space-y-4'>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='friendRequests' className='font-normal'>
Friend Requests
</Label>
<Switch id='friendRequests' />
</div>
<div className='flex items-center justify-between space-x-2'>
<Label htmlFor='groupUpdates' className='font-normal'>
Group Invitations/Updates
</Label>
<Switch id='groupUpdates' />
</div>
</div>
</GroupWrapper>
{/*-------------------- Social --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
</Card>
</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</>
);
}

View file

@ -0,0 +1,147 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import {
useGetApiUserMe,
usePatchApiUserMePassword,
} from '@/generated/api/user/user';
import LabeledInput from '@/components/custom-ui/labeled-input';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import { BookKey, FileKey, FileKey2, RotateCcwKey } from 'lucide-react';
import useZodForm from '@/lib/hooks/useZodForm';
import { updateUserPasswordServerSchema } from '@/app/api/user/me/validation';
import { useRouter } from 'next/navigation';
export default function PasswordTab() {
const router = useRouter();
const { data } = useGetApiUserMe();
const updatePassword = usePatchApiUserMePassword();
const { handleSubmit, formState, register, setError } = useZodForm(
updateUserPasswordServerSchema,
);
const onSubmit = handleSubmit(async (data) => {
await updatePassword.mutateAsync(
{
data: data,
},
{
onSuccess: () => {
router.refresh();
},
onError: (error) => {
if (error instanceof Error) {
setError('root', {
message: error.response?.data.message,
});
} else {
setError('root', {
message: 'An unknown error occurred.',
});
}
},
},
);
});
if (!data) {
return (
<div className='fixed inset-0 flex items-center justify-center p-4 bg-background/50 backdrop-blur-sm'>
<div className='rounded-lg border bg-card text-card-foreground shadow-xl max-w-[700px] w-full h-auto max-h-[calc(100vh-2rem)] flex flex-col'>
<div className='p-6 border-b'>
<h1 className='text-2xl font-semibold'>Loading Settings...</h1>
</div>
</div>
</div>
);
}
return (
<form onSubmit={onSubmit}>
<div className='flex-grow overflow-auto'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Password Settings</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- Reset Password --------------------*/}
<GroupWrapper title='Reset Password'>
<div className='flex flex-col items-center gap-6'>
<div className='flex flex-col sm:flex-row gap-6 w-full'>
<div className='flex-1'>
<LabeledInput
type='password'
label='Current Password'
icon={FileKey}
{...register('current_password')}
error={formState.errors.current_password?.message}
/>
</div>
<div className='flex-1'>
<LabeledInput
type='password'
label='New Password'
icon={FileKey2}
{...register('new_password')}
error={formState.errors.new_password?.message}
/>
</div>
<div className='flex-1'>
<LabeledInput
type='password'
label='Repeat Password'
icon={RotateCcwKey}
{...register('confirm_new_password')}
error={formState.errors.confirm_new_password?.message}
/>
</div>
<div className='flex items-end'>
<Button
variant={
formState.isValid
? 'outline_secondary'
: 'outline_muted'
}
size='icon'
className='w-full md:size-9'
disabled={!formState.isValid || formState.isSubmitting}
>
<BookKey className='h-[1.2rem] w-[1.2rem]' />
</Button>
</div>
</div>
{formState.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formState.errors.root.message}
</p>
)}
</div>
</GroupWrapper>
{/*-------------------- Reset Password --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
</Card>
</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</form>
);
}

View file

@ -0,0 +1,138 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ScrollableSettingsWrapper } from '@/components/wrappers/settings-scroll';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GroupWrapper } from '@/components/wrappers/group-wrapper';
import { useRouter } from 'next/navigation';
import { IconButton } from '@/components/buttons/icon-button';
import { UserLock } from 'lucide-react';
export default function PrivacyTab() {
const router = useRouter();
return (
<>
<div className='flex-grow overflow-auto'>
<Card className='h-full flex flex-col border-0 shadow-none rounded-none'>
<ScrollableSettingsWrapper>
<CardHeader>
<CardTitle>Sharing & Privacy</CardTitle>
</CardHeader>
<CardContent className='space-y-6 my-2'>
{/*-------------------- Privacy Settigs --------------------*/}
<GroupWrapper title='Privacy Settings'>
<div className='flex flex-col space-y-4'>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='defaultVisibility'>
Default Calendar Visibility
</Label>
<span className='text-sm text-muted-foreground'>
Default setting for new friends.
</span>
<Select>
<SelectTrigger id='defaultVisibility'>
<SelectValue placeholder='Select visibility' />
</SelectTrigger>
<SelectContent>
<SelectItem value='private'>
Private (Only You)
</SelectItem>
<SelectItem value='freebusy'>Free/Busy</SelectItem>
<SelectItem value='fulldetails'>
Full Details
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='whoCanSeeFull'>
Who Can See Your Full Calendar Details?
</Label>
<span className='text-sm text-muted-foreground'>
(Override for Default Visibility)
<br />
<span className='text-sm text-muted-foreground'>
This setting will override the default visibility for
your calendar. You can set specific friends or groups to
see your full calendar details.
</span>
</span>
<Select>
<SelectTrigger id='whoCanSeeFull'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='me'>Only Me</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor='whoCanBook'>
Who Can Book Time With You?
</Label>
<Select>
<SelectTrigger id='whoCanBook'>
<SelectValue placeholder='Select audience' />
</SelectTrigger>
<SelectContent>
<SelectItem value='none'>No One</SelectItem>
<SelectItem value='friends'>My Friends</SelectItem>
<SelectItem value='specific'>
Specific Friends/Groups (manage separately)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-1'>
<Label>Blocked Users</Label>
<span className='text-sm text-muted-foreground'>
Prevent specific users from seeing your calendar or
booking time.
</span>
<IconButton
variant='outline_muted'
size='sm'
icon={UserLock}
>
Manage Blocked Users
</IconButton>
</div>
</div>
</div>
</GroupWrapper>
{/*-------------------- Privacy Settigs --------------------*/}
</CardContent>
</ScrollableSettingsWrapper>
</Card>
</div>
<div>
<CardFooter className='border-t h-[60px] flex content-center justify-between'>
<Button onClick={() => router.back()} variant='secondary'>
Exit
</Button>
<Button variant='primary'>Save Changes</Button>
</CardFooter>
</div>
</>
);
}

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-nonef",
{
variants: {
variant: {

View file

@ -126,7 +126,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
className={cn('flex items-center px-6', className)}
{...props}
/>
);

View file

@ -69,7 +69,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot='dialog-close'
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-2 right-2 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className='sr-only'>Close</span>

View file

@ -0,0 +1,23 @@
import { cn } from '@/lib/utils';
import type * as React from 'react';
interface ScrollableSettingsWrapperProps {
className?: string;
title?: string;
children: React.ReactNode;
}
export function GroupWrapper({
className,
title,
children,
}: ScrollableSettingsWrapperProps) {
return (
<fieldset
className={cn('space-t-4 p-4 border rounded-md shadow-md', className)}
>
<legend className='text-sm font-medium px-1'>{title}</legend>
{children}
</fieldset>
);
}

View file

@ -1,16 +1,16 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type * as React from 'react';
interface ScrollableContentWrapperProps {
children: React.ReactNode;
interface ScrollableSettingsWrapperProps {
className?: string;
children: React.ReactNode;
}
export const ScrollableSettingsWrapper: React.FC<
ScrollableContentWrapperProps
> = ({ children, className = '' }) => {
export function ScrollableSettingsWrapper({
className,
children,
}: ScrollableSettingsWrapperProps) {
return (
<div className={`h-[500px] overflow-y-auto space-y-2 ${className}`}>
{children}
</div>
<div className={cn('overflow-y-auto h-full', className)}>{children}</div>
);
};
}