feat(settings): expand workspace branding controls

This commit is contained in:
poyrazavsever
2026-07-17 21:56:15 +03:00
parent dafd9eb344
commit b603f260f8
4 changed files with 440 additions and 224 deletions
+106 -53
View File
@@ -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<ReturnType<typeof requireFreelancerBackend>>["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<ReturnType<typeof requireFreelancerBackend>>["actor"],
): Promise<string | null> {
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<ReturnType<typeof requireFreelancerBackend>>["actor"],
previous: ReturnType<ReturnType<typeof getBrandingService>["getPublic"]>,
next: ReturnType<ReturnType<typeof getBrandingService>["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<ReturnType<typeof requireFreelancerBackend>>["actor"],
fileIds: Array<string | null>,
exceptId?: string,
exceptIds: ReadonlySet<string> = 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);
+304 -152
View File
@@ -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<ColorMode>("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<Record<BrandingAsset, string>>({
lightLogo: "",
darkLogo: "",
favicon: "",
});
const [pendingAssetUrls, setPendingAssetUrls] = useState<Record<BrandingAsset, string>>({
lightLogo: "",
darkLogo: "",
favicon: "",
});
const [customAssets, setCustomAssets] = useState<Record<BrandingAsset, boolean>>({
lightLogo: false,
darkLogo: false,
favicon: false,
});
const [isSavingBranding, setIsSavingBranding] = useState(false);
const logoObjectUrlRef = useRef<string | null>(null);
const assetObjectUrlRefs = useRef<Partial<Record<BrandingAsset, string>>>({});
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<HTMLInputElement>) => {
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
const handleBrandingAssetChange = (
asset: BrandingAsset,
event: React.ChangeEvent<HTMLInputElement>,
) => {
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() {
</div>
</div>
<div className="flex flex-col md:flex-row gap-8 flex-1 min-h-0 pb-12">
<div className="flex flex-col gap-8 pb-12 md:flex-row md:items-start">
{/* Settings Sidebar */}
<div className="w-full md:w-64 flex overflow-x-auto md:flex-col gap-2 shrink-0 pb-2 md:pb-0 tiny-scrollbar">
<div className="tiny-scrollbar flex w-full shrink-0 gap-2 overflow-x-auto pb-2 md:sticky md:top-8 md:max-h-[calc(100vh-4rem)] md:w-64 md:self-start md:flex-col md:overflow-y-auto md:pb-0">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
@@ -264,86 +291,125 @@ export default function SettingsPage() {
{/* Settings Content Area */}
<div className="flex-1">
{activeTab === "Workspace" && (
{activeTab === "Genel" && (
<Card className="animate-in fade-in duration-300">
<CardContent className="p-6 sm:p-8">
<div className="mb-7 space-y-1.5">
<h2 className="text-xl font-bold text-foreground">Workspace görünümü</h2>
<h2 className="text-xl font-bold text-foreground">Genel görünüm ve marka</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>
<form action={handleWorkspaceBrandingAction} className="max-w-3xl space-y-8">
<section className="space-y-3">
<div className="space-y-1">
<form action={handleGeneralSettingsAction} className="max-w-4xl space-y-8">
<section className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="workspaceName">Workspace adı</Label>
<p className="text-xs text-muted-foreground">
Firma, freelance marka veya çalışma alanı adınız.
</p>
</div>
<Input
id="workspaceName"
name="workspaceName"
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
minLength={1}
maxLength={120}
required
/>
<p className="text-xs text-muted-foreground">
Firma, freelance marka veya çalışma alanı adınız.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="metaTitle">Tarayıcı başlığı</Label>
<Input
id="metaTitle"
name="metaTitle"
value={metaTitle}
onChange={(event) => setMetaTitle(event.target.value)}
minLength={1}
maxLength={80}
required
/>
<p className="text-xs text-muted-foreground">
Sekme başlıklarında ve uygulama metadata bilgisinde kullanılır.
</p>
</div>
</div>
<div className="max-w-md space-y-2">
<Label htmlFor="shortName">Kısa uygulama adı</Label>
<Input
id="shortName"
name="shortName"
value={shortName}
onChange={(event) => setShortName(event.target.value)}
minLength={1}
maxLength={24}
required
/>
<p className="text-xs text-muted-foreground">
Mobil uygulama ve ana ekrana ekleme alanlarında kullanılan kısa ad.
</p>
</div>
</section>
<section className="border-t border-border pt-7">
<div className="grid gap-5 md:grid-cols-2">
<BrandingAssetField
asset="lightLogo"
inputId="lightLogo"
name="lightLogo"
title="Light logo"
accept="image/png,image/jpeg,image/webp,image/gif"
currentUrl={assetUrls.lightLogo}
pendingUrl={pendingAssetUrls.lightLogo}
hasCustomAsset={customAssets.lightLogo}
previewTone="light"
disabled={isSavingBranding}
onChange={handleBrandingAssetChange}
onRemove={handleRemoveBrandingAsset}
/>
<BrandingAssetField
asset="darkLogo"
inputId="darkLogo"
name="darkLogo"
title="Dark logo"
accept="image/png,image/jpeg,image/webp,image/gif"
currentUrl={assetUrls.darkLogo}
pendingUrl={pendingAssetUrls.darkLogo}
hasCustomAsset={customAssets.darkLogo}
previewTone="dark"
disabled={isSavingBranding}
onChange={handleBrandingAssetChange}
onRemove={handleRemoveBrandingAsset}
/>
</div>
</section>
<section className="space-y-4 border-t border-border pt-7">
<div className="space-y-1">
<Label htmlFor="workspaceLogo">Workspace logosu</Label>
<h3 className="text-sm font-semibold text-foreground">Tarayıcı ikonu</h3>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.7fr)]">
<div className="space-y-3">
<Input
id="workspaceLogo"
name="logo"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleLogoChange}
className="cursor-pointer"
/>
{hasCustomLogo ? (
<Button
type="button"
variant="ghost"
size="sm"
<BrandingAssetField
asset="favicon"
inputId="favicon"
name="favicon"
title="Favicon"
description="Kare PNG önerilir; en fazla 5 MB."
accept="image/png"
currentUrl={assetUrls.favicon}
pendingUrl={pendingAssetUrls.favicon}
hasCustomAsset={customAssets.favicon}
previewTone="neutral"
compact
disabled={isSavingBranding}
onClick={handleRemoveWorkspaceLogo}
className="gap-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
Logoyu kaldır
</Button>
) : null}
</div>
<div className="flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border bg-muted/40 p-4">
{pendingLogoUrl || logoUrl ? (
<Image
src={pendingLogoUrl || logoUrl}
alt="Workspace logo önizlemesi"
width={220}
height={80}
unoptimized
className="max-h-20 w-auto max-w-full object-contain"
onChange={handleBrandingAssetChange}
onRemove={handleRemoveBrandingAsset}
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="h-7 w-7" aria-hidden="true" />
<span className="text-xs">Henüz özel logo yüklenmedi</span>
</div>
)}
</div>
</div>
</section>
<section className="space-y-4 border-t border-border pt-7">
@@ -384,10 +450,75 @@ export default function SettingsPage() {
<div className="flex items-center gap-3 border-t border-border pt-6">
<Button type="submit" loading={isSavingBranding} className="gap-2">
<Upload className="h-4 w-4" aria-hidden="true" />
Workspace görünümünü kaydet
Genel ayarları kaydet
</Button>
</div>
</form>
<section className="mt-10 space-y-5 border-t border-border pt-8">
<div className="space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">Tema görünümü</h3>
<p className="max-w-2xl text-sm text-muted-foreground">
Arayüzün açık, koyu veya cihazınızla uyumlu görünmesini seçin.
</p>
</div>
<RadioGroup
value={colorMode}
onValueChange={handleColorModeChange}
disabled={isSavingColorMode}
aria-label="Tema görünümü"
className="grid max-w-3xl gap-3 sm:grid-cols-3"
>
{colorModeOptions.map((option) => {
const Icon = option.icon;
const selected = colorMode === option.value;
return (
<Label
key={option.value}
htmlFor={`color-mode-${option.value}`}
className={`relative flex min-h-40 cursor-pointer flex-col justify-between gap-5 rounded-md border p-4 transition-[color,background-color,border-color,box-shadow] ${
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border bg-card hover:border-primary/50 hover:bg-muted/40"
} ${isSavingColorMode ? "cursor-wait opacity-70" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`flex h-10 w-10 items-center justify-center rounded-md border ${
selected
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted text-muted-foreground"
}`}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<RadioGroupItem
id={`color-mode-${option.value}`}
value={option.value}
aria-label={option.label}
/>
</div>
<span className="space-y-1">
<span className="block text-sm font-semibold text-foreground">
{option.label}
</span>
<span className="block text-xs font-normal leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</Label>
);
})}
</RadioGroup>
<p className="text-xs text-muted-foreground" aria-live="polite">
{isSavingColorMode
? "Görünüm tercihi kaydediliyor…"
: "Değişiklik tüm sayfalara anında uygulanır."}
</p>
</section>
</CardContent>
</Card>
)}
@@ -462,75 +593,6 @@ export default function SettingsPage() {
</Card>
)}
{activeTab === "Görünüm" && (
<Card className="animate-in fade-in duration-300">
<CardContent className="p-6 sm:p-8">
<div className="mb-6 space-y-1.5">
<h2 className="text-xl font-bold text-foreground">Tema görünümü</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Neta arayüzünün açık, koyu veya cihazınızla uyumlu görünmesini seçin.
</p>
</div>
<RadioGroup
value={colorMode}
onValueChange={handleColorModeChange}
disabled={isSavingColorMode}
aria-label="Tema görünümü"
className="grid max-w-3xl gap-3 sm:grid-cols-3"
>
{colorModeOptions.map((option) => {
const Icon = option.icon;
const selected = colorMode === option.value;
return (
<Label
key={option.value}
htmlFor={`color-mode-${option.value}`}
className={`relative flex min-h-40 cursor-pointer flex-col justify-between gap-5 rounded-md border p-4 transition-[color,background-color,border-color,box-shadow] ${
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border bg-card hover:border-primary/50 hover:bg-muted/40"
} ${isSavingColorMode ? "cursor-wait opacity-70" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`flex h-10 w-10 items-center justify-center rounded-md border ${
selected
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted text-muted-foreground"
}`}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<RadioGroupItem
id={`color-mode-${option.value}`}
value={option.value}
aria-label={option.label}
/>
</div>
<span className="space-y-1">
<span className="block text-sm font-semibold text-foreground">
{option.label}
</span>
<span className="block text-xs font-normal leading-relaxed text-muted-foreground">
{option.description}
</span>
</span>
</Label>
);
})}
</RadioGroup>
<p className="mt-4 text-xs text-muted-foreground" aria-live="polite">
{isSavingColorMode
? "Görünüm tercihi kaydediliyor…"
: "Değişiklik tüm Neta sayfalarına anında uygulanır."}
</p>
</CardContent>
</Card>
)}
{activeTab === "AI Preferences" && (
<Card className="animate-in fade-in duration-300">
<CardContent className="p-6 sm:p-8">
@@ -606,3 +668,93 @@ export default function SettingsPage() {
</div>
);
}
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<HTMLInputElement>) => 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 (
<div className={`grid gap-4 rounded-md border border-border p-4 ${compact ? "max-w-2xl sm:grid-cols-[minmax(0,1fr)_160px]" : ""}`}>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor={inputId}>{title}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<Input
id={inputId}
name={name}
type="file"
accept={accept}
onChange={(event) => onChange(asset, event)}
className="cursor-pointer"
/>
{hasCustomAsset ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => onRemove(asset)}
className="gap-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
Kaldır
</Button>
) : null}
</div>
<div className={`flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border p-4 ${previewClassName}`}>
{previewUrl ? (
<Image
src={previewUrl}
alt={`${title} önizlemesi`}
width={compact ? 72 : 220}
height={compact ? 72 : 80}
unoptimized
className={compact ? "h-16 w-16 object-contain" : "max-h-20 w-auto max-w-full object-contain"}
/>
) : (
<div className={previewTone === "dark" ? "text-neutral-400" : "text-muted-foreground"}>
<ImageIcon className="h-7 w-7" aria-hidden="true" />
</div>
)}
</div>
</div>
);
}
@@ -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 `<html style>` ü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
+20 -9
View File
@@ -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 {