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.
This commit is contained in:
parent
53cc8cb2b7
commit
8ffe7850ad
25 changed files with 1471 additions and 552 deletions
8
src/app/api/logout/route.ts
Normal file
8
src/app/api/logout/route.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { signOut } from '@/auth';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const GET = async () => {
|
||||
await signOut();
|
||||
|
||||
return NextResponse.redirect('/login');
|
||||
};
|
|
@ -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'),
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
16
src/auth.ts
16
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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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<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 +60,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>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
36
src/components/misc/profile-picture-upload.tsx
Normal file
36
src/components/misc/profile-picture-upload.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
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,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
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' {...props} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
165
src/components/settings/settings-dropdown.tsx
Normal file
165
src/components/settings/settings-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/settings/settings-page.tsx
Normal file
59
src/components/settings/settings-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
282
src/components/settings/tabs/account.tsx
Normal file
282
src/components/settings/tabs/account.tsx
Normal file
|
@ -0,0 +1,282 @@
|
|||
'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';
|
||||
import { toast } from 'sonner';
|
||||
import { ToastInner } from '@/components/misc/toast-inner';
|
||||
|
||||
export default function AccountTab() {
|
||||
const router = useRouter();
|
||||
const { data, refetch } = 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,
|
||||
name:
|
||||
submitData?.name !== data?.data.user.name
|
||||
? submitData?.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: () => {
|
||||
refetch();
|
||||
toast.custom((t) => (
|
||||
<ToastInner
|
||||
toastId={t}
|
||||
title='Settings saved'
|
||||
description='Your account settings have been updated successfully.'
|
||||
variant='success'
|
||||
/>
|
||||
));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.custom((t) => (
|
||||
<ToastInner
|
||||
toastId={t}
|
||||
title='Settings saved'
|
||||
description={
|
||||
error.response?.data.message || 'An unknown error occurred.'
|
||||
}
|
||||
variant='error'
|
||||
/>
|
||||
));
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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='User Name'
|
||||
icon={UserPen}
|
||||
placeholder='User 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 disabled />
|
||||
</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 disabled>
|
||||
<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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
}
|
55
src/components/settings/tabs/appearance.tsx
Normal file
55
src/components/settings/tabs/appearance.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
'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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
226
src/components/settings/tabs/calendar.tsx
Normal file
226
src/components/settings/tabs/calendar.tsx
Normal file
|
@ -0,0 +1,226 @@
|
|||
'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 disabled>
|
||||
<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 disabled>
|
||||
<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 disabled>
|
||||
<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 disabled>
|
||||
<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 disabled />
|
||||
</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' disabled>
|
||||
Set Working Hours
|
||||
</Button>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<LabeledInput
|
||||
disabled
|
||||
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
|
||||
disabled
|
||||
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
|
||||
disabled
|
||||
type='url'
|
||||
label='Import iCal Feed URL'
|
||||
icon={CalendarCheck}
|
||||
placeholder='https://calendar.example.com/feed.ics'
|
||||
defaultValue={''}
|
||||
name='icalUrl'
|
||||
required
|
||||
></LabeledInput>
|
||||
<IconButton
|
||||
disabled
|
||||
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
|
||||
disabled
|
||||
variant='outline_muted'
|
||||
size='sm'
|
||||
icon={CalendarArrowUp}
|
||||
>
|
||||
Get iCal Export URL
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled
|
||||
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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
134
src/components/settings/tabs/notifications.tsx
Normal file
134
src/components/settings/tabs/notifications.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
'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' disabled />
|
||||
</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' disabled />
|
||||
</div>
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<Label
|
||||
htmlFor='enableMeetingReminders'
|
||||
className='font-normal'
|
||||
>
|
||||
Meeting Reminders
|
||||
</Label>
|
||||
<div>
|
||||
<Switch id='enableMeetingReminders' disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<Label className='font-normal' htmlFor='remindBefore'>
|
||||
Remind me before
|
||||
</Label>
|
||||
<Select disabled>
|
||||
<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' disabled />
|
||||
</div>
|
||||
<div className='flex items-center justify-between space-x-2'>
|
||||
<Label htmlFor='groupUpdates' className='font-normal'>
|
||||
Group Invitations/Updates
|
||||
</Label>
|
||||
<Switch id='groupUpdates' disabled />
|
||||
</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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
151
src/components/settings/tabs/password.tsx
Normal file
151
src/components/settings/tabs/password.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
'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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
143
src/components/settings/tabs/privacy.tsx
Normal file
143
src/components/settings/tabs/privacy.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
'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 disabled>
|
||||
<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 disabled>
|
||||
<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 disabled>
|
||||
<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
|
||||
disabled
|
||||
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'
|
||||
type='button'
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
<Button variant='primary'>Save Changes</Button>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
23
src/components/wrappers/group-wrapper.tsx
Normal file
23
src/components/wrappers/group-wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue