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);
+313 -161
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">
<Label htmlFor="workspaceName">Workspace adı</Label>
<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>
<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">
Firma, freelance marka veya çalışma alanı adınız.
Mobil uygulama ve ana ekrana ekleme alanlarında kullanılan kısa ad.
</p>
</div>
<Input
id="workspaceName"
name="workspaceName"
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
minLength={1}
maxLength={80}
required
/>
</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"
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"
/>
) : (
<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>
<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}
onChange={handleBrandingAssetChange}
onRemove={handleRemoveBrandingAsset}
/>
</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>
);
}