add layout for my account, preference, notiication and import
This commit is contained in:
parent
aa52dd0ca4
commit
276a63a4dc
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"files.autoSave": "afterDelay"
|
"files.autoSave": "off"
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ export default async function Layout({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<InboxDrawer showTitle={true} showAvatar={false} />
|
<InboxDrawer showTitle={true} showAvatar={false} />
|
||||||
<ThemeSwitcher showTitle={true} />
|
<ThemeSwitcher showTitle={true} title="Theme" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/app/_components/ui/card";
|
||||||
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import { Badge } from "../../ui/badge";
|
||||||
|
import {
|
||||||
|
IconBrandGoogleAnalytics,
|
||||||
|
IconCsv,
|
||||||
|
IconFileExcel,
|
||||||
|
IconFileWord,
|
||||||
|
IconHtml,
|
||||||
|
IconMarkdown,
|
||||||
|
TablerIcon,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
// Data for import options
|
||||||
|
type ImportOption = {
|
||||||
|
name: string;
|
||||||
|
icon: TablerIcon;
|
||||||
|
beta?: boolean;
|
||||||
|
new?: boolean;
|
||||||
|
sync?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importOptions: ImportOption[] = [
|
||||||
|
{ name: "Excel", icon: IconFileExcel },
|
||||||
|
{ name: "Google Sheets", icon: IconBrandGoogleAnalytics },
|
||||||
|
{ name: "CSV", icon: IconCsv },
|
||||||
|
{ name: "Text & Markdown", icon: IconMarkdown },
|
||||||
|
{ name: "Word", icon: IconFileWord },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ImportData = () => {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-140px)] w-full">
|
||||||
|
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<h1 className="font-medium">Import data</h1>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold mb-2">
|
||||||
|
Import data from other apps and files into Sigap.
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You can import crime data from csv files, text files, and other
|
||||||
|
apps into Sigap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{importOptions.map((option) => (
|
||||||
|
<Card
|
||||||
|
key={option.name}
|
||||||
|
className="cursor-pointer bg-secondary hover:bg-muted-foreground/15"
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-lg">
|
||||||
|
{React.createElement(option.icon)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 ml-3">
|
||||||
|
<p className="text-sm font-medium">{option.name}</p>
|
||||||
|
</div>
|
||||||
|
{option.beta && (
|
||||||
|
<Badge className="text-xs text-red-600 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
|
||||||
|
Beta
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{option.new && (
|
||||||
|
<Badge className="text-xs text-green-500 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{option.sync && (
|
||||||
|
<Badge className="text-xs text-blue-500 w-fit bg-secondary-foreground dark:bg-secondary-foreground hover:bg-secondary-foreground dark:hover:bg-secondary-foreground text-[10px] rounded px-1 py-0">
|
||||||
|
Sync
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center mb-2">
|
||||||
|
Don't see the app you use? Import a ZIP file, and Notion will
|
||||||
|
convert it.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="w-full flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg cursor-pointer p-6 hover:border-gray-400"
|
||||||
|
>
|
||||||
|
<Upload className="w-10 h-10 text-gray-500 mb-2" />
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Drag and drop your ZIP files here
|
||||||
|
</span>
|
||||||
|
<input id="file-upload" type="file" className="hidden" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportData;
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Switch } from "@/app/_components/ui/switch";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
type NotificationPreferences,
|
||||||
|
defaultNotificationPreferences,
|
||||||
|
getNotificationPreferences,
|
||||||
|
saveNotificationPreferences,
|
||||||
|
applyNotificationPreferences,
|
||||||
|
} from "@/utils/notification-cookies-manager";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function NotificationsSetting() {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [lastToastTime, setLastToastTime] = useState(0);
|
||||||
|
const [notificationPreferences, setNotificationPreferences] =
|
||||||
|
useState<NotificationPreferences | null>(null);
|
||||||
|
|
||||||
|
// Show toast with debounce to prevent too many notifications
|
||||||
|
const showSavedToast = () => {
|
||||||
|
// const now = Date.now();
|
||||||
|
// if (now - lastToastTime > 2000) {
|
||||||
|
// toast("Preferences saved");
|
||||||
|
// setLastToastTime(now);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load saved preferences on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const savedPreferences = getNotificationPreferences();
|
||||||
|
setNotificationPreferences(savedPreferences);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading notification preferences:", error);
|
||||||
|
setNotificationPreferences(defaultNotificationPreferences);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Jika masih loading atau notificationPreferences masih null, tampilkan loading
|
||||||
|
if (isLoading || !notificationPreferences) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[calc(100vh-140px)]">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gunakan nilai default jika notificationPreferences belum ada
|
||||||
|
const {
|
||||||
|
mobilePushNotifications,
|
||||||
|
emailNotifications,
|
||||||
|
announcementNotifications,
|
||||||
|
} = notificationPreferences || defaultNotificationPreferences;
|
||||||
|
|
||||||
|
// Generic handler untuk update state dan menyimpan preferensi
|
||||||
|
const updatePreference = (
|
||||||
|
key: keyof NotificationPreferences,
|
||||||
|
value: boolean
|
||||||
|
) => {
|
||||||
|
const newPreferences = { ...notificationPreferences, [key]: value };
|
||||||
|
setNotificationPreferences(newPreferences);
|
||||||
|
saveNotificationPreferences(newPreferences);
|
||||||
|
applyNotificationPreferences(newPreferences);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-140px)] w-full">
|
||||||
|
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-14">
|
||||||
|
<div>
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<h1 className="font-medium">Notifications</h1>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile push notification Section */}
|
||||||
|
<div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Mobile push notifications
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
Receive push notifications on your mobile device when you're
|
||||||
|
offline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={mobilePushNotifications}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updatePreference("mobilePushNotifications", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email notifications Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium">Email Notifications</h2>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Always send email notifications
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
Receive emails about activity in your workspace, even when
|
||||||
|
you're active on the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={emailNotifications}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updatePreference("emailNotifications", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Announcements and Update Emails
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
Receive occasional emails about product launches and new
|
||||||
|
features from Notion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={announcementNotifications}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updatePreference("announcementNotifications", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,433 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { Switch } from "@/app/_components/ui/switch";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||||
|
import { ThemeSwitcher } from "../../theme-switcher";
|
||||||
|
import DropdownSwitcher from "../../custom-dropdown-switcher";
|
||||||
|
import {
|
||||||
|
type CookiePreferences,
|
||||||
|
defaultCookiePreferences,
|
||||||
|
getCookiePreferences,
|
||||||
|
saveCookiePreferences,
|
||||||
|
getLanguagePreference,
|
||||||
|
saveLanguagePreference,
|
||||||
|
getTimezonePreference,
|
||||||
|
saveTimezonePreference,
|
||||||
|
getWeekStartPreference,
|
||||||
|
saveWeekStartPreference,
|
||||||
|
getAutoTimezonePreference,
|
||||||
|
saveAutoTimezonePreference,
|
||||||
|
applyCookiePreferences,
|
||||||
|
} from "@/utils/cookies-manager";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { initialTimezones, TimezoneType } from "@/prisma/data/timezones";
|
||||||
|
import { languages, LanguageType } from "@/prisma/data/languages";
|
||||||
|
|
||||||
|
export default function PreferencesSettings() {
|
||||||
|
const [language, setLanguage] = useState("en-US");
|
||||||
|
const [languageLabel, setLanguageLabel] = useState("English");
|
||||||
|
const [startWeekOnMonday, setStartWeekOnMonday] = useState(false);
|
||||||
|
const [autoTimezone, setAutoTimezone] = useState(true);
|
||||||
|
const [timezone, setTimezone] = useState("Asia/Jakarta");
|
||||||
|
const [timezoneLabel, setTimezoneLabel] = useState("(GMT+7:00) Jakarta");
|
||||||
|
const [timezones, setTimezones] = useState<LanguageType[]>([]);
|
||||||
|
const [availableLanguages, setAvailableLanguages] = useState<TimezoneType[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [cookiePreferences, setCookiePreferences] = useState<CookiePreferences>(
|
||||||
|
defaultCookiePreferences
|
||||||
|
);
|
||||||
|
const [lastToastTime, setLastToastTime] = useState(0);
|
||||||
|
|
||||||
|
// Show toast with debounce to prevent too many notifications
|
||||||
|
const showSavedToast = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Only show toast if it's been at least 2 seconds since the last one
|
||||||
|
// if (now - lastToastTime > 2000) {
|
||||||
|
// toast("Preferences saved");
|
||||||
|
// setLastToastTime(now);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load data when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
// Load cookie preferences
|
||||||
|
const savedCookiePreferences = getCookiePreferences();
|
||||||
|
setCookiePreferences(savedCookiePreferences);
|
||||||
|
|
||||||
|
// Load language preference
|
||||||
|
const savedLanguage = getLanguagePreference();
|
||||||
|
if (savedLanguage) {
|
||||||
|
setLanguage(savedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load week start preference
|
||||||
|
const savedWeekStart = getWeekStartPreference();
|
||||||
|
setStartWeekOnMonday(savedWeekStart);
|
||||||
|
|
||||||
|
// Load auto timezone preference
|
||||||
|
const savedAutoTimezone = getAutoTimezonePreference();
|
||||||
|
setAutoTimezone(savedAutoTimezone);
|
||||||
|
|
||||||
|
// Load timezone preference
|
||||||
|
const savedTimezone = getTimezonePreference();
|
||||||
|
if (savedTimezone) {
|
||||||
|
setTimezone(savedTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load timezone and language data
|
||||||
|
if (Array.isArray(initialTimezones)) {
|
||||||
|
setTimezones(initialTimezones);
|
||||||
|
} else {
|
||||||
|
console.warn("Using fallback timezone data");
|
||||||
|
setTimezones([
|
||||||
|
{
|
||||||
|
value: "Asia/Jakarta",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Jakarta",
|
||||||
|
subLabel: "(GMT+7:00)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(languages)) {
|
||||||
|
setAvailableLanguages(languages);
|
||||||
|
} else {
|
||||||
|
console.warn("Using fallback language data");
|
||||||
|
setAvailableLanguages([
|
||||||
|
{
|
||||||
|
value: "en-US",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "English",
|
||||||
|
subLabel: "English (US)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading data:", error);
|
||||||
|
setTimezones([
|
||||||
|
{
|
||||||
|
value: "Asia/Jakarta",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Jakarta",
|
||||||
|
subLabel: "(GMT+7:00)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setAvailableLanguages([
|
||||||
|
{
|
||||||
|
value: "en-US",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "English",
|
||||||
|
subLabel: "English (US)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update labels when data is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
// Set language label
|
||||||
|
const selectedLanguage = availableLanguages.find(
|
||||||
|
(lang) => lang.value === language
|
||||||
|
);
|
||||||
|
if (selectedLanguage) {
|
||||||
|
setLanguageLabel(selectedLanguage.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timezone label
|
||||||
|
const selectedTimezone = timezones.find((tz) => tz.value === timezone);
|
||||||
|
if (selectedTimezone) {
|
||||||
|
setTimezoneLabel(
|
||||||
|
selectedTimezone.subLabel + " " + selectedTimezone.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoading, language, timezone, availableLanguages, timezones]);
|
||||||
|
|
||||||
|
// Detect browser language
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isLoading &&
|
||||||
|
availableLanguages.length > 0 &&
|
||||||
|
!getLanguagePreference()
|
||||||
|
) {
|
||||||
|
const browserLang = navigator.language;
|
||||||
|
const matchedLang = availableLanguages.find(
|
||||||
|
(lang) =>
|
||||||
|
lang.value === browserLang ||
|
||||||
|
lang.value.split("-")[0] === browserLang.split("-")[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedLang) {
|
||||||
|
setLanguage(matchedLang.value);
|
||||||
|
setLanguageLabel(matchedLang.label);
|
||||||
|
// Save the detected language
|
||||||
|
saveLanguagePreference(matchedLang.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoading, availableLanguages]);
|
||||||
|
|
||||||
|
// Handle language change
|
||||||
|
const handleLanguageChange = (value: string) => {
|
||||||
|
setLanguage(value);
|
||||||
|
const selectedLanguage = availableLanguages.find(
|
||||||
|
(lang) => lang.value === value
|
||||||
|
);
|
||||||
|
if (selectedLanguage) {
|
||||||
|
setLanguageLabel(selectedLanguage.label);
|
||||||
|
}
|
||||||
|
// Save immediately
|
||||||
|
saveLanguagePreference(value);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle timezone change
|
||||||
|
const handleTimezoneChange = (value: string) => {
|
||||||
|
setTimezone(value);
|
||||||
|
const selectedTimezone = timezones.find((tz) => tz.value === value);
|
||||||
|
if (selectedTimezone) {
|
||||||
|
setTimezoneLabel(
|
||||||
|
selectedTimezone.subLabel + " " + selectedTimezone.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Save immediately
|
||||||
|
saveTimezonePreference(value);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle week start change
|
||||||
|
const handleWeekStartChange = (value: boolean) => {
|
||||||
|
setStartWeekOnMonday(value);
|
||||||
|
// Save immediately
|
||||||
|
saveWeekStartPreference(value);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle auto timezone change
|
||||||
|
const handleAutoTimezoneChange = (value: boolean) => {
|
||||||
|
setAutoTimezone(value);
|
||||||
|
// Save immediately
|
||||||
|
saveAutoTimezonePreference(value);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cookie preference changes
|
||||||
|
const handleCookiePreferenceChange = (
|
||||||
|
type: keyof CookiePreferences,
|
||||||
|
value: boolean
|
||||||
|
) => {
|
||||||
|
const newPreferences = { ...cookiePreferences, [type]: value };
|
||||||
|
setCookiePreferences(newPreferences);
|
||||||
|
// Save and apply immediately
|
||||||
|
saveCookiePreferences(newPreferences);
|
||||||
|
applyCookiePreferences(newPreferences);
|
||||||
|
showSavedToast();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-[calc(100vh-140px)]">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-140px)] w-full">
|
||||||
|
<div className="min-h-screen p-8 max-w-4xl mx-auto space-y-14">
|
||||||
|
<div>
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<h1 className="font-medium">Preferences</h1>
|
||||||
|
<Separator className="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<div className="">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="font-medium">Appearance</h2>
|
||||||
|
<div className="flex items-center cursor-pointer">
|
||||||
|
<ThemeSwitcher
|
||||||
|
showTitle={true}
|
||||||
|
prefix={false}
|
||||||
|
suffix={ChevronDown}
|
||||||
|
variant={"ghost"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize how the application looks on your device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language & Time Section */}
|
||||||
|
<div className="">
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<h2 className="font-medium">Language & Time</h2>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Language Subsection */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Language</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Change the language used in the user interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownSwitcher
|
||||||
|
key={language}
|
||||||
|
options={availableLanguages}
|
||||||
|
prefix={false}
|
||||||
|
suffix={ChevronDown}
|
||||||
|
showTitle={true}
|
||||||
|
variant="ghost"
|
||||||
|
title={languageLabel}
|
||||||
|
selectedValue={language}
|
||||||
|
searchable={true}
|
||||||
|
searchPlaceholder="Search languages..."
|
||||||
|
onChange={handleLanguageChange}
|
||||||
|
currentLabel="Current language"
|
||||||
|
selectLabel="Select a language"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start week on Monday Subsection */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Start week on Monday</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will change how all calendars in your app look.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={startWeekOnMonday}
|
||||||
|
onCheckedChange={handleWeekStartChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Timezone Subsection */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Set timezone automatically using your location
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Reminders, notifications and emails are delivered based on
|
||||||
|
your time zone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoTimezone}
|
||||||
|
onCheckedChange={handleAutoTimezoneChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timezone Subsection */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Timezone</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current timezone setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownSwitcher
|
||||||
|
key={timezone}
|
||||||
|
options={timezones}
|
||||||
|
prefix={false}
|
||||||
|
suffix={ChevronDown}
|
||||||
|
showTitle={true}
|
||||||
|
variant="ghost"
|
||||||
|
title={timezoneLabel}
|
||||||
|
selectedValue={timezone}
|
||||||
|
searchable={true}
|
||||||
|
searchPlaceholder="Search for a timezone..."
|
||||||
|
onChange={handleTimezoneChange}
|
||||||
|
currentLabel="Current timezone"
|
||||||
|
selectLabel="Select a timezone"
|
||||||
|
disabled={autoTimezone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Privacy Section */}
|
||||||
|
<div className="">
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
|
<h2 className="font-medium">Privacy</h2>
|
||||||
|
<Separator className="" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Cookies Settings */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Necessary Cookies</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Required for the website to function properly. Cannot be
|
||||||
|
disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={true} disabled className="opacity-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Functional Cookies</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enable enhanced functionality and personalization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cookiePreferences.functional}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleCookiePreferenceChange("functional", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Analytics Cookies</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Help us improve by collecting anonymous information about how
|
||||||
|
you use the site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cookiePreferences.analytics}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleCookiePreferenceChange("analytics", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Marketing Cookies</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Used to display personalized advertisements based on your
|
||||||
|
browsing habits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cookiePreferences.marketing || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleCookiePreferenceChange("marketing", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
|
@ -107,12 +107,14 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
<ScrollArea className="h-[calc(100vh-140px)] w-full ">
|
||||||
<div className="space-y-16 px-20 py-10">
|
<div className="space-y-14 min-h-screen p-8 max-w-4xl mx-auto">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
<h3 className="text-lg font-semibold">Account</h3>
|
<h3 className="text-lg font-semibold">Account</h3>
|
||||||
<Separator className="" />
|
<Separator className="" />
|
||||||
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer group"
|
className="relative cursor-pointer group"
|
||||||
|
@ -186,8 +188,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="">
|
<div className="">
|
||||||
|
<div className="space-y-4 mb-4">
|
||||||
<h3 className="text-base font-medium">Account security</h3>
|
<h3 className="text-base font-medium">Account security</h3>
|
||||||
<Separator className="my-2" />
|
<Separator className="" />
|
||||||
|
</div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
@ -239,8 +243,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-medium">Support</h3>
|
<div className="space-y-4 mb-4">
|
||||||
<Separator className="my-2" />
|
<h3 className="text-base font-medium">Notifications</h3>
|
||||||
|
<Separator className="" />
|
||||||
|
</div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -16,6 +16,8 @@ import {
|
||||||
} from "@/app/_components/ui/avatar";
|
} from "@/app/_components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
IconBell,
|
IconBell,
|
||||||
|
IconFileExport,
|
||||||
|
IconFileImport,
|
||||||
IconFingerprint,
|
IconFingerprint,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconPlugConnected,
|
IconPlugConnected,
|
||||||
|
@ -29,6 +31,10 @@ import { ProfileSettings } from "./profile-settings";
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import NotificationsSetting from "./notification-settings";
|
||||||
|
import PreferencesSettings from "./preference-settings";
|
||||||
|
import ImportData from "./import-data";
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
trigger: React.ReactNode;
|
trigger: React.ReactNode;
|
||||||
|
@ -78,19 +84,13 @@ export function SettingsDialog({
|
||||||
id: "preferences",
|
id: "preferences",
|
||||||
icon: IconSettings,
|
icon: IconSettings,
|
||||||
title: "Preferences",
|
title: "Preferences",
|
||||||
content: <div>Preferences content</div>,
|
content: <PreferencesSettings />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "notifications",
|
id: "notifications",
|
||||||
icon: IconBell,
|
icon: IconBell,
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
content: <div>Notifications content</div>,
|
content: <NotificationsSetting />,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "connections",
|
|
||||||
icon: IconPlugConnected,
|
|
||||||
title: "Connections",
|
|
||||||
content: <div>Connections content</div>,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -98,28 +98,16 @@ export function SettingsDialog({
|
||||||
title: "Workspace",
|
title: "Workspace",
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "import",
|
||||||
icon: IconWorld,
|
icon: IconFileImport,
|
||||||
title: "General",
|
title: "Import",
|
||||||
content: <div>General content</div>,
|
content: <ImportData />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "members",
|
id: "export",
|
||||||
icon: IconUsers,
|
icon: IconFileExport,
|
||||||
title: "Members",
|
title: "Export",
|
||||||
content: <div>Members content</div>,
|
content: <div>Export</div>,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security",
|
|
||||||
icon: IconLock,
|
|
||||||
title: "Security",
|
|
||||||
content: <div>Security content</div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "identity",
|
|
||||||
icon: IconFingerprint,
|
|
||||||
title: "Identity",
|
|
||||||
content: <div>Identity content</div>,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,270 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Check, Search, type LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
|
import { Input } from "@/app/_components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Option<T> = {
|
||||||
|
value: T;
|
||||||
|
prefix?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
subLabel?: string;
|
||||||
|
beta?: boolean;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Variant = "outline" | "ghost";
|
||||||
|
|
||||||
|
interface DropdownSwitcherProps<T> {
|
||||||
|
options: Option<T>[];
|
||||||
|
selectedValue: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
showTitle?: boolean;
|
||||||
|
title?: string;
|
||||||
|
prefix?: boolean;
|
||||||
|
suffix?: LucideIcon;
|
||||||
|
variant?: Variant;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
currentLabel?: string;
|
||||||
|
selectLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_SIZE = 16;
|
||||||
|
|
||||||
|
const DropdownSwitcher = <T extends string>({
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onChange,
|
||||||
|
showTitle = false,
|
||||||
|
title = "Select",
|
||||||
|
prefix = true,
|
||||||
|
suffix,
|
||||||
|
variant = "outline",
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
currentLabel = "Current timezone",
|
||||||
|
selectLabel = "Select a timezone",
|
||||||
|
disabled = false,
|
||||||
|
}: DropdownSwitcherProps<T>) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const currentOption = useMemo(
|
||||||
|
() =>
|
||||||
|
options.find((option) => option.value === selectedValue) || options[0],
|
||||||
|
[selectedValue, options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return options;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return options.filter(
|
||||||
|
(option) =>
|
||||||
|
option.label.toLowerCase().includes(query) ||
|
||||||
|
option.value.toLowerCase().includes(query) ||
|
||||||
|
(option.subLabel && option.subLabel.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}, [options, searchQuery]);
|
||||||
|
|
||||||
|
const { currentOptions, selectableOptions } = useMemo(
|
||||||
|
() => ({
|
||||||
|
currentOptions: filteredOptions.filter(
|
||||||
|
(opt) => opt.value === selectedValue
|
||||||
|
),
|
||||||
|
selectableOptions: filteredOptions.filter(
|
||||||
|
(opt) => opt.value !== selectedValue
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[filteredOptions, selectedValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchable) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isOpen, searchable]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((open: boolean) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOptionSelect = useCallback(
|
||||||
|
(value: T) => {
|
||||||
|
onChange(value);
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchInteraction = useCallback(
|
||||||
|
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={showTitle ? "sm" : "icon"}
|
||||||
|
className={cn(
|
||||||
|
variant !== "outline" ? "" : "border-2",
|
||||||
|
showTitle ? "flex justify-center items-center" : ""
|
||||||
|
)}
|
||||||
|
aria-label={`Current selection: ${selectedValue}`}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{prefix && currentOption.prefix && (
|
||||||
|
<currentOption.prefix
|
||||||
|
size={ICON_SIZE}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showTitle && (
|
||||||
|
<span className="text-muted-foreground font-medium">{title}</span>
|
||||||
|
)}
|
||||||
|
{!prefix &&
|
||||||
|
suffix &&
|
||||||
|
React.createElement(suffix, {
|
||||||
|
size: ICON_SIZE,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[300px] max-h-[400px] overflow-auto"
|
||||||
|
align="start"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 bg-background z-50 px-2 py-2"
|
||||||
|
onClick={handleSearchInteraction}
|
||||||
|
onMouseDown={handleSearchInteraction}
|
||||||
|
>
|
||||||
|
<div className="relative"></div>
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="pl-8 h-9"
|
||||||
|
onClick={handleSearchInteraction}
|
||||||
|
onMouseDown={handleSearchInteraction}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentOptions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">
|
||||||
|
{currentLabel}
|
||||||
|
</p>
|
||||||
|
{currentOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center justify-between py-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
{option.subLabel && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{option.subLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">{selectLabel}</p>
|
||||||
|
{selectableOptions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No timezones found
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
selectableOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center justify-between py-2"
|
||||||
|
onClick={() => handleOptionSelect(option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
{option.subLabel && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{option.subLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{option.beta && (
|
||||||
|
<Badge className="w-fit bg-secondary-foreground dark:bg-muted hover:bg-secondary-foreground dark:hover:bg-muted text-[10px] rounded px-1 py-0">
|
||||||
|
BETA
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownSwitcher;
|
|
@ -14,8 +14,20 @@ import { Laptop, Moon, Sun, type LucideIcon } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
|
||||||
|
type Variant =
|
||||||
|
| "link"
|
||||||
|
| "outline"
|
||||||
|
| "default"
|
||||||
|
| "destructive"
|
||||||
|
| "secondary"
|
||||||
|
| "ghost";
|
||||||
|
|
||||||
interface ThemeSwitcherComponentProps {
|
interface ThemeSwitcherComponentProps {
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
title?: string;
|
||||||
|
prefix?: boolean;
|
||||||
|
suffix?: LucideIcon;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThemeOption = {
|
type ThemeOption = {
|
||||||
|
@ -34,6 +46,10 @@ const themeOptions: ThemeOption[] = [
|
||||||
|
|
||||||
const ThemeSwitcherComponent = ({
|
const ThemeSwitcherComponent = ({
|
||||||
showTitle = false,
|
showTitle = false,
|
||||||
|
title,
|
||||||
|
prefix = true,
|
||||||
|
suffix,
|
||||||
|
variant = "outline",
|
||||||
}: ThemeSwitcherComponentProps) => {
|
}: ThemeSwitcherComponentProps) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
@ -56,18 +72,28 @@ const ThemeSwitcherComponent = ({
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant={variant}
|
||||||
size={showTitle ? "sm" : "icon"}
|
size={showTitle ? "sm" : "icon"}
|
||||||
className={`border-2 ${showTitle ? "flex justify-center items-center" : ""}`}
|
className={`${variant != "outline" ? "" : "border-2"} ${showTitle ? "flex justify-center items-center" : ""}`}
|
||||||
aria-label={`Current theme: theme`}
|
aria-label={`Current theme: ${theme}`}
|
||||||
>
|
>
|
||||||
|
{prefix && (
|
||||||
<currentTheme.icon
|
<currentTheme.icon
|
||||||
size={ICON_SIZE}
|
size={ICON_SIZE}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
{showTitle && (
|
|
||||||
<span className="text-muted-foreground font-medium">Theme</span>
|
|
||||||
)}
|
)}
|
||||||
|
{showTitle && (
|
||||||
|
<span className="text-muted-foreground font-medium">
|
||||||
|
{title || currentTheme.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!prefix &&
|
||||||
|
suffix &&
|
||||||
|
React.createElement(suffix, {
|
||||||
|
size: ICON_SIZE,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-content" align="start">
|
<DropdownMenuContent className="w-content" align="start">
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ChevronDown, LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export type LanguageType = {
|
||||||
|
value: string;
|
||||||
|
prefix?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
subLabel: string;
|
||||||
|
beta?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const languages = [
|
||||||
|
{
|
||||||
|
value: "en-US",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "English",
|
||||||
|
subLabel: "English (US)",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "id-ID",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Indonesian",
|
||||||
|
subLabel: "Indonesia",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ja-JP",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "日本語",
|
||||||
|
subLabel: "Japanese",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ko-KR",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "한국어",
|
||||||
|
subLabel: "Korean",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "zh-CN",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "中文",
|
||||||
|
subLabel: "Chinese (Simplified)",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "es-419",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Español (Latinoamérica)",
|
||||||
|
subLabel: "Spanish (Latin America)",
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
];
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ChevronDown, LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export type TimezoneType = {
|
||||||
|
value: string;
|
||||||
|
prefix?: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
subLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial timezone options
|
||||||
|
export const initialTimezones = [
|
||||||
|
{
|
||||||
|
value: "Asia/Jakarta",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Jakarta",
|
||||||
|
subLabel: "(GMT+7:00)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Asia/Singapore",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Singapore",
|
||||||
|
subLabel: "(GMT+8:00)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Asia/Tokyo",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Tokyo",
|
||||||
|
subLabel: "(GMT+9:00)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Australia/Adelaide",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Adelaide",
|
||||||
|
subLabel: "(GMT+9:30)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Australia/Sydney",
|
||||||
|
prefix: ChevronDown,
|
||||||
|
label: "Sydney",
|
||||||
|
subLabel: "(GMT+10:00)",
|
||||||
|
},
|
||||||
|
];
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Cookie management utility functions
|
||||||
|
|
||||||
|
// Cookie types
|
||||||
|
export type CookiePreferences = {
|
||||||
|
necessary: boolean;
|
||||||
|
functional: boolean;
|
||||||
|
analytics: boolean;
|
||||||
|
marketing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default cookie preferences
|
||||||
|
export const defaultCookiePreferences: CookiePreferences = {
|
||||||
|
necessary: true, // Always true, cannot be disabled
|
||||||
|
functional: false,
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cookie names
|
||||||
|
const COOKIE_PREFERENCES_KEY = "cookie-preferences";
|
||||||
|
const LANGUAGE_PREFERENCE_KEY = "language-preference";
|
||||||
|
const TIMEZONE_PREFERENCE_KEY = "timezone-preference";
|
||||||
|
const WEEK_START_PREFERENCE_KEY = "week-start-preference";
|
||||||
|
const AUTO_TIMEZONE_PREFERENCE_KEY = "auto-timezone-preference";
|
||||||
|
|
||||||
|
// Helper to set a cookie with expiration
|
||||||
|
export const setCookie = (
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
days: number = 365
|
||||||
|
): void => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
const expires = `; expires=${date.toUTCString()}`;
|
||||||
|
document.cookie = `${name}=${value}${expires}; path=/; SameSite=Lax`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get a cookie by name
|
||||||
|
export const getCookie = (name: string): string | null => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const nameEQ = `${name}=`;
|
||||||
|
const ca = document.cookie.split(";");
|
||||||
|
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) === " ") c = c.substring(1, c.length);
|
||||||
|
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to delete a cookie
|
||||||
|
export const deleteCookie = (name: string): void => {
|
||||||
|
setCookie(name, "", -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save cookie preferences
|
||||||
|
export const saveCookiePreferences = (preferences: CookiePreferences): void => {
|
||||||
|
// Always ensure necessary cookies are enabled
|
||||||
|
const prefsToSave = {
|
||||||
|
...preferences,
|
||||||
|
necessary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setCookie(COOKIE_PREFERENCES_KEY, JSON.stringify(prefsToSave));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get cookie preferences
|
||||||
|
export const getCookiePreferences = (): CookiePreferences => {
|
||||||
|
try {
|
||||||
|
const preferences = getCookie(COOKIE_PREFERENCES_KEY);
|
||||||
|
if (preferences) {
|
||||||
|
return { ...defaultCookiePreferences, ...JSON.parse(preferences) };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing cookie preferences:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultCookiePreferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save language preference
|
||||||
|
export const saveLanguagePreference = (language: string): void => {
|
||||||
|
setCookie(LANGUAGE_PREFERENCE_KEY, language);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get language preference
|
||||||
|
export const getLanguagePreference = (): string | null => {
|
||||||
|
return getCookie(LANGUAGE_PREFERENCE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save timezone preference
|
||||||
|
export const saveTimezonePreference = (timezone: string): void => {
|
||||||
|
setCookie(TIMEZONE_PREFERENCE_KEY, timezone);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get timezone preference
|
||||||
|
export const getTimezonePreference = (): string | null => {
|
||||||
|
return getCookie(TIMEZONE_PREFERENCE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save week start preference
|
||||||
|
export const saveWeekStartPreference = (startOnMonday: boolean): void => {
|
||||||
|
setCookie(WEEK_START_PREFERENCE_KEY, startOnMonday ? "monday" : "sunday");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get week start preference
|
||||||
|
export const getWeekStartPreference = (): boolean => {
|
||||||
|
const pref = getCookie(WEEK_START_PREFERENCE_KEY);
|
||||||
|
return pref === "monday";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save auto timezone preference
|
||||||
|
export const saveAutoTimezonePreference = (auto: boolean): void => {
|
||||||
|
setCookie(AUTO_TIMEZONE_PREFERENCE_KEY, auto ? "true" : "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get auto timezone preference
|
||||||
|
export const getAutoTimezonePreference = (): boolean => {
|
||||||
|
const pref = getCookie(AUTO_TIMEZONE_PREFERENCE_KEY);
|
||||||
|
return pref === "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply cookie preferences to the application
|
||||||
|
export const applyCookiePreferences = (
|
||||||
|
preferences: CookiePreferences
|
||||||
|
): void => {
|
||||||
|
// This function would implement the actual cookie policy enforcement
|
||||||
|
// For example, enabling/disabling analytics scripts based on preferences
|
||||||
|
|
||||||
|
if (preferences.analytics) {
|
||||||
|
// Enable analytics scripts
|
||||||
|
console.log("Analytics cookies enabled");
|
||||||
|
// Example: initAnalytics();
|
||||||
|
} else {
|
||||||
|
// Disable analytics scripts
|
||||||
|
console.log("Analytics cookies disabled");
|
||||||
|
// Example: disableAnalytics();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.functional) {
|
||||||
|
// Enable functional cookies
|
||||||
|
console.log("Functional cookies enabled");
|
||||||
|
// Example: enableFunctionalFeatures();
|
||||||
|
} else {
|
||||||
|
// Disable functional cookies
|
||||||
|
console.log("Functional cookies disabled");
|
||||||
|
// Example: disableFunctionalFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.marketing) {
|
||||||
|
// Enable marketing cookies
|
||||||
|
console.log("Marketing cookies enabled");
|
||||||
|
// Example: enableMarketingFeatures();
|
||||||
|
} else {
|
||||||
|
// Disable marketing cookies
|
||||||
|
console.log("Marketing cookies disabled");
|
||||||
|
// Example: disableMarketingFeatures();
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Notification preferences cookie management
|
||||||
|
|
||||||
|
// Notification preferences type
|
||||||
|
export type NotificationPreferences = {
|
||||||
|
mobilePushNotifications: boolean;
|
||||||
|
emailNotifications: boolean;
|
||||||
|
announcementNotifications: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default notification preferences
|
||||||
|
export const defaultNotificationPreferences: NotificationPreferences = {
|
||||||
|
mobilePushNotifications: true,
|
||||||
|
emailNotifications: true,
|
||||||
|
announcementNotifications: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cookie names
|
||||||
|
const NOTIFICATION_PREFERENCES_KEY = "notification-preferences";
|
||||||
|
|
||||||
|
// Helper to set a cookie with expiration
|
||||||
|
export const setCookie = (name: string, value: string, days = 365): void => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
const expires = `; expires=${date.toUTCString()}`;
|
||||||
|
document.cookie = `${name}=${value}${expires}; path=/; SameSite=Lax`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get a cookie by name
|
||||||
|
export const getCookie = (name: string): string | null => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const nameEQ = `${name}=`;
|
||||||
|
const ca = document.cookie.split(";");
|
||||||
|
|
||||||
|
for (let i = 0; i < ca.length; i++) {
|
||||||
|
let c = ca[i];
|
||||||
|
while (c.charAt(0) === " ") c = c.substring(1, c.length);
|
||||||
|
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save notification preferences
|
||||||
|
export const saveNotificationPreferences = (
|
||||||
|
preferences: NotificationPreferences
|
||||||
|
): void => {
|
||||||
|
setCookie(NOTIFICATION_PREFERENCES_KEY, JSON.stringify(preferences));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get notification preferences
|
||||||
|
export const getNotificationPreferences = (): NotificationPreferences => {
|
||||||
|
try {
|
||||||
|
const preferences = getCookie(NOTIFICATION_PREFERENCES_KEY);
|
||||||
|
if (preferences) {
|
||||||
|
return { ...defaultNotificationPreferences, ...JSON.parse(preferences) };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing notification preferences:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultNotificationPreferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply notification preferences to the application
|
||||||
|
export const applyNotificationPreferences = (
|
||||||
|
preferences: NotificationPreferences
|
||||||
|
): void => {
|
||||||
|
// This function would implement the actual notification settings
|
||||||
|
// For example, enabling/disabling push notifications
|
||||||
|
|
||||||
|
if (preferences.mobilePushNotifications) {
|
||||||
|
// Enable push notifications
|
||||||
|
console.log("Push notifications enabled");
|
||||||
|
// Example: requestPushPermission();
|
||||||
|
} else {
|
||||||
|
// Disable push notifications
|
||||||
|
console.log("Push notifications disabled");
|
||||||
|
// Example: disablePushNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.emailNotifications) {
|
||||||
|
// Enable email notifications
|
||||||
|
console.log("Email notifications enabled");
|
||||||
|
// Example: enableEmailNotifications();
|
||||||
|
} else {
|
||||||
|
// Disable email notifications
|
||||||
|
console.log("Email notifications disabled");
|
||||||
|
// Example: disableEmailNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.announcementNotifications) {
|
||||||
|
// Enable announcement notifications
|
||||||
|
console.log("Announcement notifications enabled");
|
||||||
|
// Example: subscribeToAnnouncementEmails();
|
||||||
|
} else {
|
||||||
|
// Disable announcement notifications
|
||||||
|
console.log("Announcement notifications disabled");
|
||||||
|
// Example: unsubscribeFromAnnouncementEmails();
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue