feat: tema ayarları Appwrite user prefs ile kalıcı hale getirildi
- saveThemePrefsAction: account.updatePrefs ile mevcut prefs'i merge eder - Dashboard layout'ta account.getPrefs ile prefs server-side yüklenir - PrefsInitializer: mount'ta dark/light, renk teması, radius, sidebar config'ini Appwrite'dan gelen initialPrefs ile uygular - ThemeCustomizer: renk teması / tweakcn / radius / sidebar değişikliği anında Appwrite'a kaydedilir; dark/light toggle useEffect ile izlenir - Sayfa yenileme ve farklı cihazda giriş sonrasında ayarlar korunur
This commit is contained in:
@@ -7,7 +7,9 @@ import { SiteHeader } from "@/components/site-header";
|
|||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||||
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
||||||
|
import { PrefsInitializer } from "@/components/theme-customizer/prefs-initializer";
|
||||||
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
||||||
|
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||||
|
|
||||||
export type ShellUser = {
|
export type ShellUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,10 +27,12 @@ export function DashboardShell({
|
|||||||
user,
|
user,
|
||||||
company,
|
company,
|
||||||
children,
|
children,
|
||||||
|
initialPrefs,
|
||||||
}: {
|
}: {
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
company: ShellCompany;
|
company: ShellCompany;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
initialPrefs: ThemePrefs;
|
||||||
}) {
|
}) {
|
||||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||||
const { config } = useSidebarConfig();
|
const { config } = useSidebarConfig();
|
||||||
@@ -44,6 +48,8 @@ export function DashboardShell({
|
|||||||
}
|
}
|
||||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||||
>
|
>
|
||||||
|
<PrefsInitializer prefs={initialPrefs} />
|
||||||
|
|
||||||
{config.side === "left" ? (
|
{config.side === "left" ? (
|
||||||
<>
|
<>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
@@ -88,6 +94,7 @@ export function DashboardShell({
|
|||||||
<ThemeCustomizer
|
<ThemeCustomizer
|
||||||
open={themeCustomizerOpen}
|
open={themeCustomizerOpen}
|
||||||
onOpenChange={setThemeCustomizerOpen}
|
onOpenChange={setThemeCustomizerOpen}
|
||||||
|
initialPrefs={initialPrefs}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||||
import { getLogoUrl } from "@/lib/appwrite/storage";
|
import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||||
|
import { createSessionClient } from "@/lib/appwrite/server";
|
||||||
|
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||||
import { DashboardShell } from "./dashboard-shell";
|
import { DashboardShell } from "./dashboard-shell";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
@@ -12,6 +14,14 @@ export default async function DashboardLayout({
|
|||||||
const ctx = await getActiveContext();
|
const ctx = await getActiveContext();
|
||||||
if (!ctx) redirect("/onboarding");
|
if (!ctx) redirect("/onboarding");
|
||||||
|
|
||||||
|
let themePrefs: ThemePrefs = {};
|
||||||
|
try {
|
||||||
|
const { account } = await createSessionClient();
|
||||||
|
themePrefs = await account.getPrefs<ThemePrefs>();
|
||||||
|
} catch {
|
||||||
|
// use defaults if prefs unavailable
|
||||||
|
}
|
||||||
|
|
||||||
const company = {
|
const company = {
|
||||||
id: ctx.tenantId,
|
id: ctx.tenantId,
|
||||||
name: ctx.settings?.officeName ?? "Çalışma alanı",
|
name: ctx.settings?.officeName ?? "Çalışma alanı",
|
||||||
@@ -24,7 +34,7 @@ export default async function DashboardLayout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell user={user} company={company}>
|
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
|
||||||
{children}
|
{children}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,78 +6,102 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useThemeManager } from '@/hooks/use-theme-manager'
|
import { useThemeManager } from '@/hooks/use-theme-manager'
|
||||||
|
import { useTheme } from '@/hooks/use-theme'
|
||||||
import { useSidebarConfig } from '@/contexts/sidebar-context'
|
import { useSidebarConfig } from '@/contexts/sidebar-context'
|
||||||
import { tweakcnThemes } from '@/config/theme-data'
|
import { tweakcnThemes } from '@/config/theme-data'
|
||||||
import { ThemeTab } from './theme-tab'
|
import { ThemeTab } from './theme-tab'
|
||||||
import { LayoutTab } from './layout-tab'
|
import { LayoutTab } from './layout-tab'
|
||||||
import { ImportModal } from './import-modal'
|
import { ImportModal } from './import-modal'
|
||||||
|
import { saveThemePrefsAction } from '@/lib/appwrite/theme-prefs-actions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { ImportedTheme } from '@/types/theme-customizer'
|
import type { ImportedTheme } from '@/types/theme-customizer'
|
||||||
|
import type { ThemePrefs } from '@/lib/appwrite/theme-prefs-actions'
|
||||||
|
|
||||||
interface ThemeCustomizerProps {
|
interface ThemeCustomizerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
initialPrefs: ThemePrefs
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
export function ThemeCustomizer({ open, onOpenChange, initialPrefs }: ThemeCustomizerProps) {
|
||||||
const { applyImportedTheme, isDarkMode, resetTheme, applyRadius, setBrandColorsValues, applyTheme, applyTweakcnTheme } = useThemeManager()
|
const { applyImportedTheme, isDarkMode, resetTheme, applyRadius, setBrandColorsValues, applyTheme, applyTweakcnTheme } = useThemeManager()
|
||||||
const { config: sidebarConfig, updateConfig: updateSidebarConfig } = useSidebarConfig()
|
const { theme } = useTheme()
|
||||||
|
const { config: sidebarConfig } = useSidebarConfig()
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState("theme")
|
const [activeTab, setActiveTab] = React.useState("theme")
|
||||||
const [selectedTheme, setSelectedTheme] = React.useState("default")
|
const [selectedTheme, setSelectedTheme] = React.useState(initialPrefs.colorTheme ?? "default")
|
||||||
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState("")
|
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState(initialPrefs.tweakcnTheme ?? "")
|
||||||
const [selectedRadius, setSelectedRadius] = React.useState("0.5rem")
|
const [selectedRadius, setSelectedRadius] = React.useState(initialPrefs.radius ?? "0.5rem")
|
||||||
const [importModalOpen, setImportModalOpen] = React.useState(false)
|
const [importModalOpen, setImportModalOpen] = React.useState(false)
|
||||||
const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null)
|
const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null)
|
||||||
|
|
||||||
const handleReset = () => {
|
// Save dark/light mode to Appwrite when it changes (skip first mount)
|
||||||
// Complete reset to application defaults
|
const themeMountRef = React.useRef(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!themeMountRef.current) { themeMountRef.current = true; return }
|
||||||
|
void saveThemePrefsAction({ theme })
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
// 1. Reset all state variables to initial values
|
// Save sidebar config to Appwrite when it changes (skip first mount)
|
||||||
|
const sidebarMountRef = React.useRef(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!sidebarMountRef.current) { sidebarMountRef.current = true; return }
|
||||||
|
void saveThemePrefsAction({
|
||||||
|
sidebarVariant: sidebarConfig.variant,
|
||||||
|
sidebarCollapsible: sidebarConfig.collapsible,
|
||||||
|
sidebarSide: sidebarConfig.side,
|
||||||
|
})
|
||||||
|
}, [sidebarConfig.variant, sidebarConfig.collapsible, sidebarConfig.side])
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
setSelectedTheme("default")
|
setSelectedTheme("default")
|
||||||
setSelectedTweakcnTheme("")
|
setSelectedTweakcnTheme("")
|
||||||
setSelectedRadius("0.5rem")
|
setSelectedRadius("0.5rem")
|
||||||
setImportedTheme(null) // Clear imported theme
|
setImportedTheme(null)
|
||||||
setBrandColorsValues({}) // Clear brand colors state
|
setBrandColorsValues({})
|
||||||
|
|
||||||
// 2. Completely remove all custom CSS variables
|
|
||||||
resetTheme()
|
resetTheme()
|
||||||
|
|
||||||
// 3. Reset the radius to default
|
|
||||||
applyRadius("0.5rem")
|
applyRadius("0.5rem")
|
||||||
|
void saveThemePrefsAction({ colorTheme: "default", tweakcnTheme: "", radius: "0.5rem" })
|
||||||
// 4. Reset sidebar to defaults
|
|
||||||
updateSidebarConfig({ variant: "inset", collapsible: "offcanvas", side: "left" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImport = (themeData: ImportedTheme) => {
|
const handleImport = (themeData: ImportedTheme) => {
|
||||||
setImportedTheme(themeData)
|
setImportedTheme(themeData)
|
||||||
// Clear other selections to indicate custom import is active
|
|
||||||
setSelectedTheme("")
|
setSelectedTheme("")
|
||||||
setSelectedTweakcnTheme("")
|
setSelectedTweakcnTheme("")
|
||||||
|
|
||||||
// Apply the imported theme
|
|
||||||
applyImportedTheme(themeData, isDarkMode)
|
applyImportedTheme(themeData, isDarkMode)
|
||||||
|
// Imported themes have no stable ID to save
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportClick = () => {
|
// Re-apply when dark/light toggles
|
||||||
setImportModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply themes when theme mode changes
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (importedTheme) {
|
if (importedTheme) {
|
||||||
applyImportedTheme(importedTheme, isDarkMode)
|
applyImportedTheme(importedTheme, isDarkMode)
|
||||||
} else if (selectedTheme) {
|
} else if (selectedTheme) {
|
||||||
applyTheme(selectedTheme, isDarkMode)
|
applyTheme(selectedTheme, isDarkMode)
|
||||||
} else if (selectedTweakcnTheme) {
|
} else if (selectedTweakcnTheme) {
|
||||||
const selectedPreset = tweakcnThemes.find(t => t.value === selectedTweakcnTheme)?.preset
|
const preset = tweakcnThemes.find(t => t.value === selectedTweakcnTheme)?.preset
|
||||||
if (selectedPreset) {
|
if (preset) applyTweakcnTheme(preset, isDarkMode)
|
||||||
applyTweakcnTheme(selectedPreset, isDarkMode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isDarkMode, importedTheme, selectedTheme, selectedTweakcnTheme, applyImportedTheme, applyTheme, applyTweakcnTheme])
|
}, [isDarkMode, importedTheme, selectedTheme, selectedTweakcnTheme, applyImportedTheme, applyTheme, applyTweakcnTheme])
|
||||||
|
|
||||||
|
// Wrappers that also persist to Appwrite
|
||||||
|
const handleSetSelectedTheme = (value: string) => {
|
||||||
|
setSelectedTheme(value)
|
||||||
|
setSelectedTweakcnTheme("")
|
||||||
|
void saveThemePrefsAction({ colorTheme: value, tweakcnTheme: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetSelectedTweakcnTheme = (value: string) => {
|
||||||
|
setSelectedTweakcnTheme(value)
|
||||||
|
setSelectedTheme("")
|
||||||
|
void saveThemePrefsAction({ tweakcnTheme: value, colorTheme: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetSelectedRadius = (value: string) => {
|
||||||
|
setSelectedRadius(value)
|
||||||
|
void saveThemePrefsAction({ radius: value })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
||||||
@@ -85,10 +109,7 @@ export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
|||||||
side={sidebarConfig.side === "left" ? "right" : "left"}
|
side={sidebarConfig.side === "left" ? "right" : "left"}
|
||||||
className="w-[400px] p-0 gap-0 pointer-events-auto [&>button]:hidden overflow-hidden flex flex-col"
|
className="w-[400px] p-0 gap-0 pointer-events-auto [&>button]:hidden overflow-hidden flex flex-col"
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
// Prevent the sheet from closing when dialog is open
|
if (importModalOpen) e.preventDefault()
|
||||||
if (importModalOpen) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetHeader className="space-y-0 p-4 pb-2">
|
<SheetHeader className="space-y-0 p-4 pb-2">
|
||||||
@@ -140,13 +161,13 @@ export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
|||||||
<TabsContent value="theme" className="flex-1 mt-0">
|
<TabsContent value="theme" className="flex-1 mt-0">
|
||||||
<ThemeTab
|
<ThemeTab
|
||||||
selectedTheme={selectedTheme}
|
selectedTheme={selectedTheme}
|
||||||
setSelectedTheme={setSelectedTheme}
|
setSelectedTheme={handleSetSelectedTheme}
|
||||||
selectedTweakcnTheme={selectedTweakcnTheme}
|
selectedTweakcnTheme={selectedTweakcnTheme}
|
||||||
setSelectedTweakcnTheme={setSelectedTweakcnTheme}
|
setSelectedTweakcnTheme={handleSetSelectedTweakcnTheme}
|
||||||
selectedRadius={selectedRadius}
|
selectedRadius={selectedRadius}
|
||||||
setSelectedRadius={setSelectedRadius}
|
setSelectedRadius={handleSetSelectedRadius}
|
||||||
setImportedTheme={setImportedTheme}
|
setImportedTheme={setImportedTheme}
|
||||||
onImportClick={handleImportClick}
|
onImportClick={() => setImportModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -167,7 +188,6 @@ export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating trigger button - positioned dynamically based on sidebar side
|
|
||||||
export function ThemeCustomizerTrigger({ onClick }: { onClick: () => void }) {
|
export function ThemeCustomizerTrigger({ onClick }: { onClick: () => void }) {
|
||||||
const { config: sidebarConfig } = useSidebarConfig()
|
const { config: sidebarConfig } = useSidebarConfig()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useTheme } from "@/hooks/use-theme";
|
||||||
|
import { useSidebarConfig } from "@/contexts/sidebar-context";
|
||||||
|
import { useThemeManager } from "@/hooks/use-theme-manager";
|
||||||
|
import { tweakcnThemes } from "@/config/theme-data";
|
||||||
|
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||||
|
|
||||||
|
export function PrefsInitializer({ prefs }: { prefs: ThemePrefs }) {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
const { updateConfig } = useSidebarConfig();
|
||||||
|
const { applyTheme, applyTweakcnTheme, applyRadius } = useThemeManager();
|
||||||
|
const applied = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (applied.current) return;
|
||||||
|
applied.current = true;
|
||||||
|
|
||||||
|
// Dark/light mode
|
||||||
|
if (prefs.theme) setTheme(prefs.theme);
|
||||||
|
|
||||||
|
// Determine isDark for CSS variable application
|
||||||
|
const isDark =
|
||||||
|
prefs.theme === "dark" ||
|
||||||
|
(prefs.theme !== "light" &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
|
||||||
|
// Radius
|
||||||
|
if (prefs.radius) applyRadius(prefs.radius);
|
||||||
|
|
||||||
|
// Color theme (shadcn presets)
|
||||||
|
if (prefs.colorTheme) {
|
||||||
|
applyTheme(prefs.colorTheme, isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweakcn theme
|
||||||
|
if (prefs.tweakcnTheme) {
|
||||||
|
const preset = tweakcnThemes.find((t) => t.value === prefs.tweakcnTheme)?.preset;
|
||||||
|
if (preset) applyTweakcnTheme(preset, isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar config
|
||||||
|
if (prefs.sidebarVariant || prefs.sidebarCollapsible || prefs.sidebarSide) {
|
||||||
|
updateConfig({
|
||||||
|
...(prefs.sidebarVariant && { variant: prefs.sidebarVariant }),
|
||||||
|
...(prefs.sidebarCollapsible && { collapsible: prefs.sidebarCollapsible }),
|
||||||
|
...(prefs.sidebarSide && { side: prefs.sidebarSide }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createSessionClient } from "@/lib/appwrite/server";
|
||||||
|
|
||||||
|
export interface ThemePrefs {
|
||||||
|
theme?: "dark" | "light" | "system";
|
||||||
|
colorTheme?: string;
|
||||||
|
tweakcnTheme?: string;
|
||||||
|
radius?: string;
|
||||||
|
sidebarVariant?: "sidebar" | "floating" | "inset";
|
||||||
|
sidebarCollapsible?: "offcanvas" | "icon" | "none";
|
||||||
|
sidebarSide?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveThemePrefsAction(update: Partial<ThemePrefs>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account } = await createSessionClient();
|
||||||
|
const existing = await account.getPrefs<Record<string, unknown>>();
|
||||||
|
await account.updatePrefs({ ...existing, ...update });
|
||||||
|
} catch {
|
||||||
|
// best-effort — UI still works without persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user