diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index abcbb1b..7d21ede 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -36,10 +36,16 @@ export async function loadSettings() { aiProvider: ai.provider, hasApiKey: ai.hasApiKey, colorMode: preferences.colorMode, - workspaceName: branding.applicationName, + workspaceName: branding.organizationName ?? branding.applicationName, + metaTitle: branding.applicationName, + shortName: branding.shortName, primaryColor: branding.primaryColor, - logoUrl: branding.lightLogoUrl ?? branding.darkLogoUrl ?? "", - hasCustomLogo: Boolean(branding.lightLogoFileId || branding.darkLogoFileId), + lightLogoUrl: branding.lightLogoUrl ?? "", + darkLogoUrl: branding.darkLogoUrl ?? "", + faviconUrl: branding.iconUrl ?? "", + hasCustomLightLogo: Boolean(branding.lightLogoFileId), + hasCustomDarkLogo: Boolean(branding.darkLogoFileId), + hasCustomFavicon: Boolean(branding.iconFileId), }; } @@ -137,8 +143,8 @@ export async function saveColorMode(colorMode: string) { } } -export async function saveWorkspaceBranding(formData: FormData) { - let uploadedLogoId: string | null = null; +export async function saveGeneralSettings(formData: FormData) { + const uploadedFileIds: string[] = []; let brandingCommitted = false; let actorForCleanup: Awaited>["actor"] | null = null; @@ -147,97 +153,144 @@ export async function saveWorkspaceBranding(formData: FormData) { actorForCleanup = actor; const workspaceName = cleanText(formData.get("workspaceName")); + const metaTitle = cleanText(formData.get("metaTitle")); + const shortName = cleanText(formData.get("shortName")); 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 (!workspaceName || workspaceName.length > 120) { + return { error: "Workspace adı 1-120 karakter arasında olmalıdır." }; + } + if (!metaTitle || metaTitle.length > 80) { + return { error: "Tarayıcı başlığı 1-80 karakter arasında olmalıdır." }; + } + if (!shortName || shortName.length > 24) { + return { error: "Kısa uygulama adı 1-24 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 lightLogoFileId = await uploadBrandingFile(formData, "lightLogo", "branding_logo", actor); + if (lightLogoFileId) uploadedFileIds.push(lightLogoFileId); + const darkLogoFileId = await uploadBrandingFile(formData, "darkLogo", "branding_logo", actor); + if (darkLogoFileId) uploadedFileIds.push(darkLogoFileId); + const iconFileId = await uploadBrandingFile(formData, "favicon", "branding_icon", actor); + if (iconFileId) uploadedFileIds.push(iconFileId); const updated = brandingService.update(actor, { - applicationName: workspaceName, - shortName: Array.from(workspaceName).slice(0, 24).join(""), + applicationName: metaTitle, + shortName, organizationName: workspaceName, primaryColor, - ...(uploadedLogoId - ? { - lightLogoFileId: uploadedLogoId, - darkLogoFileId: uploadedLogoId, - } - : {}), + ...(lightLogoFileId ? { lightLogoFileId } : {}), + ...(darkLogoFileId ? { darkLogoFileId } : {}), + ...(iconFileId ? { iconFileId } : {}), }); brandingCommitted = true; - if (uploadedLogoId) { - deleteBrandingFilesBestEffort( - actor, - [current.lightLogoFileId, current.darkLogoFileId], - uploadedLogoId, - ); - } + deleteSupersededBrandingFiles(actor, current, updated); revalidateBrandingPaths(); return { success: true, - workspaceName: updated.applicationName, + workspaceName: updated.organizationName ?? updated.applicationName, + metaTitle: updated.applicationName, + shortName: updated.shortName, primaryColor: updated.primaryColor, - logoUrl: updated.lightLogoUrl ?? updated.darkLogoUrl ?? "", - hasCustomLogo: Boolean(updated.lightLogoFileId || updated.darkLogoFileId), + lightLogoUrl: updated.lightLogoUrl ?? "", + darkLogoUrl: updated.darkLogoUrl ?? "", + faviconUrl: updated.iconUrl ?? "", + hasCustomLightLogo: Boolean(updated.lightLogoFileId), + hasCustomDarkLogo: Boolean(updated.darkLogoFileId), + hasCustomFavicon: Boolean(updated.iconFileId), }; } catch (error) { - if (uploadedLogoId && actorForCleanup && !brandingCommitted) { - deleteBrandingFilesBestEffort(actorForCleanup, [uploadedLogoId]); + if (actorForCleanup && !brandingCommitted) { + deleteBrandingFilesBestEffort(actorForCleanup, uploadedFileIds); } - return { error: error instanceof Error ? error.message : "Workspace görünümü kaydedilemedi." }; + return { error: error instanceof Error ? error.message : "Genel ayarlar kaydedilemedi." }; } } -export async function removeWorkspaceLogo() { +type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; + +export async function removeBrandingAsset(asset: BrandingAsset) { try { const { actor } = await requireFreelancerBackend(); const brandingService = getBrandingService(); const current = brandingService.getPublic(); - const updated = brandingService.update(actor, { - lightLogoFileId: null, - darkLogoFileId: null, - }); + const fieldByAsset = { + lightLogo: "lightLogoFileId", + darkLogo: "darkLogoFileId", + favicon: "iconFileId", + } as const; + if (!(asset in fieldByAsset)) { + return { error: "Geçersiz marka görseli." }; + } + const updated = brandingService.update(actor, { [fieldByAsset[asset]]: null }); - deleteBrandingFilesBestEffort(actor, [ - current.lightLogoFileId, - current.darkLogoFileId, - ]); + deleteSupersededBrandingFiles(actor, current, updated); revalidateBrandingPaths(); return { success: true, - logoUrl: updated.lightLogoUrl ?? updated.darkLogoUrl ?? "", - hasCustomLogo: false, + lightLogoUrl: updated.lightLogoUrl ?? "", + darkLogoUrl: updated.darkLogoUrl ?? "", + faviconUrl: updated.iconUrl ?? "", + hasCustomLightLogo: Boolean(updated.lightLogoFileId), + hasCustomDarkLogo: Boolean(updated.darkLogoFileId), + hasCustomFavicon: Boolean(updated.iconFileId), }; } catch (error) { - return { error: error instanceof Error ? error.message : "Logo kaldırılamadı." }; + return { error: error instanceof Error ? error.message : "Marka görseli kaldırılamadı." }; } } +async function uploadBrandingFile( + formData: FormData, + field: "lightLogo" | "darkLogo" | "favicon", + kind: "branding_logo" | "branding_icon", + actor: Awaited>["actor"], +): Promise { + const file = formData.get(field); + if (!(file instanceof File) || file.size === 0) return null; + + return getFileService().upload(actor, { + kind, + originalName: file.name, + claimedMimeType: file.type, + bytes: new Uint8Array(await file.arrayBuffer()), + }).id; +} + +function deleteSupersededBrandingFiles( + actor: Awaited>["actor"], + previous: ReturnType["getPublic"]>, + next: ReturnType["getPublic"]>, +): void { + const activeFileIds = new Set([ + next.lightLogoFileId, + next.darkLogoFileId, + next.iconFileId, + ].filter((id): id is string => Boolean(id))); + + deleteBrandingFilesBestEffort( + actor, + [ + previous.lightLogoFileId, + previous.darkLogoFileId, + previous.iconFileId, + ], + activeFileIds, + ); +} + function deleteBrandingFilesBestEffort( actor: Awaited>["actor"], fileIds: Array, - exceptId?: string, + exceptIds: ReadonlySet = new Set(), ): void { - const uniqueFileIds = new Set(fileIds.filter((id): id is string => Boolean(id && id !== exceptId))); + const uniqueFileIds = new Set(fileIds.filter((id): id is string => Boolean(id && !exceptIds.has(id)))); for (const fileId of uniqueFileIds) { try { getFileService().delete(actor, fileId); diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index d704420..1d34eec 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -5,7 +5,6 @@ import Image from "next/image"; import { Blocks, Brain, - Building2, ImageIcon, Key, Monitor, @@ -20,10 +19,10 @@ import { } from "lucide-react"; import { loadSettings, - removeWorkspaceLogo, + removeBrandingAsset, saveAiSettings, saveColorMode, - saveWorkspaceBranding, + saveGeneralSettings, updatePassword, updateProfile, } from "./actions"; @@ -41,6 +40,7 @@ import { applyColorMode } from "@/components/theme/color-mode-sync"; import { isColorMode, type ColorMode } from "@/lib/color-mode"; type AiProvider = "groq" | "ollama" | "openai" | "gemini"; +type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; const colorModeOptions = [ { @@ -69,7 +69,7 @@ const colorModeOptions = [ }>; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("Workspace"); + const [activeTab, setActiveTab] = useState("Genel"); // Profile States const [firstName, setFirstName] = useState(""); @@ -86,17 +86,30 @@ export default function SettingsPage() { const [colorMode, setColorMode] = useState("system"); const [isSavingColorMode, setIsSavingColorMode] = useState(false); const [workspaceName, setWorkspaceName] = useState("Neta"); + const [metaTitle, setMetaTitle] = useState("Neta"); + const [shortName, setShortName] = useState("Neta"); const [primaryColor, setPrimaryColor] = useState("#C81E1E"); - const [logoUrl, setLogoUrl] = useState(""); - const [pendingLogoUrl, setPendingLogoUrl] = useState(""); - const [hasCustomLogo, setHasCustomLogo] = useState(false); + const [assetUrls, setAssetUrls] = useState>({ + lightLogo: "", + darkLogo: "", + favicon: "", + }); + const [pendingAssetUrls, setPendingAssetUrls] = useState>({ + lightLogo: "", + darkLogo: "", + favicon: "", + }); + const [customAssets, setCustomAssets] = useState>({ + lightLogo: false, + darkLogo: false, + favicon: false, + }); const [isSavingBranding, setIsSavingBranding] = useState(false); - const logoObjectUrlRef = useRef(null); + const assetObjectUrlRefs = useRef>>({}); const tabs = [ - { name: "Workspace", icon: Building2 }, + { name: "Genel", icon: Palette }, { name: "Profile & Account", icon: User }, - { name: "Görünüm", icon: Palette }, { name: "AI Preferences", icon: Brain }, { name: "Security", icon: Shield }, ]; @@ -114,9 +127,19 @@ export default function SettingsPage() { setHasApiKey(settings.hasApiKey); setColorMode(settings.colorMode); setWorkspaceName(settings.workspaceName); + setMetaTitle(settings.metaTitle); + setShortName(settings.shortName); setPrimaryColor(settings.primaryColor); - setLogoUrl(settings.logoUrl); - setHasCustomLogo(settings.hasCustomLogo); + setAssetUrls({ + lightLogo: settings.lightLogoUrl, + darkLogo: settings.darkLogoUrl, + favicon: settings.faviconUrl, + }); + setCustomAssets({ + lightLogo: settings.hasCustomLightLogo, + darkLogo: settings.hasCustomDarkLogo, + favicon: settings.hasCustomFavicon, + }); }; void fetchData(); @@ -124,8 +147,11 @@ export default function SettingsPage() { }, []); useEffect(() => { + const objectUrls = assetObjectUrlRefs.current; return () => { - if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); + for (const objectUrl of Object.values(objectUrls)) { + if (objectUrl) URL.revokeObjectURL(objectUrl); + } }; }, []); @@ -184,43 +210,44 @@ export default function SettingsPage() { } }; - const handleLogoChange = (event: React.ChangeEvent) => { - if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); + const handleBrandingAssetChange = ( + asset: BrandingAsset, + event: React.ChangeEvent, + ) => { + const previousObjectUrl = assetObjectUrlRefs.current[asset]; + if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl); const file = event.target.files?.[0]; const objectUrl = file ? URL.createObjectURL(file) : ""; - logoObjectUrlRef.current = objectUrl || null; - setPendingLogoUrl(objectUrl); + assetObjectUrlRefs.current[asset] = objectUrl || undefined; + setPendingAssetUrls((current) => ({ ...current, [asset]: objectUrl })); }; - const handleWorkspaceBrandingAction = async (formData: FormData) => { + const handleGeneralSettingsAction = async (formData: FormData) => { setIsSavingBranding(true); try { - const response = await saveWorkspaceBranding(formData); + const response = await saveGeneralSettings(formData); if (response.error) { toast.error(response.error); return; } - toast.success("Workspace görünümü güncellendi."); + toast.success("Genel görünüm ve marka ayarları güncellendi."); window.location.reload(); } finally { setIsSavingBranding(false); } }; - const handleRemoveWorkspaceLogo = async () => { + const handleRemoveBrandingAsset = async (asset: BrandingAsset) => { setIsSavingBranding(true); try { - const response = await removeWorkspaceLogo(); + const response = await removeBrandingAsset(asset); if (response.error) { toast.error(response.error); return; } - setLogoUrl(""); - setPendingLogoUrl(""); - setHasCustomLogo(false); - toast.success("Workspace logosu kaldırıldı."); + toast.success("Marka görseli kaldırıldı."); window.location.reload(); } finally { setIsSavingBranding(false); @@ -240,9 +267,9 @@ export default function SettingsPage() { -
+
{/* Settings Sidebar */} -
+
{tabs.map((tab) => { const Icon = tab.icon; return ( @@ -264,86 +291,125 @@ export default function SettingsPage() { {/* Settings Content Area */}
- {activeTab === "Workspace" && ( + {activeTab === "Genel" && (
-

Workspace görünümü

+

Genel görünüm ve marka

- Müşterilerinizin ve sizin gördüğünüz workspace adını, logoyu ve ana rengi yönetin. + Web ve mobil istemcilerde kullanılan workspace kimliğini, marka görsellerini ve tema tercihlerini yönetin.

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

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

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

+ Sekme başlıklarında ve uygulama metadata bilgisinde kullanılır. +

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

- Firma, freelance marka veya çalışma alanı adınız. + Mobil uygulama ve ana ekrana ekleme alanlarında kullanılan kısa ad.

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

Tarayıcı ikonu

- PNG, JPEG, WebP veya GIF; en fazla 5 MB. Şeffaf arka planlı yatay logo önerilir. + Favicon, web manifest ve mobil instance metadata alanlarında kullanılır.

- -
-
- - {hasCustomLogo ? ( - - ) : null} -
- -
- {pendingLogoUrl || logoUrl ? ( - Workspace logo önizlemesi - ) : ( -
-
- )} -
-
+
@@ -384,10 +450,75 @@ export default function SettingsPage() {
+ +
+
+

Tema görünümü

+

+ Arayüzü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 sayfalara anında uygulanır."} +

+
)} @@ -462,75 +593,6 @@ 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" && ( @@ -606,3 +668,93 @@ export default function SettingsPage() {
); } + +type BrandingAssetFieldProps = { + asset: BrandingAsset; + inputId: string; + name: string; + title: string; + description?: string; + accept: string; + currentUrl: string; + pendingUrl: string; + hasCustomAsset: boolean; + previewTone: "light" | "dark" | "neutral"; + compact?: boolean; + disabled: boolean; + onChange: (asset: BrandingAsset, event: React.ChangeEvent) => void; + onRemove: (asset: BrandingAsset) => void; +}; + +function BrandingAssetField({ + asset, + inputId, + name, + title, + description, + accept, + currentUrl, + pendingUrl, + hasCustomAsset, + previewTone, + compact = false, + disabled, + onChange, + onRemove, +}: BrandingAssetFieldProps) { + const previewUrl = pendingUrl || (hasCustomAsset ? currentUrl : ""); + const previewClassName = { + light: "bg-white", + dark: "bg-neutral-950", + neutral: "bg-muted/40", + }[previewTone]; + + return ( +
+
+
+ + {description ?

{description}

: null} +
+ onChange(asset, event)} + className="cursor-pointer" + /> + {hasCustomAsset ? ( + + ) : null} +
+ +
+ {previewUrl ? ( + {`${title} + ) : ( +
+
+ )} +
+
+ ); +} diff --git a/docs/self-hosted-redesign/phase-3-storage-branding.md b/docs/self-hosted-redesign/phase-3-storage-branding.md index 89b06c0..1f00d74 100644 --- a/docs/self-hosted-redesign/phase-3-storage-branding.md +++ b/docs/self-hosted-redesign/phase-3-storage-branding.md @@ -78,7 +78,7 @@ Primary/accent değerleri yalnızca altı haneli hex olarak kabul edilir ve norm Root layout her request'te branding'i SQLite'tan okur ve semantic CSS custom property'lerini doğrudan `` üzerinde üretir. `data-color-mode` ve dark class ilk HTML'de bulunur; system dark tercihi CSS media query ile uygulanır. Bu nedenle token veya color mode için hydration sonrası browser düzeltmesi ve ilk render parlaması gerekmez. -Metadata title, Apple web app adı, theme color ve icon da branding'den üretilir. `manifest.webmanifest` dinamik olarak application name, short name, primary color ve icon referansını kullanır. Light/dark logo alanlarından biri boşsa diğeri fallback olur; file silmek foreign key `set null` ile varsayılan asset durumuna döner. Dashboard ve client portal aynı root layout tokenlarını kullanır. +Metadata title, Apple web app adı, theme color ve favicon da branding'den üretilir. `organizationName` görünür workspace adını, `applicationName` meta title'ı, `shortName` PWA/mobil kısa adını taşır. `manifest.webmanifest` bu alanları, primary rengi ve favicon referansını dinamik kullanır. Light/dark logo alanlarından biri boşsa diğeri fallback olur; ayarlar arayüzü iki tema için ayrı upload ister. File silmek foreign key `set null` ile varsayılan asset durumuna döner. Dashboard ve client portal aynı root layout tokenlarını kullanır. ## Backup ve restore diff --git a/scripts/phase3-storage-smoke.ts b/scripts/phase3-storage-smoke.ts index ff156e4..277fb6a 100644 --- a/scripts/phase3-storage-smoke.ts +++ b/scripts/phase3-storage-smoke.ts @@ -69,23 +69,29 @@ try { assertDomainError(() => fileService.read(clientOne, ownerAvatar.id), "NOT_FOUND"); assertDomainError(() => fileService.read(ownerTwo, ownerAvatar.id), "NOT_FOUND"); - const logo = fileService.upload(ownerOne, imageInput("branding_logo", "logo.png")); + const lightLogo = fileService.upload(ownerOne, imageInput("branding_logo", "light-logo.png")); + const darkLogo = fileService.upload(ownerOne, imageInput("branding_logo", "dark-logo.png")); const icon = fileService.upload(ownerOne, imageInput("branding_icon", "icon.png")); - assertDomainError(() => fileService.readPublicBranding(logo.id), "NOT_FOUND"); + assertDomainError(() => fileService.readPublicBranding(lightLogo.id), "NOT_FOUND"); const branding = brandingService.update(ownerOne, { - applicationName: "Studio Portal", + applicationName: "Studio Portal Meta", shortName: "Studio", + organizationName: "Studio Portal", primaryColor: "#336699", - lightLogoFileId: logo.id, + lightLogoFileId: lightLogo.id, + darkLogoFileId: darkLogo.id, iconFileId: icon.id, defaultColorMode: "dark", radiusScale: "soft", }); - assert.equal(branding.applicationName, "Studio Portal"); + assert.equal(branding.applicationName, "Studio Portal Meta"); + assert.equal(branding.organizationName, "Studio Portal"); assert.equal(branding.primaryColor, "#336699"); assert.equal(branding.accentColor, deriveAccentColor("#336699"), "Accent palette must derive from the single primary color"); - assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo"); - assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id); + assert.notEqual(branding.darkLogoUrl, branding.lightLogoUrl, "Light and dark logos must remain distinct"); + assert.equal(fileService.readPublicBranding(lightLogo.id).metadata.id, lightLogo.id); + assert.equal(fileService.readPublicBranding(darkLogo.id).metadata.id, darkLogo.id); + assert.equal(fileService.readPublicBranding(icon.id).metadata.id, icon.id); assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--poyraz-primary-foreground"]) >= 4.5); assert.ok(contrastRatio(branding.accentColor, branding.cssVariables["--poyraz-accent-foreground"]) >= 4.5); assertDomainError(() => brandingService.update(clientOne, { applicationName: "Attack" }), "FORBIDDEN"); @@ -156,10 +162,15 @@ try { assert.equal(fs.existsSync(avatarPath), false); assert.equal(db.select({ image: schema.user.image }).from(schema.user).where(eq(schema.user.id, ownerOne.authUserId)).get()?.image, null); - const logoPath = resolveStoragePath(uploadsDir, logo.storagePath); - fileService.delete(ownerOne, logo.id); + const logoPath = resolveStoragePath(uploadsDir, lightLogo.storagePath); + fileService.delete(ownerOne, lightLogo.id); assert.equal(fs.existsSync(logoPath), false); assert.equal(brandingService.getPublic().lightLogoFileId, null, "Deleting a logo must clear branding reference"); + assert.equal( + brandingService.getPublic().lightLogoUrl, + brandingService.getPublic().darkLogoUrl, + "Missing light logo must safely fall back to the configured dark logo", + ); console.log("Phase 3 storage smoke passed: uploads, authorization, path safety, branding and deletion verified."); } finally {