From dafd9eb344ffa334d72e25be555ac58375e9b325 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Fri, 17 Jul 2026 12:47:54 +0300 Subject: [PATCH] feat(ui): add workspace customization and simplify navigation --- .../analytics/analytics-client.tsx | 21 +- app/(dashboard)/calendar/calendar-client.tsx | 18 +- app/(dashboard)/clients/clients-client.tsx | 20 +- app/(dashboard)/dashboard-client.tsx | 20 +- app/(dashboard)/finance/finance-client.tsx | 20 +- app/(dashboard)/journal/journal-client.tsx | 20 +- app/(dashboard)/layout.tsx | 7 +- app/(dashboard)/projects/projects-client.tsx | 20 +- app/(dashboard)/settings/actions.ts | 157 +++++++- app/(dashboard)/settings/page.tsx | 371 +++++++++++++++++- app/(dashboard)/tasks/tasks-client.tsx | 20 +- app/layout.tsx | 18 +- app/portal/layout.tsx | 3 + app/portal/page.tsx | 18 +- components/layout/app-shell.tsx | 149 +++---- components/layout/dashboard-shell.tsx | 13 +- components/layout/portal-shell.tsx | 5 +- components/system/page-header.tsx | 7 - components/theme/color-mode-sync.tsx | 27 ++ lib/color-mode.ts | 10 + scripts/phase3-storage-smoke.ts | 4 +- scripts/phase4-ui-boundary.mjs | 58 ++- server/branding/service.ts | 13 +- server/settings/preferences.ts | 60 +++ 24 files changed, 855 insertions(+), 224 deletions(-) create mode 100644 components/theme/color-mode-sync.tsx create mode 100644 lib/color-mode.ts create mode 100644 server/settings/preferences.ts diff --git a/app/(dashboard)/analytics/analytics-client.tsx b/app/(dashboard)/analytics/analytics-client.tsx index 2e0356e..446d88b 100644 --- a/app/(dashboard)/analytics/analytics-client.tsx +++ b/app/(dashboard)/analytics/analytics-client.tsx @@ -7,7 +7,6 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, PieChart, Pie, Cell, Legend } from "recharts"; -import { BarChart3, Filter } from "lucide-react"; export type AnalyticsData = { metrics: { @@ -45,19 +44,13 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { return (
-
-
- - Analizler -
-
-

- Performans ve Finans Analizi -

-

- Müşteri bazlı gelirler, görev tamamlama oranları ve proje ilerleme grafikleri. -

-
+
+

+ Performans ve Finans Analizi +

+

+ Müşteri bazlı gelirler, görev tamamlama oranları ve proje ilerleme grafikleri. +

diff --git a/app/(dashboard)/calendar/calendar-client.tsx b/app/(dashboard)/calendar/calendar-client.tsx index 6c4db1b..c8e5d26 100644 --- a/app/(dashboard)/calendar/calendar-client.tsx +++ b/app/(dashboard)/calendar/calendar-client.tsx @@ -21,7 +21,7 @@ import { SelectValue, toast, } from "poyraz-ui/molecules"; -import { CalendarDays, Clock, Pencil, Plus, Trash2 } from "lucide-react"; +import { Clock, Pencil, Plus, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; export type CalendarRelationOption = { @@ -89,17 +89,11 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli return (
-
-
- - Planlama -
-
-

Takvim

-

- Toplantı, odak bloğu, deadline, kişisel ve finans etkinliklerini yönet. -

-
+
+

Takvim

+

+ Toplantı, odak bloğu, deadline, kişisel ve finans etkinliklerini yönet. +

-
-
- - CRM & Operasyon -
-
-

- CRM & Müşteriler -

-

- Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet. -

-
+
+

+ CRM & Müşteriler +

+

+ Potansiyel müşterilerini pipeline üzerinden takip et ve müşteri ilişkilerini yönet. +

diff --git a/app/(dashboard)/dashboard-client.tsx b/app/(dashboard)/dashboard-client.tsx index ed2284b..de638a2 100644 --- a/app/(dashboard)/dashboard-client.tsx +++ b/app/(dashboard)/dashboard-client.tsx @@ -64,19 +64,13 @@ export function DashboardClient({ data }: DashboardClientProps) {
{/* Header */}
-
-
- - Genel Bakış -
-
-

- Dashboard -

-

- İş performansını, gelirlerini ve günlük durumunu takip et. -

-
+
+

+ Dashboard +

+

+ İş performansını, gelirlerini ve günlük durumunu takip et. +

diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx index fbd8be0..1c9079d 100644 --- a/app/(dashboard)/finance/finance-client.tsx +++ b/app/(dashboard)/finance/finance-client.tsx @@ -115,19 +115,13 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient return (
-
-
- - Finans -
-
-

- Finans işlemleri -

-

- Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et. -

-
+
+

+ Finans işlemleri +

+

+ Gelir, gider, ödeme durumu ve proje/müşteri bağlantılarını takip et. +

diff --git a/app/(dashboard)/journal/journal-client.tsx b/app/(dashboard)/journal/journal-client.tsx index 65b5c4f..b2032c7 100644 --- a/app/(dashboard)/journal/journal-client.tsx +++ b/app/(dashboard)/journal/journal-client.tsx @@ -77,19 +77,13 @@ export function JournalClient({ logs }: JournalClientProps) { return (
-
-
- - Günlük durum -
-
-

- Mood ve enerji -

-

- Günlük ruh hali, enerji ve çalışma memnuniyetini takip ederek kişisel kapasite trendini gör. -

-
+
+

+ Mood ve enerji +

+

+ Günlük ruh hali, enerji ve çalışma memnuniyetini takip ederek kişisel kapasite trendini gör. +

diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 9fb852b..085598d 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,14 +1,18 @@ import { DashboardShell } from "@/components/layout/dashboard-shell"; +import { domainActorFromSession } from "@/server/auth/domain-actor"; import { requireFreelancer } from "@/server/auth/session"; import { getPublicBranding } from "@/server/branding/runtime"; +import { getUserPreferences } from "@/server/settings/preferences"; export default async function DashboardLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const { user, profile } = await requireFreelancer(); + const context = await requireFreelancer(); + const { user, profile } = context; const branding = getPublicBranding(); + const preferences = getUserPreferences(domainActorFromSession(context)); const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı"; const shortName = @@ -28,6 +32,7 @@ export default async function DashboardLayout({ lightLogoUrl: branding.lightLogoUrl, darkLogoUrl: branding.darkLogoUrl, }} + colorMode={preferences.colorMode} user={{ email: user.email, displayName, diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx index ed8d457..fe7a25c 100644 --- a/app/(dashboard)/projects/projects-client.tsx +++ b/app/(dashboard)/projects/projects-client.tsx @@ -114,19 +114,13 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) { return (
-
-
- - İş ve side project yönetimi -
-
-

- Projeler -

-

- Müşteri işleri ve kişisel side projectleri aynı yerde takip et. -

-
+
+

+ Projeler +

+

+ Müşteri işleri ve kişisel side projectleri aynı yerde takip et. +

diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 0cb7318..abcbb1b 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -1,14 +1,24 @@ "use server"; import { eq } from "drizzle-orm"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; import { revalidatePath } from "next/cache"; import { auth } from "@/server/auth/auth"; +import { + COLOR_MODE_COOKIE, + COLOR_MODE_COOKIE_MAX_AGE, +} from "@/lib/color-mode"; +import { getServerConfig } from "@/server/config"; +import { getBrandingService } from "@/server/branding/runtime"; import { getSqliteConnection } from "@/server/db/client"; import { appProfiles } from "@/server/db/schema"; import { domainActorFromSession } from "@/server/auth/domain-actor"; import { getFileService } from "@/server/files/runtime"; import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; +import { + getUserPreferences, + updateColorModePreference, +} from "@/server/settings/preferences"; import { requireFreelancerBackend } from "@/server/web/freelancer"; import { cleanText } from "@/server/web/form-data"; @@ -16,6 +26,8 @@ export async function loadSettings() { const { context, actor } = await requireFreelancerBackend(); const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/); const ai = getPublicAiSettings(actor); + const preferences = getUserPreferences(actor); + const branding = getBrandingService().getPublic(); return { firstName, @@ -23,6 +35,11 @@ export async function loadSettings() { avatarUrl: context.user.image ?? "", aiProvider: ai.provider, hasApiKey: ai.hasApiKey, + colorMode: preferences.colorMode, + workspaceName: branding.applicationName, + primaryColor: branding.primaryColor, + logoUrl: branding.lightLogoUrl ?? branding.darkLogoUrl ?? "", + hasCustomLogo: Boolean(branding.lightLogoFileId || branding.darkLogoFileId), }; } @@ -98,3 +115,141 @@ export async function saveAiSettings(provider: string, apiKey: string) { return { error: error instanceof Error ? error.message : "Ayarlar kaydedilemedi." }; } } + +export async function saveColorMode(colorMode: string) { + try { + const { actor } = await requireFreelancerBackend(); + const preferences = updateColorModePreference(actor, { colorMode }); + const config = getServerConfig(); + + (await cookies()).set(COLOR_MODE_COOKIE, preferences.colorMode, { + httpOnly: false, + maxAge: COLOR_MODE_COOKIE_MAX_AGE, + path: "/", + sameSite: "lax", + secure: config.secureCookies, + }); + + revalidatePath("/", "layout"); + return { success: true, colorMode: preferences.colorMode }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Tema tercihi kaydedilemedi." }; + } +} + +export async function saveWorkspaceBranding(formData: FormData) { + let uploadedLogoId: string | null = null; + let brandingCommitted = false; + let actorForCleanup: Awaited>["actor"] | null = null; + + try { + const { actor } = await requireFreelancerBackend(); + actorForCleanup = actor; + + const workspaceName = cleanText(formData.get("workspaceName")); + const primaryColor = cleanText(formData.get("primaryColor"))?.toUpperCase() ?? ""; + if (!workspaceName || workspaceName.length > 80) { + return { error: "Workspace adı 1-80 karakter arasında olmalıdır." }; + } + if (!/^#[0-9A-F]{6}$/.test(primaryColor)) { + return { error: "Ana renk #RRGGBB formatında olmalıdır." }; + } + + const brandingService = getBrandingService(); + const fileService = getFileService(); + const current = brandingService.getPublic(); + const logo = formData.get("logo"); + + if (logo instanceof File && logo.size > 0) { + uploadedLogoId = fileService.upload(actor, { + kind: "branding_logo", + originalName: logo.name, + claimedMimeType: logo.type, + bytes: new Uint8Array(await logo.arrayBuffer()), + }).id; + } + + const updated = brandingService.update(actor, { + applicationName: workspaceName, + shortName: Array.from(workspaceName).slice(0, 24).join(""), + organizationName: workspaceName, + primaryColor, + ...(uploadedLogoId + ? { + lightLogoFileId: uploadedLogoId, + darkLogoFileId: uploadedLogoId, + } + : {}), + }); + brandingCommitted = true; + + if (uploadedLogoId) { + deleteBrandingFilesBestEffort( + actor, + [current.lightLogoFileId, current.darkLogoFileId], + uploadedLogoId, + ); + } + + revalidateBrandingPaths(); + return { + success: true, + workspaceName: updated.applicationName, + primaryColor: updated.primaryColor, + logoUrl: updated.lightLogoUrl ?? updated.darkLogoUrl ?? "", + hasCustomLogo: Boolean(updated.lightLogoFileId || updated.darkLogoFileId), + }; + } catch (error) { + if (uploadedLogoId && actorForCleanup && !brandingCommitted) { + deleteBrandingFilesBestEffort(actorForCleanup, [uploadedLogoId]); + } + return { error: error instanceof Error ? error.message : "Workspace görünümü kaydedilemedi." }; + } +} + +export async function removeWorkspaceLogo() { + try { + const { actor } = await requireFreelancerBackend(); + const brandingService = getBrandingService(); + const current = brandingService.getPublic(); + const updated = brandingService.update(actor, { + lightLogoFileId: null, + darkLogoFileId: null, + }); + + deleteBrandingFilesBestEffort(actor, [ + current.lightLogoFileId, + current.darkLogoFileId, + ]); + revalidateBrandingPaths(); + return { + success: true, + logoUrl: updated.lightLogoUrl ?? updated.darkLogoUrl ?? "", + hasCustomLogo: false, + }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Logo kaldırılamadı." }; + } +} + +function deleteBrandingFilesBestEffort( + actor: Awaited>["actor"], + fileIds: Array, + exceptId?: string, +): void { + const uniqueFileIds = new Set(fileIds.filter((id): id is string => Boolean(id && id !== exceptId))); + for (const fileId of uniqueFileIds) { + try { + getFileService().delete(actor, fileId); + } catch { + // The branding update is authoritative; orphan cleanup can safely be retried later. + } + } +} + +function revalidateBrandingPaths(): void { + revalidatePath("/", "layout"); + revalidatePath("/settings"); + revalidatePath("/portal", "layout"); + revalidatePath("/manifest.webmanifest"); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index d7640e7..d704420 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -2,15 +2,74 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; -import { Blocks, Brain, Key, Save, Shield, User } from "lucide-react"; -import { loadSettings, saveAiSettings, updatePassword, updateProfile } from "./actions"; -import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; +import { + Blocks, + Brain, + Building2, + ImageIcon, + Key, + Monitor, + Moon, + Palette, + Save, + Shield, + Sun, + Trash2, + Upload, + User, +} from "lucide-react"; +import { + loadSettings, + removeWorkspaceLogo, + saveAiSettings, + saveColorMode, + saveWorkspaceBranding, + updatePassword, + updateProfile, +} from "./actions"; +import { + Button, + Card, + CardContent, + Input, + Label, + RadioGroup, + RadioGroupItem, +} from "poyraz-ui/atoms"; import { toast } from "poyraz-ui/molecules"; +import { applyColorMode } from "@/components/theme/color-mode-sync"; +import { isColorMode, type ColorMode } from "@/lib/color-mode"; type AiProvider = "groq" | "ollama" | "openai" | "gemini"; +const colorModeOptions = [ + { + value: "light", + label: "Açık", + description: "Her zaman aydınlık renk paletini kullanır.", + icon: Sun, + }, + { + value: "dark", + label: "Koyu", + description: "Her zaman koyu renk paletini kullanır.", + icon: Moon, + }, + { + value: "system", + label: "Sistem", + description: "Cihazınızın görünüm tercihini otomatik takip eder.", + icon: Monitor, + }, +] satisfies Array<{ + value: ColorMode; + label: string; + description: string; + icon: typeof Sun; +}>; + export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("AI Preferences"); + const [activeTab, setActiveTab] = useState("Workspace"); // Profile States const [firstName, setFirstName] = useState(""); @@ -24,9 +83,20 @@ export default function SettingsPage() { const [aiProvider, setAiProvider] = useState("gemini"); const [apiKey, setApiKey] = useState(""); const [hasApiKey, setHasApiKey] = useState(false); + const [colorMode, setColorMode] = useState("system"); + const [isSavingColorMode, setIsSavingColorMode] = useState(false); + const [workspaceName, setWorkspaceName] = useState("Neta"); + const [primaryColor, setPrimaryColor] = useState("#C81E1E"); + const [logoUrl, setLogoUrl] = useState(""); + const [pendingLogoUrl, setPendingLogoUrl] = useState(""); + const [hasCustomLogo, setHasCustomLogo] = useState(false); + const [isSavingBranding, setIsSavingBranding] = useState(false); + const logoObjectUrlRef = useRef(null); const tabs = [ + { name: "Workspace", icon: Building2 }, { name: "Profile & Account", icon: User }, + { name: "Görünüm", icon: Palette }, { name: "AI Preferences", icon: Brain }, { name: "Security", icon: Shield }, ]; @@ -42,12 +112,23 @@ export default function SettingsPage() { setAvatarUrl(settings.avatarUrl); setAiProvider(settings.aiProvider); setHasApiKey(settings.hasApiKey); + setColorMode(settings.colorMode); + setWorkspaceName(settings.workspaceName); + setPrimaryColor(settings.primaryColor); + setLogoUrl(settings.logoUrl); + setHasCustomLogo(settings.hasCustomLogo); }; void fetchData(); return () => { isActive = false; }; }, []); + useEffect(() => { + return () => { + if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); + }; + }, []); + const handleProfileAction = async (formData: FormData) => { const response = await updateProfile(formData); if (response?.error) { @@ -80,21 +161,82 @@ export default function SettingsPage() { toast.success("Yapay Zeka ayarları kaydedildi!"); }; + const handleColorModeChange = async (value: string) => { + if (!isColorMode(value) || value === colorMode || isSavingColorMode) return; + + const previousColorMode = colorMode; + setColorMode(value); + applyColorMode(value); + setIsSavingColorMode(true); + + try { + const response = await saveColorMode(value); + if (response.error) { + setColorMode(previousColorMode); + applyColorMode(previousColorMode); + toast.error(response.error); + return; + } + + toast.success("Görünüm tercihi kaydedildi."); + } finally { + setIsSavingColorMode(false); + } + }; + + const handleLogoChange = (event: React.ChangeEvent) => { + if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); + const file = event.target.files?.[0]; + const objectUrl = file ? URL.createObjectURL(file) : ""; + logoObjectUrlRef.current = objectUrl || null; + setPendingLogoUrl(objectUrl); + }; + + const handleWorkspaceBrandingAction = async (formData: FormData) => { + setIsSavingBranding(true); + try { + const response = await saveWorkspaceBranding(formData); + if (response.error) { + toast.error(response.error); + return; + } + + toast.success("Workspace görünümü güncellendi."); + window.location.reload(); + } finally { + setIsSavingBranding(false); + } + }; + + const handleRemoveWorkspaceLogo = async () => { + setIsSavingBranding(true); + try { + const response = await removeWorkspaceLogo(); + if (response.error) { + toast.error(response.error); + return; + } + + setLogoUrl(""); + setPendingLogoUrl(""); + setHasCustomLogo(false); + toast.success("Workspace logosu kaldırıldı."); + window.location.reload(); + } finally { + setIsSavingBranding(false); + } + }; + return (
-
-
- Settings / {activeTab} -
-
-

- Ayarlar -

-

- Profilinizi, güvenlik ayarlarınızı ve yapay zeka tercihlerinizi yönetin. -

-
+
+

+ Ayarlar +

+

+ Workspace markanızı, profilinizi, görünümü ve sistem tercihlerinizi yönetin. +

@@ -122,6 +264,134 @@ export default function SettingsPage() { {/* Settings Content Area */}
+ {activeTab === "Workspace" && ( + + +
+

Workspace görünümü

+

+ Müşterilerinizin ve sizin gördüğünüz workspace adını, logoyu ve ana rengi yönetin. +

+
+ +
+
+
+ +

+ Firma, freelance marka veya çalışma alanı adınız. +

+
+ setWorkspaceName(event.target.value)} + minLength={1} + maxLength={80} + required + /> +
+ +
+
+ +

+ PNG, JPEG, WebP veya GIF; en fazla 5 MB. Şeffaf arka planlı yatay logo önerilir. +

+
+ +
+
+ + {hasCustomLogo ? ( + + ) : null} +
+ +
+ {pendingLogoUrl || logoUrl ? ( + Workspace logo önizlemesi + ) : ( +
+
+ )} +
+
+
+ +
+
+ +

+ Bir renk seçin; vurgu, focus ve yumuşak yüzey tonları otomatik türetilir. +

+
+ +
+ setPrimaryColor(event.target.value.toUpperCase())} + aria-label="Ana renk seçici" + className="h-11 w-16 shrink-0 cursor-pointer p-1" + /> + setPrimaryColor(event.target.value.toUpperCase())} + pattern="^#[0-9A-Fa-f]{6}$" + maxLength={7} + placeholder="#C81E1E" + required + className="font-mono uppercase" + /> +
+
+ +
+ +
+
+
+
+ )} + {activeTab === "Profile & Account" && ( @@ -192,6 +462,75 @@ export default function SettingsPage() { )} + {activeTab === "Görünüm" && ( + + +
+

Tema görünümü

+

+ Neta arayüzünün açık, koyu veya cihazınızla uyumlu görünmesini seçin. +

+
+ + + {colorModeOptions.map((option) => { + const Icon = option.icon; + const selected = colorMode === option.value; + + return ( + + ); + })} + + +

+ {isSavingColorMode + ? "Görünüm tercihi kaydediliyor…" + : "Değişiklik tüm Neta sayfalarına anında uygulanır."} +

+
+
+ )} + {activeTab === "AI Preferences" && ( diff --git a/app/(dashboard)/tasks/tasks-client.tsx b/app/(dashboard)/tasks/tasks-client.tsx index bb9bddb..a88c480 100644 --- a/app/(dashboard)/tasks/tasks-client.tsx +++ b/app/(dashboard)/tasks/tasks-client.tsx @@ -180,19 +180,13 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) { return (
-
-
- - Günlük operasyon -
-
-

- Görevler -

-

- Proje ve müşteri bağlantılı işleri liste veya basit kanban ile takip et. -

-
+
+

+ Görevler +

+

+ Proje ve müşteri bağlantılı işleri liste veya basit kanban ile takip et. +

diff --git a/app/layout.tsx b/app/layout.tsx index 836170c..dea7db2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,13 @@ import type { Metadata, Viewport } from "next"; import type { CSSProperties } from "react"; +import { cookies } from "next/headers"; import "./globals.css"; import { Geist } from "next/font/google"; import { cn } from "@/lib/utils"; +import { + COLOR_MODE_COOKIE, + isColorMode, +} from "@/lib/color-mode"; import { Toaster } from "poyraz-ui/molecules"; import { getPublicBranding } from "@/server/branding/runtime"; @@ -12,7 +17,7 @@ const colorModeScript = `(() => { const media = window.matchMedia("(prefers-color-scheme: dark)"); const apply = () => root.classList.toggle("dark", root.dataset.colorMode === "dark" || (root.dataset.colorMode === "system" && media.matches)); apply(); - if (root.dataset.colorMode === "system") media.addEventListener("change", apply); + media.addEventListener("change", apply); })();`; export function generateMetadata(): Metadata { @@ -34,17 +39,22 @@ export function generateViewport(): Viewport { return { themeColor: getPublicBranding().primaryColor }; } -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const branding = getPublicBranding(); + const cookieColorMode = (await cookies()).get(COLOR_MODE_COOKIE)?.value; + const colorMode = isColorMode(cookieColorMode) + ? cookieColorMode + : branding.defaultColorMode; + return ( diff --git a/app/portal/layout.tsx b/app/portal/layout.tsx index 992f5a6..19756ff 100644 --- a/app/portal/layout.tsx +++ b/app/portal/layout.tsx @@ -1,5 +1,6 @@ import { PortalShell } from "@/components/layout/portal-shell"; import { getPublicBranding } from "@/server/branding/runtime"; +import { getUserPreferences } from "@/server/settings/preferences"; import { requirePortalBackend } from "@/server/web/portal"; export default async function PortalLayout({ @@ -10,6 +11,7 @@ export default async function PortalLayout({ const { context, actor, service } = await requirePortalBackend(); const { user, profile } = context; const branding = getPublicBranding(); + const preferences = getUserPreferences(actor); const projects = service.listProjects(actor); const progress = projects.length ? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length) @@ -33,6 +35,7 @@ export default async function PortalLayout({ lightLogoUrl: branding.lightLogoUrl, darkLogoUrl: branding.darkLogoUrl, }} + colorMode={preferences.colorMode} user={{ email: user.email, displayName, diff --git a/app/portal/page.tsx b/app/portal/page.tsx index e4eacf8..8c65a46 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, Badge } from "poyraz-ui/atoms"; -import { FolderKanban, CheckCircle2, Clock, Activity, BarChart, type LucideIcon } from "lucide-react"; +import { FolderKanban, CheckCircle2, Clock, BarChart, type LucideIcon } from "lucide-react"; import Link from "next/link"; import { format } from "date-fns"; import { tr } from "date-fns/locale"; @@ -18,17 +18,11 @@ export default async function PortalDashboardPage() { return (
-
-
- - Genel Bakış -
-
-

Müşteri Paneli

-

- Hoş geldiniz, {client.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin. -

-
+
+

Müşteri Paneli

+

+ Hoş geldiniz, {client.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin. +

diff --git a/components/layout/app-shell.tsx b/components/layout/app-shell.tsx index 17432d7..384b66a 100644 --- a/components/layout/app-shell.tsx +++ b/components/layout/app-shell.tsx @@ -1,6 +1,8 @@ "use client"; import { signOut } from "@/app/login/actions"; +import { ColorModeSync } from "@/components/theme/color-mode-sync"; +import type { ColorMode } from "@/lib/color-mode"; import { Card, CardContent, @@ -10,6 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Tooltip, @@ -18,7 +21,6 @@ import { TooltipTrigger, } from "poyraz-ui/molecules"; import { - SidebarBranding, SidebarContent, SidebarFooter, SidebarGroup, @@ -28,7 +30,6 @@ import { SidebarMenuItem, SidebarPanel, SidebarProvider, - SidebarRail, SidebarTrigger, SidebarUserProfile, useSidebar, @@ -72,6 +73,7 @@ type AppShellProps = { settingsHref: string; user: ShellUser; progress?: number; + colorMode?: ColorMode; }; export function AppShell({ @@ -82,12 +84,14 @@ export function AppShell({ settingsHref, user, progress, + colorMode, }: AppShellProps) { const pathname = usePathname(); const sidebarProps = { branding, homeHref, navGroups, pathname, progress, settingsHref, user }; return ( + {colorMode ? : null}
+ - ); @@ -136,8 +139,12 @@ function MobileSidebar(props: SidebarCompositionProps) { return (
- - + + @@ -165,23 +172,14 @@ function SidebarComposition({ }: SidebarCompositionProps) { return ( <> - - - } - title={branding.applicationName} - subtitle={branding.organizationName ?? "Freelancer portalı"} - /> + + + - - - - - Kenar çubuğunu daralt - @@ -239,54 +237,41 @@ function SidebarNavigation({ )); } -function BrandMark({ branding }: { branding: AppShellBranding }) { - const logoUrl = branding.lightLogoUrl ?? branding.darkLogoUrl; - - if (!logoUrl) { - return ( - - {branding.applicationName.slice(0, 1).toLocaleUpperCase("tr-TR")} - - ); - } +function WorkspaceLogo({ + branding, + compact = false, +}: { + branding: AppShellBranding; + compact?: boolean; +}) { + const lightLogoUrl = branding.lightLogoUrl ?? branding.darkLogoUrl ?? "/logo/blackLogoLong.png"; + const darkLogoUrl = branding.darkLogoUrl ?? branding.lightLogoUrl ?? "/logo/lightLogoLong.png"; + const imageClassName = compact + ? "max-h-8 w-auto max-w-full object-contain" + : "max-h-12 w-auto max-w-full object-contain"; return ( - + + {`${branding.applicationName} - {branding.darkLogoUrl ? ( - - ) : null} ); } -function MobileBrand({ branding }: { branding: AppShellBranding }) { - return ( -
- - - - - {branding.applicationName} - -
- ); -} - function ProgressSummary({ progress }: { progress: number }) { const normalizedProgress = Math.max(0, Math.min(100, progress)); @@ -325,8 +310,8 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st - -