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 { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { ThemeCustomizer, ThemeCustomizerTrigger } from "@/components/theme-customizer";
|
||||
import { PrefsInitializer } from "@/components/theme-customizer/prefs-initializer";
|
||||
import { useSidebarConfig } from "@/hooks/use-sidebar-config";
|
||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
|
||||
export type ShellUser = {
|
||||
id: string;
|
||||
@@ -25,10 +27,12 @@ export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
@@ -44,6 +48,8 @@ export function DashboardShell({
|
||||
}
|
||||
className={config.collapsible === "none" ? "sidebar-none-mode" : ""}
|
||||
>
|
||||
<PrefsInitializer prefs={initialPrefs} />
|
||||
|
||||
{config.side === "left" ? (
|
||||
<>
|
||||
<AppSidebar
|
||||
@@ -88,6 +94,7 @@ export function DashboardShell({
|
||||
<ThemeCustomizer
|
||||
open={themeCustomizerOpen}
|
||||
onOpenChange={setThemeCustomizerOpen}
|
||||
initialPrefs={initialPrefs}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getActiveContext } from "@/lib/appwrite/active-context";
|
||||
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";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -12,6 +14,14 @@ export default async function DashboardLayout({
|
||||
const ctx = await getActiveContext();
|
||||
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 = {
|
||||
id: ctx.tenantId,
|
||||
name: ctx.settings?.officeName ?? "Çalışma alanı",
|
||||
@@ -24,7 +34,7 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company}>
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -6,78 +6,102 @@ import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useThemeManager } from '@/hooks/use-theme-manager'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
import { useSidebarConfig } from '@/contexts/sidebar-context'
|
||||
import { tweakcnThemes } from '@/config/theme-data'
|
||||
import { ThemeTab } from './theme-tab'
|
||||
import { LayoutTab } from './layout-tab'
|
||||
import { ImportModal } from './import-modal'
|
||||
import { saveThemePrefsAction } from '@/lib/appwrite/theme-prefs-actions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ImportedTheme } from '@/types/theme-customizer'
|
||||
import type { ThemePrefs } from '@/lib/appwrite/theme-prefs-actions'
|
||||
|
||||
interface ThemeCustomizerProps {
|
||||
open: boolean
|
||||
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 { config: sidebarConfig, updateConfig: updateSidebarConfig } = useSidebarConfig()
|
||||
const { theme } = useTheme()
|
||||
const { config: sidebarConfig } = useSidebarConfig()
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState("theme")
|
||||
const [selectedTheme, setSelectedTheme] = React.useState("default")
|
||||
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState("")
|
||||
const [selectedRadius, setSelectedRadius] = React.useState("0.5rem")
|
||||
const [selectedTheme, setSelectedTheme] = React.useState(initialPrefs.colorTheme ?? "default")
|
||||
const [selectedTweakcnTheme, setSelectedTweakcnTheme] = React.useState(initialPrefs.tweakcnTheme ?? "")
|
||||
const [selectedRadius, setSelectedRadius] = React.useState(initialPrefs.radius ?? "0.5rem")
|
||||
const [importModalOpen, setImportModalOpen] = React.useState(false)
|
||||
const [importedTheme, setImportedTheme] = React.useState<ImportedTheme | null>(null)
|
||||
|
||||
const handleReset = () => {
|
||||
// Complete reset to application defaults
|
||||
// Save dark/light mode to Appwrite when it changes (skip first mount)
|
||||
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")
|
||||
setSelectedTweakcnTheme("")
|
||||
setSelectedRadius("0.5rem")
|
||||
setImportedTheme(null) // Clear imported theme
|
||||
setBrandColorsValues({}) // Clear brand colors state
|
||||
|
||||
// 2. Completely remove all custom CSS variables
|
||||
setImportedTheme(null)
|
||||
setBrandColorsValues({})
|
||||
resetTheme()
|
||||
|
||||
// 3. Reset the radius to default
|
||||
applyRadius("0.5rem")
|
||||
|
||||
// 4. Reset sidebar to defaults
|
||||
updateSidebarConfig({ variant: "inset", collapsible: "offcanvas", side: "left" })
|
||||
void saveThemePrefsAction({ colorTheme: "default", tweakcnTheme: "", radius: "0.5rem" })
|
||||
}
|
||||
|
||||
const handleImport = (themeData: ImportedTheme) => {
|
||||
setImportedTheme(themeData)
|
||||
// Clear other selections to indicate custom import is active
|
||||
setSelectedTheme("")
|
||||
setSelectedTweakcnTheme("")
|
||||
|
||||
// Apply the imported theme
|
||||
applyImportedTheme(themeData, isDarkMode)
|
||||
// Imported themes have no stable ID to save
|
||||
}
|
||||
|
||||
const handleImportClick = () => {
|
||||
setImportModalOpen(true)
|
||||
}
|
||||
|
||||
// Re-apply themes when theme mode changes
|
||||
// Re-apply when dark/light toggles
|
||||
React.useEffect(() => {
|
||||
if (importedTheme) {
|
||||
applyImportedTheme(importedTheme, isDarkMode)
|
||||
} else if (selectedTheme) {
|
||||
applyTheme(selectedTheme, isDarkMode)
|
||||
} else if (selectedTweakcnTheme) {
|
||||
const selectedPreset = tweakcnThemes.find(t => t.value === selectedTweakcnTheme)?.preset
|
||||
if (selectedPreset) {
|
||||
applyTweakcnTheme(selectedPreset, isDarkMode)
|
||||
}
|
||||
const preset = tweakcnThemes.find(t => t.value === selectedTweakcnTheme)?.preset
|
||||
if (preset) applyTweakcnTheme(preset, isDarkMode)
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
@@ -85,10 +109,7 @@ export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
||||
side={sidebarConfig.side === "left" ? "right" : "left"}
|
||||
className="w-[400px] p-0 gap-0 pointer-events-auto [&>button]:hidden overflow-hidden flex flex-col"
|
||||
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">
|
||||
@@ -140,13 +161,13 @@ export function ThemeCustomizer({ open, onOpenChange }: ThemeCustomizerProps) {
|
||||
<TabsContent value="theme" className="flex-1 mt-0">
|
||||
<ThemeTab
|
||||
selectedTheme={selectedTheme}
|
||||
setSelectedTheme={setSelectedTheme}
|
||||
setSelectedTheme={handleSetSelectedTheme}
|
||||
selectedTweakcnTheme={selectedTweakcnTheme}
|
||||
setSelectedTweakcnTheme={setSelectedTweakcnTheme}
|
||||
setSelectedTweakcnTheme={handleSetSelectedTweakcnTheme}
|
||||
selectedRadius={selectedRadius}
|
||||
setSelectedRadius={setSelectedRadius}
|
||||
setSelectedRadius={handleSetSelectedRadius}
|
||||
setImportedTheme={setImportedTheme}
|
||||
onImportClick={handleImportClick}
|
||||
onImportClick={() => setImportModalOpen(true)}
|
||||
/>
|
||||
</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 }) {
|
||||
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