diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts deleted file mode 100644 index c585e18..0000000 --- a/app/(dashboard)/settings/actions.ts +++ /dev/null @@ -1,488 +0,0 @@ -"use server"; - -import { eq } from "drizzle-orm"; -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 { runtimeEvents } from "@/server/db/schema/runtime"; -import { domainActorFromSession } from "@/server/auth/domain-actor"; -import { getFileService } from "@/server/files/runtime"; -import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; -import { - getUserPreferences, - updateLanguagePreference, - updateColorModePreference, -} from "@/server/settings/preferences"; -import { buildLocaleCookie } from "@/server/i18n/locale"; -import { - ContentTranslationService, - parseContentTranslationsFromFormData, -} from "@/server/i18n/content"; -import { getReferenceTranslationKeys, I18nService } from "@/server/i18n/service"; -import { requireFreelancerBackend } from "@/server/web/freelancer"; -import { cleanText } from "@/server/web/form-data"; - -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(); - const i18nService = new I18nService(getSqliteConnection().db); - const locales = i18nService.listLocales(actor); - const i18nSettings = i18nService.getSettings(actor); - const translations = i18nService.listUiTranslations(actor); - const completion = i18nService.getCompletion(actor); - const contentI18n = new ContentTranslationService(getSqliteConnection().db); - const brandingTranslations = contentI18n.listEntityTranslations("branding", "default"); - - return { - firstName, - lastName: lastNameParts.join(" "), - avatarUrl: context.user.image ?? "", - aiProvider: ai.provider, - hasApiKey: ai.hasApiKey, - colorMode: preferences.colorMode, - workspaceName: branding.organizationName ?? branding.applicationName, - metaTitle: branding.applicationName, - shortName: branding.shortName, - primaryColor: branding.primaryColor, - lightLogoUrl: branding.lightLogoUrl ?? "", - darkLogoUrl: branding.darkLogoUrl ?? "", - faviconUrl: branding.iconUrl ?? "", - hasCustomLightLogo: Boolean(branding.lightLogoFileId), - hasCustomDarkLogo: Boolean(branding.darkLogoFileId), - hasCustomFavicon: Boolean(branding.iconFileId), - language: preferences.language, - i18n: { - locales, - defaultLocale: i18nSettings.defaultLocale, - catalogVersion: i18nSettings.catalogVersion, - translations, - completion, - referenceKeys: getReferenceTranslationKeys("all"), - }, - contentTranslations: { - branding: brandingTranslations, - }, - }; -} - -export async function updateProfile(formData: FormData) { - try { - const { context } = await requireFreelancerBackend(); - const firstName = cleanText(formData.get("firstName")); - const lastName = cleanText(formData.get("lastName")); - if (!firstName || !lastName || firstName.length > 80 || lastName.length > 120) { - return { error: "Ad ve soyad zorunludur." }; - } - - const displayName = `${firstName} ${lastName}`; - await auth.api.updateUser({ - headers: await headers(), - body: { name: displayName }, - }); - getSqliteConnection().db - .update(appProfiles) - .set({ displayName, updatedAt: new Date() }) - .where(eq(appProfiles.authUserId, context.user.id)) - .run(); - - const avatar = formData.get("avatar"); - if (avatar instanceof File && avatar.size > 0) { - getFileService().upload(domainActorFromSession(context), { - kind: "avatar", - originalName: avatar.name, - claimedMimeType: avatar.type, - bytes: new Uint8Array(await avatar.arrayBuffer()), - }); - } - - revalidatePath("/settings"); - revalidatePath("/", "layout"); - return { success: true }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Profil güncellenemedi." }; - } -} - -export async function updatePassword(formData: FormData) { - const currentPassword = cleanText(formData.get("currentPassword")); - const newPassword = cleanText(formData.get("password")); - - if (!currentPassword || !newPassword || newPassword.length < 8) { - return { error: "Mevcut şifre zorunludur; yeni şifre en az 8 karakter olmalıdır." }; - } - - try { - await requireFreelancerBackend(); - await auth.api.changePassword({ - headers: await headers(), - body: { - currentPassword, - newPassword, - revokeOtherSessions: true, - }, - }); - return { success: true }; - } catch { - return { error: "Mevcut şifre doğrulanamadı veya şifre güncellenemedi." }; - } -} - -export async function saveAiSettings(provider: string, apiKey: string) { - try { - const { actor } = await requireFreelancerBackend(); - const settings = updateAiSettings(actor, { provider, apiKey }); - revalidatePath("/settings"); - return { success: true, hasApiKey: settings.hasApiKey }; - } catch (error) { - 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 saveLanguagePreference(language: string) { - try { - const { actor } = await requireFreelancerBackend(); - const preferences = updateLanguagePreference(actor, { language }); - - (await cookies()).set(buildLocaleCookie(preferences.language)); - recordI18nEvent(actor.authUserId, `user_language_updated:${preferences.language}`); - revalidatePath("/", "layout"); - revalidatePath("/settings"); - return { success: true, language: preferences.language }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Dil tercihi kaydedilemedi." }; - } -} - -export async function createLocaleAction(input: { - code: string; - name: string; - nativeName: string; - fallbackLocale: string; - textDirection: "ltr" | "rtl"; -}) { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - const locale = service.createLocale(actor, input); - recordI18nEvent(actor.authUserId, `locale_created:${locale.code}`); - revalidateI18nPaths(); - return { success: true, locale }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Dil eklenemedi." }; - } -} - -export async function updateLocaleStatusAction(code: string, status: "draft" | "active" | "archived" | "test") { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - const locale = service.updateLocale(actor, code, { status }); - recordI18nEvent(actor.authUserId, `locale_status_updated:${locale.code}:${locale.status}`); - revalidateI18nPaths(); - return { success: true, locale }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Dil durumu güncellenemedi." }; - } -} - -export async function setDefaultLocaleAction(code: string) { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - const settings = service.setDefaultLocale(actor, code); - recordI18nEvent(actor.authUserId, `default_locale_updated:${settings.defaultLocale}`); - revalidateI18nPaths(); - return { success: true, settings }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Varsayılan dil güncellenemedi." }; - } -} - -export async function saveUiTranslationAction(input: { - locale: string; - namespace: string; - key: string; - value: string; -}) { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - service.upsertUiTranslation(actor, input); - recordI18nEvent(actor.authUserId, `ui_translation_saved:${input.locale}:${input.namespace}.${input.key}`); - revalidateI18nPaths(); - return { success: true }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Çeviri kaydedilemedi." }; - } -} - -export async function resetUiTranslationAction(input: { - locale: string; - namespace: string; - key: string; -}) { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - service.resetUiTranslation(actor, input); - recordI18nEvent(actor.authUserId, `ui_translation_reset:${input.locale}:${input.namespace}.${input.key}`); - revalidateI18nPaths(); - return { success: true }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Çeviri sıfırlanamadı." }; - } -} - -export async function exportI18nAction() { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - recordI18nEvent(actor.authUserId, "i18n_exported"); - return { success: true, package: service.exportPackage(actor) }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Çeviri paketi dışa aktarılamadı." }; - } -} - -export async function previewI18nImportAction(rawJson: string) { - try { - await requireFreelancerBackend(); - const parsed = JSON.parse(rawJson); - const localeCount = Array.isArray(parsed.locales) ? parsed.locales.length : 0; - const translationCount = Array.isArray(parsed.translations) ? parsed.translations.length : 0; - if (parsed.format !== "neta-i18n" || parsed.version !== 1) { - return { error: "Import paketi desteklenmiyor." }; - } - return { success: true, preview: { localeCount, translationCount, defaultLocale: parsed.defaultLocale ?? "tr" } }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Import paketi okunamadı." }; - } -} - -export async function commitI18nImportAction(rawJson: string) { - try { - const { actor } = await requireFreelancerBackend(); - const service = new I18nService(getSqliteConnection().db); - const result = service.importPackage(actor, JSON.parse(rawJson)); - recordI18nEvent(actor.authUserId, `i18n_imported:${result.translations.length}`); - revalidateI18nPaths(); - return { success: true, package: result }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Çeviri paketi içe aktarılamadı." }; - } -} - -export async function saveGeneralSettings(formData: FormData) { - const uploadedFileIds: string[] = []; - let brandingCommitted = false; - let actorForCleanup: Awaited>["actor"] | null = null; - - try { - const { actor } = await requireFreelancerBackend(); - 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 > 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 contentI18n = new ContentTranslationService(getSqliteConnection().db); - const localization = contentI18n.getLocalizationContext(actor); - const brandingTranslations = parseContentTranslationsFromFormData(formData, "branding", localization); - const current = brandingService.getPublic(); - 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: metaTitle, - shortName, - organizationName: workspaceName, - primaryColor, - ...(lightLogoFileId ? { lightLogoFileId } : {}), - ...(darkLogoFileId ? { darkLogoFileId } : {}), - ...(iconFileId ? { iconFileId } : {}), - }); - brandingCommitted = true; - contentI18n.upsertEntityTranslations("branding", "default", brandingTranslations); - - deleteSupersededBrandingFiles(actor, current, updated); - - revalidateBrandingPaths(); - return { - success: true, - workspaceName: updated.organizationName ?? updated.applicationName, - metaTitle: updated.applicationName, - shortName: updated.shortName, - primaryColor: updated.primaryColor, - lightLogoUrl: updated.lightLogoUrl ?? "", - darkLogoUrl: updated.darkLogoUrl ?? "", - faviconUrl: updated.iconUrl ?? "", - hasCustomLightLogo: Boolean(updated.lightLogoFileId), - hasCustomDarkLogo: Boolean(updated.darkLogoFileId), - hasCustomFavicon: Boolean(updated.iconFileId), - }; - } catch (error) { - if (actorForCleanup && !brandingCommitted) { - deleteBrandingFilesBestEffort(actorForCleanup, uploadedFileIds); - } - return { error: error instanceof Error ? error.message : "Genel ayarlar kaydedilemedi." }; - } -} - -type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; - -export async function removeBrandingAsset(asset: BrandingAsset) { - try { - const { actor } = await requireFreelancerBackend(); - const brandingService = getBrandingService(); - const current = brandingService.getPublic(); - 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 }); - - deleteSupersededBrandingFiles(actor, current, updated); - revalidateBrandingPaths(); - return { - success: true, - 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 : "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, - exceptIds: ReadonlySet = new Set(), -): void { - 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); - } 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"); -} - -function revalidateI18nPaths(): void { - revalidatePath("/", "layout"); - revalidatePath("/settings"); - revalidatePath("/portal", "layout"); - revalidatePath("/api/v1/meta"); -} - -function recordI18nEvent(authUserId: string, message: string): void { - getSqliteConnection().db.insert(runtimeEvents).values({ - type: "i18n.settings", - message: `${authUserId}:${message}`, - createdAt: new Date(), - }).run(); -} diff --git a/app/(dashboard)/settings/ai/actions.ts b/app/(dashboard)/settings/ai/actions.ts new file mode 100644 index 0000000..9ab8e2e --- /dev/null +++ b/app/(dashboard)/settings/ai/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; + +export async function saveAiSettingsAction(formData: FormData) { + try { + const { actor } = await requireFreelancerBackend(); + const provider = cleanText(formData.get("provider")) ?? ""; + const model = cleanText(formData.get("model")) ?? ""; + const apiKey = cleanText(formData.get("apiKey")) ?? ""; + const current = getPublicAiSettings(actor); + + if (!["gemini", "openai", "groq", "ollama"].includes(provider)) { + return { errorKey: "settings.ai.errors.provider" }; + } + if (model.length > 200) { + return { errorKey: "settings.ai.errors.model" }; + } + if (apiKey.length > 4_096) { + return { errorKey: "settings.ai.errors.apiKey" }; + } + if ( + provider !== "ollama" + && !apiKey + && (!current.hasApiKey || current.provider !== provider) + ) { + return { errorKey: "settings.ai.errors.apiKeyRequired" }; + } + + const settings = updateAiSettings(actor, { + provider, + model, + apiKey, + }); + revalidatePath("/settings/ai"); + return { + success: true, + hasApiKey: settings.hasApiKey, + }; + } catch (error) { + console.error("AI settings update failed", error); + return { errorKey: "settings.ai.errors.saveFailed" }; + } +} diff --git a/app/(dashboard)/settings/ai/ai-settings-form.tsx b/app/(dashboard)/settings/ai/ai-settings-form.tsx new file mode 100644 index 0000000..4cf2db0 --- /dev/null +++ b/app/(dashboard)/settings/ai/ai-settings-form.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Bot, Check, KeyRound, Save } from "lucide-react"; +import { Badge, Button, Card, CardContent, Input, Label, RadioGroup, RadioGroupItem } from "poyraz-ui/atoms"; +import { Alert, AlertDescription, AlertTitle, toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import type { AiProvider } from "@/server/db/schema/settings"; +import { saveAiSettingsAction } from "./actions"; + +const providers: AiProvider[] = ["gemini", "openai", "groq", "ollama"]; + +export function AiSettingsForm({ + initial, +}: { + initial: { + provider: AiProvider; + model: string | null; + hasApiKey: boolean; + }; +}) { + const t = useTranslations(); + const [pending, startTransition] = useTransition(); + const [provider, setProvider] = useState(initial.provider); + const [savedProvider, setSavedProvider] = useState(initial.provider); + const [model, setModel] = useState(initial.model ?? ""); + const [hasApiKey, setHasApiKey] = useState(initial.hasApiKey); + const providerHasApiKey = hasApiKey && provider === savedProvider; + + function submit(formData: FormData) { + startTransition(async () => { + const result = await saveAiSettingsAction(formData); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + setHasApiKey(Boolean(result.hasApiKey)); + setSavedProvider(provider); + toast.success(t("settings.ai.messages.saved")); + }); + } + + return ( + + +
+

+ {t("settings.ai.title")} +

+

+ {t("settings.ai.description")} +

+
+ +
+
+
+

+ {t("settings.ai.provider.title")} +

+

+ {t("settings.ai.provider.description")} +

+
+ { + setProvider(value as AiProvider); + setModel(""); + }} + className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4" + aria-label={t("settings.ai.provider.ariaLabel")} + > + {providers.map((option) => ( + + ))} + +
+ +
+
+ + setModel(event.target.value)} + maxLength={200} + placeholder={t(`settings.ai.providers.${provider}.defaultModel`)} + /> +

+ {t("settings.ai.help.model")} +

+
+ + {provider === "ollama" ? ( + + + ) : ( +
+
+ + {providerHasApiKey && ( + + + )} +
+ +

+

+
+ )} +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/settings/ai/page.tsx b/app/(dashboard)/settings/ai/page.tsx new file mode 100644 index 0000000..5585e4b --- /dev/null +++ b/app/(dashboard)/settings/ai/page.tsx @@ -0,0 +1,9 @@ +import { getPublicAiSettings } from "@/server/settings/ai"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { AiSettingsForm } from "./ai-settings-form"; + +export default async function AiSettingsPage() { + const { actor } = await requireFreelancerBackend(); + const settings = getPublicAiSettings(actor); + return ; +} diff --git a/app/(dashboard)/settings/appearance/actions.ts b/app/(dashboard)/settings/appearance/actions.ts new file mode 100644 index 0000000..e13dda6 --- /dev/null +++ b/app/(dashboard)/settings/appearance/actions.ts @@ -0,0 +1,152 @@ +"use server"; + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { + COLOR_MODE_COOKIE, + COLOR_MODE_COOKIE_MAX_AGE, +} from "@/lib/color-mode"; +import { getBrandingService } from "@/server/branding/runtime"; +import { getServerConfig } from "@/server/config"; +import { getFileService } from "@/server/files/runtime"; +import { updateColorModePreference } from "@/server/settings/preferences"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; + +type BrandingAsset = "darkLogo" | "favicon" | "lightLogo"; +type Branding = ReturnType["getPublic"]>; +type Actor = Awaited>["actor"]; + +export async function saveAppearanceSettingsAction(formData: FormData) { + const uploadedFileIds: string[] = []; + let actorForCleanup: Actor | null = null; + let brandingCommitted = false; + + try { + const { actor } = await requireFreelancerBackend(); + actorForCleanup = actor; + const primaryColor = cleanText(formData.get("primaryColor"))?.toUpperCase() ?? ""; + if (!/^#[0-9A-F]{6}$/.test(primaryColor)) { + return { errorKey: "settings.appearance.errors.primaryColor" }; + } + + const brandingService = getBrandingService(); + const current = brandingService.getPublic(); + 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, { + primaryColor, + ...(lightLogoFileId ? { lightLogoFileId } : {}), + ...(darkLogoFileId ? { darkLogoFileId } : {}), + ...(iconFileId ? { iconFileId } : {}), + }); + brandingCommitted = true; + deleteSupersededBrandingFiles(actor, current, updated); + revalidateAppearance(); + return { success: true }; + } catch (error) { + if (actorForCleanup && !brandingCommitted) { + deleteBrandingFilesBestEffort(actorForCleanup, uploadedFileIds); + } + console.error("Appearance settings update failed", error); + return { errorKey: "settings.appearance.errors.saveFailed" }; + } +} + +export async function saveColorModeAction(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) { + console.error("Color mode update failed", error); + return { errorKey: "settings.appearance.errors.colorMode" }; + } +} + +export async function removeAppearanceAssetAction(asset: BrandingAsset) { + try { + const { actor } = await requireFreelancerBackend(); + const fieldByAsset = { + lightLogo: "lightLogoFileId", + darkLogo: "darkLogoFileId", + favicon: "iconFileId", + } as const; + if (!(asset in fieldByAsset)) { + return { errorKey: "settings.appearance.errors.invalidAsset" }; + } + const brandingService = getBrandingService(); + const current = brandingService.getPublic(); + const updated = brandingService.update(actor, { [fieldByAsset[asset]]: null }); + deleteSupersededBrandingFiles(actor, current, updated); + revalidateAppearance(); + return { success: true }; + } catch (error) { + console.error("Branding asset removal failed", error); + return { errorKey: "settings.appearance.errors.removeFailed" }; + } +} + +async function uploadBrandingFile( + formData: FormData, + field: BrandingAsset, + kind: "branding_icon" | "branding_logo", + actor: Actor, +) { + 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: Actor, previous: Branding, next: Branding) { + 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: Actor, + fileIds: Array, + exceptIds: ReadonlySet = new Set(), +) { + const ids = new Set(fileIds.filter((id): id is string => Boolean(id && !exceptIds.has(id)))); + for (const fileId of ids) { + try { + getFileService().delete(actor, fileId); + } catch { + // The DB update is authoritative. Orphan cleanup can be retried. + } + } +} + +function revalidateAppearance() { + revalidatePath("/", "layout"); + revalidatePath("/settings/appearance"); + revalidatePath("/portal", "layout"); + revalidatePath("/manifest.webmanifest"); +} diff --git a/app/(dashboard)/settings/appearance/appearance-settings-form.tsx b/app/(dashboard)/settings/appearance/appearance-settings-form.tsx new file mode 100644 index 0000000..9390c26 --- /dev/null +++ b/app/(dashboard)/settings/appearance/appearance-settings-form.tsx @@ -0,0 +1,375 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { + ImageIcon, + Monitor, + Moon, + Palette, + Sun, + Trash2, + Upload, +} from "lucide-react"; +import { + Button, + Card, + CardContent, + Input, + Label, + RadioGroup, + RadioGroupItem, +} from "poyraz-ui/atoms"; +import { toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { applyColorMode } from "@/components/theme/color-mode-sync"; +import { isColorMode, type ColorMode } from "@/lib/color-mode"; +import { + removeAppearanceAssetAction, + saveAppearanceSettingsAction, + saveColorModeAction, +} from "./actions"; + +type BrandingAsset = "darkLogo" | "favicon" | "lightLogo"; +type AssetState = Record; + +type AppearanceSettingsFormProps = { + initial: { + colorMode: ColorMode; + primaryColor: string; + urls: AssetState; + custom: Record; + }; +}; + +const themeOptions = [ + { value: "light", icon: Sun }, + { value: "dark", icon: Moon }, + { value: "system", icon: Monitor }, +] as const; + +export function AppearanceSettingsForm({ initial }: AppearanceSettingsFormProps) { + const t = useTranslations(); + const [colorMode, setColorMode] = useState(initial.colorMode); + const [savingTheme, startThemeTransition] = useTransition(); + const [savingBrand, startBrandTransition] = useTransition(); + const [primaryColor, setPrimaryColor] = useState(initial.primaryColor); + const [pendingUrls, setPendingUrls] = useState({ + lightLogo: "", + darkLogo: "", + favicon: "", + }); + const objectUrls = useRef>({}); + + useEffect(() => { + const urls = objectUrls.current; + return () => Object.values(urls).forEach((url) => url && URL.revokeObjectURL(url)); + }, []); + + function changeColorMode(value: string) { + if (!isColorMode(value) || value === colorMode || savingTheme) return; + const previous = colorMode; + setColorMode(value); + applyColorMode(value); + startThemeTransition(async () => { + const result = await saveColorModeAction(value); + if (result.errorKey) { + setColorMode(previous); + applyColorMode(previous); + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.appearance.messages.themeSaved")); + }); + } + + function selectAsset(asset: BrandingAsset, file?: File) { + const previous = objectUrls.current[asset]; + if (previous) URL.revokeObjectURL(previous); + const url = file ? URL.createObjectURL(file) : ""; + objectUrls.current[asset] = url || undefined; + setPendingUrls((current) => ({ ...current, [asset]: url })); + } + + function saveBranding(formData: FormData) { + startBrandTransition(async () => { + const result = await saveAppearanceSettingsAction(formData); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.appearance.messages.brandSaved")); + window.location.reload(); + }); + } + + function removeAsset(asset: BrandingAsset) { + startBrandTransition(async () => { + const result = await removeAppearanceAssetAction(asset); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.appearance.messages.assetRemoved")); + window.location.reload(); + }); + } + + return ( +
+ + +
+

+ {t("settings.appearance.theme.title")} +

+

+ {t("settings.appearance.theme.description")} +

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

+ {t("settings.appearance.brand.title")} +

+

+ {t("settings.appearance.brand.description")} +

+
+ +
+
+
+
+
+ setPrimaryColor(event.target.value.toUpperCase())} + aria-label={t("settings.appearance.color.picker")} + className="h-11 w-16 shrink-0 cursor-pointer p-1" + /> + setPrimaryColor(event.target.value.toUpperCase())} + pattern="^#[0-9A-Fa-f]{6}$" + maxLength={7} + required + className="font-mono uppercase" + /> +
+

+ {t("settings.appearance.color.help")} +

+
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+
+
+ ); +} + +function AssetField({ + asset, + title, + description, + accept, + currentUrl, + pendingUrl, + custom, + tone, + disabled, + onSelect, + onRemove, + removeLabel, + previewAlt, +}: { + asset: BrandingAsset; + title: string; + description?: string; + accept: string; + currentUrl: string; + pendingUrl: string; + custom: boolean; + tone: "dark" | "light" | "neutral"; + disabled: boolean; + onSelect: (asset: BrandingAsset, file?: File) => void; + onRemove: (asset: BrandingAsset) => void; + removeLabel: string; + previewAlt: string; +}) { + const previewUrl = pendingUrl || (custom ? currentUrl : ""); + const toneClass = tone === "dark" + ? "bg-neutral-950" + : tone === "light" + ? "bg-white" + : "bg-muted/40"; + + return ( +
+
+
+ + {description ?

{description}

: null} +
+ onSelect(asset, event.target.files?.[0])} + className="cursor-pointer" + /> + {custom ? ( + + ) : null} +
+
+ {previewUrl ? ( + {previewAlt} + ) : ( +
+
+ ); +} diff --git a/app/(dashboard)/settings/appearance/page.tsx b/app/(dashboard)/settings/appearance/page.tsx new file mode 100644 index 0000000..b9afdc7 --- /dev/null +++ b/app/(dashboard)/settings/appearance/page.tsx @@ -0,0 +1,29 @@ +import { getBrandingService } from "@/server/branding/runtime"; +import { getUserPreferences } from "@/server/settings/preferences"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { AppearanceSettingsForm } from "./appearance-settings-form"; + +export default async function AppearanceSettingsPage() { + const { actor } = await requireFreelancerBackend(); + const branding = getBrandingService().getPublic(); + const preferences = getUserPreferences(actor); + + return ( + + ); +} diff --git a/app/(dashboard)/settings/error.tsx b/app/(dashboard)/settings/error.tsx new file mode 100644 index 0000000..ab1abff --- /dev/null +++ b/app/(dashboard)/settings/error.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useI18n } from "@/components/i18n/i18n-provider"; +import { Button, Card, CardContent } from "poyraz-ui/atoms"; + +export default function SettingsError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const { t } = useI18n(); + + return ( + + +

+ {t("settings.shell.errorTitle")} +

+

+ {t("settings.shell.errorDescription")} +

+ +
+
+ ); +} diff --git a/app/(dashboard)/settings/general/actions.ts b/app/(dashboard)/settings/general/actions.ts new file mode 100644 index 0000000..3d30684 --- /dev/null +++ b/app/(dashboard)/settings/general/actions.ts @@ -0,0 +1,72 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getBrandingService } from "@/server/branding/runtime"; +import { getSqliteConnection } from "@/server/db/client"; +import { + ContentTranslationService, + parseContentTranslationsFromFormData, +} from "@/server/i18n/content"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; + +export async function saveGeneralSettingsAction(formData: FormData) { + try { + const { actor } = await requireFreelancerBackend(); + const workspaceName = cleanText(formData.get("workspaceName")); + const metaTitle = cleanText(formData.get("metaTitle")); + const shortName = cleanText(formData.get("shortName")); + + if (!workspaceName || workspaceName.length > 120) { + return { errorKey: "settings.general.errors.workspaceName" }; + } + if (!metaTitle || metaTitle.length > 80) { + return { errorKey: "settings.general.errors.metaTitle" }; + } + if (!shortName || shortName.length > 24) { + return { errorKey: "settings.general.errors.shortName" }; + } + + const contentI18n = new ContentTranslationService(getSqliteConnection().db); + const localization = contentI18n.getLocalizationContext(actor); + const activeLocalization = { + ...localization, + locales: localization.locales.filter((locale) => locale.status === "active"), + }; + const translations = parseContentTranslationsFromFormData( + formData, + "branding", + activeLocalization, + ); + + const defaultContent = translations[activeLocalization.defaultLocale] ?? {}; + const branding = getBrandingService().update(actor, { + applicationName: metaTitle, + shortName, + organizationName: workspaceName, + portalWelcomeText: defaultContent.portalWelcome ?? null, + portalFooterText: defaultContent.portalFooter ?? null, + }); + contentI18n.upsertEntityTranslations("branding", "default", translations); + + revalidateGeneralSettings(); + return { + success: true, + data: { + workspaceName: branding.organizationName ?? branding.applicationName, + metaTitle: branding.applicationName, + shortName: branding.shortName, + }, + }; + } catch (error) { + console.error("General settings update failed", error); + return { errorKey: "settings.general.errors.saveFailed" }; + } +} + +function revalidateGeneralSettings() { + revalidatePath("/", "layout"); + revalidatePath("/settings/general"); + revalidatePath("/portal", "layout"); + revalidatePath("/manifest.webmanifest"); +} diff --git a/app/(dashboard)/settings/general/general-settings-form.tsx b/app/(dashboard)/settings/general/general-settings-form.tsx new file mode 100644 index 0000000..bb56e65 --- /dev/null +++ b/app/(dashboard)/settings/general/general-settings-form.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Building2, Save, TextCursorInput } from "lucide-react"; +import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; +import { toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { + LocalizedFields, + type LocalizedFieldLocale, + type LocalizedFieldValues, +} from "@/components/i18n/localized-fields"; +import { contentTranslationRegistry } from "@/lib/i18n/content"; +import { saveGeneralSettingsAction } from "./actions"; + +type GeneralSettingsFormProps = { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + initial: { + metaTitle: string; + shortName: string; + workspaceName: string; + translations: LocalizedFieldValues; + }; +}; + +export function GeneralSettingsForm({ + defaultLocale, + locales, + initial, +}: GeneralSettingsFormProps) { + const t = useTranslations(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [workspaceName, setWorkspaceName] = useState(initial.workspaceName); + const [metaTitle, setMetaTitle] = useState(initial.metaTitle); + const [shortName, setShortName] = useState(initial.shortName); + const localizedFields = useMemo( + () => contentTranslationRegistry.branding.map((field) => ({ + ...field, + label: field.name === "portalWelcome" + ? t("settings.general.fields.portalWelcome") + : t("settings.general.fields.portalFooter"), + placeholder: field.name === "portalWelcome" + ? t("settings.general.placeholders.portalWelcome") + : t("settings.general.placeholders.portalFooter"), + })), + [t], + ); + + function submit(formData: FormData) { + startTransition(async () => { + const result = await saveGeneralSettingsAction(formData); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.general.messages.saved")); + router.refresh(); + }); + } + + return ( + + +
+

+ {t("settings.general.title")} +

+

+ {t("settings.general.description")} +

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

+ {t("settings.general.help.workspaceName")} +

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

+ {t("settings.general.help.metaTitle")} +

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

+ {t("settings.general.help.shortName")} +

+
+
+ +
+
+
+

+ {t("settings.general.help.portalContent")} +

+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/settings/general/page.tsx b/app/(dashboard)/settings/general/page.tsx new file mode 100644 index 0000000..e956e6a --- /dev/null +++ b/app/(dashboard)/settings/general/page.tsx @@ -0,0 +1,36 @@ +import { getBrandingService } from "@/server/branding/runtime"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService } from "@/server/i18n/content"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { GeneralSettingsForm } from "./general-settings-form"; + +export default async function GeneralSettingsPage() { + const { actor } = await requireFreelancerBackend(); + const branding = getBrandingService().getPublic(); + const contentI18n = new ContentTranslationService(getSqliteConnection().db); + const localization = contentI18n.getLocalizationContext(actor); + const translations = contentI18n.listEntityTranslations("branding", "default"); + + return ( + locale.status === "active")} + initial={{ + workspaceName: branding.organizationName ?? branding.applicationName, + metaTitle: branding.applicationName, + shortName: branding.shortName, + translations: toLocalizedValues(translations), + }} + /> + ); +} + +function toLocalizedValues( + rows: Array<{ locale: string; field: string; value: string }>, +) { + return rows.reduce>>((result, row) => { + result[row.locale] = result[row.locale] ?? {}; + result[row.locale][row.field] = row.value; + return result; + }, {}); +} diff --git a/app/(dashboard)/settings/language/actions.ts b/app/(dashboard)/settings/language/actions.ts new file mode 100644 index 0000000..d2297f5 --- /dev/null +++ b/app/(dashboard)/settings/language/actions.ts @@ -0,0 +1,18 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { updateLanguagePreference } from "@/server/settings/preferences"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; + +export async function saveLanguagePreferenceAction(language: string) { + try { + const { actor } = await requireFreelancerBackend(); + const preferences = updateLanguagePreference(actor, { language }); + revalidatePath("/", "layout"); + revalidatePath("/settings/language"); + return { success: true, language: preferences.language }; + } catch (error) { + console.error("Language preference update failed", error); + return { errorKey: "settings.languagePreference.errors.saveFailed" }; + } +} diff --git a/app/(dashboard)/settings/language/language-preference-form.tsx b/app/(dashboard)/settings/language/language-preference-form.tsx new file mode 100644 index 0000000..283702a --- /dev/null +++ b/app/(dashboard)/settings/language/language-preference-form.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { AlertTriangle, Check, Globe2, Save } from "lucide-react"; +import { Badge, Button, Card, CardContent, Label, RadioGroup, RadioGroupItem } from "poyraz-ui/atoms"; +import { Alert, AlertDescription, AlertTitle, toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { saveLanguagePreferenceAction } from "./actions"; + +type LocaleOption = { + code: string; + name: string; + nativeName: string; +}; + +export function LanguagePreferenceForm({ + activeLocales, + defaultLocale, + initialLanguage, + preferenceNeedsSelection, +}: { + activeLocales: LocaleOption[]; + defaultLocale: string; + initialLanguage: string; + preferenceNeedsSelection: boolean; +}) { + const t = useTranslations(); + const router = useRouter(); + const [language, setLanguage] = useState(initialLanguage); + const [pending, startTransition] = useTransition(); + const defaultLanguage = activeLocales.find((locale) => locale.code === defaultLocale); + + function submit() { + startTransition(async () => { + const result = await saveLanguagePreferenceAction(language); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.languagePreference.messages.saved")); + router.refresh(); + }); + } + + return ( + + +
+

+ {t("settings.languagePreference.title")} +

+

+ {t("settings.languagePreference.description")} +

+
+ + + + + {preferenceNeedsSelection && ( + + + )} + + + {activeLocales.map((locale) => ( + + ))} + + +
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/settings/language/page.tsx b/app/(dashboard)/settings/language/page.tsx new file mode 100644 index 0000000..8bdb92f --- /dev/null +++ b/app/(dashboard)/settings/language/page.tsx @@ -0,0 +1,30 @@ +import { getSqliteConnection } from "@/server/db/client"; +import { I18nService } from "@/server/i18n/service"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; +import { getUserPreferences } from "@/server/settings/preferences"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { LanguagePreferenceForm } from "./language-preference-form"; + +export default async function LanguagePreferenceSettingsPage() { + const { actor, context } = await requireFreelancerBackend(); + const i18n = new I18nService(getSqliteConnection().db); + const activeLocales = i18n + .listLocales(actor) + .filter((locale) => locale.status === "active") + .map(({ code, name, nativeName }) => ({ code, name, nativeName })); + const defaultLocale = i18n.getSettings(actor).defaultLocale; + const preferredLanguage = getUserPreferences(actor).language; + const resolved = await resolveFreelancerLocale(context); + const preferenceIsActive = activeLocales.some( + (locale) => locale.code === preferredLanguage, + ); + + return ( + + ); +} diff --git a/app/(dashboard)/settings/languages/[locale]/actions.ts b/app/(dashboard)/settings/languages/[locale]/actions.ts new file mode 100644 index 0000000..0fa22a6 --- /dev/null +++ b/app/(dashboard)/settings/languages/[locale]/actions.ts @@ -0,0 +1,120 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { DomainError } from "@/server/domain/errors"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; + +export async function updateLanguageMetadataAction( + localeCode: string, + formData: FormData, +) { + const name = cleanText(formData.get("name")) ?? ""; + const nativeName = cleanText(formData.get("nativeName")) ?? ""; + const fallbackLocale = cleanText(formData.get("fallbackLocale")) ?? ""; + const textDirection = cleanText(formData.get("textDirection")) ?? ""; + + if (!name || name.length > 80) { + return { errorKey: "settings.languageDetail.errors.name" }; + } + if (!nativeName || nativeName.length > 80) { + return { errorKey: "settings.languageDetail.errors.nativeName" }; + } + if (textDirection !== "ltr" && textDirection !== "rtl") { + return { errorKey: "settings.languageDetail.errors.direction" }; + } + if (fallbackLocale === localeCode) { + return { errorKey: "settings.languageDetail.errors.selfFallback" }; + } + + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const current = service + .listLocales(actor) + .find((locale) => locale.code === localeCode); + if (!current) return { errorKey: "settings.languageDetail.errors.notFound" }; + if (current.builtIn) { + return { errorKey: "settings.languageDetail.errors.builtInMetadata" }; + } + + const locale = service.updateLocale(actor, localeCode, { + name, + nativeName, + fallbackLocale, + textDirection, + }); + revalidateLanguage(locale.code); + return { success: true }; + } catch (error) { + console.error("Language metadata update failed", error); + if (error instanceof DomainError) { + if (error.details?.reason === "fallback_loop") { + return { errorKey: "settings.languageDetail.errors.fallbackLoop" }; + } + if (error.details?.reason === "self_fallback") { + return { errorKey: "settings.languageDetail.errors.selfFallback" }; + } + } + return { errorKey: "settings.languageDetail.errors.metadataFailed" }; + } +} + +export async function activateLanguageAction(localeCode: string) { + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const readiness = service.getLocaleReadiness(actor, localeCode); + if (!readiness.canActivate) { + return { + errorKey: "settings.languageDetail.errors.notReady", + missingCriticalCount: readiness.missingCriticalKeys.length, + }; + } + service.updateLocale(actor, localeCode, { status: "active" }); + revalidateLanguage(localeCode); + return { success: true }; + } catch (error) { + console.error("Language activation failed", error); + return { errorKey: "settings.languageDetail.errors.activateFailed" }; + } +} + +export async function setDetailDefaultLocaleAction(localeCode: string) { + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + service.setDefaultLocale(actor, localeCode); + revalidateLanguage(localeCode); + return { success: true }; + } catch (error) { + console.error("Detail default locale update failed", error); + return { errorKey: "settings.languageDetail.errors.defaultFailed" }; + } +} + +export async function archiveLanguageAction(localeCode: string) { + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const readiness = service.getLocaleReadiness(actor, localeCode); + if (!readiness.canArchive) { + return { errorKey: "settings.languageDetail.errors.archiveBlocked" }; + } + service.archiveLocale(actor, localeCode); + revalidateLanguage(localeCode); + return { success: true }; + } catch (error) { + console.error("Language archive failed", error); + return { errorKey: "settings.languageDetail.errors.archiveFailed" }; + } +} + +function revalidateLanguage(localeCode: string) { + revalidatePath("/", "layout"); + revalidatePath("/settings/language"); + revalidatePath("/settings/languages"); + revalidatePath(`/settings/languages/${localeCode}`); +} diff --git a/app/(dashboard)/settings/languages/[locale]/language-detail.tsx b/app/(dashboard)/settings/languages/[locale]/language-detail.tsx new file mode 100644 index 0000000..b692320 --- /dev/null +++ b/app/(dashboard)/settings/languages/[locale]/language-detail.tsx @@ -0,0 +1,394 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { AlertTriangle, ArrowLeft, CheckCircle2, Languages, Save, ShieldCheck } from "lucide-react"; +import { Badge, Button, Card, CardContent, Input, Label, RadioGroup, RadioGroupItem } from "poyraz-ui/atoms"; +import { Alert, AlertDescription, AlertTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { DestructiveConfirmation } from "@/components/system/destructive-confirmation"; +import type { LocaleStatus, TextDirection } from "@/server/db/schema"; +import type { LocaleReadiness, LocaleUsage, NamespaceCompletion } from "@/server/i18n/service"; +import { + activateLanguageAction, + archiveLanguageAction, + setDetailDefaultLocaleAction, + updateLanguageMetadataAction, +} from "./actions"; + +type LocaleDetail = { + builtIn: boolean; + code: string; + fallbackLocale: string | null; + name: string; + nativeName: string; + status: LocaleStatus; + textDirection: TextDirection; +}; + +type LifecycleAction = "activate" | "archive" | "default"; + +export function LanguageDetail({ + completion, + defaultLocale, + fallbackOptions, + locale, + namespaceCompletion, + readiness, + usage, +}: { + completion: number; + defaultLocale: string; + fallbackOptions: Array<{ code: string; nativeName: string }>; + locale: LocaleDetail; + namespaceCompletion: NamespaceCompletion[]; + readiness: LocaleReadiness; + usage: LocaleUsage; +}) { + const t = useTranslations(); + const router = useRouter(); + const [fallbackLocale, setFallbackLocale] = useState(locale.fallbackLocale ?? ""); + const [dialog, setDialog] = useState(null); + const [pending, startTransition] = useTransition(); + const isDefault = defaultLocale === locale.code; + const criticalComplete = readiness.missingCriticalKeys.length === 0; + + function updateMetadata(formData: FormData) { + formData.set("fallbackLocale", fallbackLocale); + startTransition(async () => { + const result = await updateLanguageMetadataAction(locale.code, formData); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.languageDetail.messages.metadataSaved")); + router.refresh(); + }); + } + + function confirmLifecycle() { + if (!dialog) return; + startTransition(async () => { + const result = dialog === "activate" + ? await activateLanguageAction(locale.code) + : dialog === "default" + ? await setDetailDefaultLocaleAction(locale.code) + : await archiveLanguageAction(locale.code); + if (result.errorKey) { + toast.error(t(result.errorKey, { + count: "missingCriticalCount" in result + ? Number( + result.missingCriticalCount + ?? readiness.missingCriticalKeys.length, + ) + : readiness.archiveReferences, + })); + return; + } + toast.success(t(`settings.languageDetail.messages.${dialog}`)); + setDialog(null); + router.refresh(); + }); + } + + const dialogName = dialog ?? "activate"; + + return ( +
+ + +
+
+ +
+
+

{locale.nativeName}

+ {locale.code} + + {t(`settings.languages.status.${locale.status}`)} + + {locale.builtIn && ( + {t("settings.languages.badges.builtIn")} + )} + {isDefault && ( + {t("settings.languages.badges.default")} + )} +
+

{locale.name}

+
+
+ +
+ + {locale.builtIn ? ( + + + ) : ( +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + {t("settings.languageDetail.fields.direction")} + + + {(["ltr", "rtl"] as const).map((direction) => ( + + ))} + +
+
+
+ +
+
+ )} +
+
+ +
+ + +
+

+ {t("settings.languageDetail.readiness.title")} +

+

+ {t("settings.languageDetail.readiness.description")} +

+
+ + + + {!criticalComplete && ( + + + )} +
+ {locale.status !== "active" && ( + + )} + {!isDefault && ( + + )} + {!locale.builtIn && locale.status !== "archived" && ( + + )} +
+
+
+ + + +
+

+ {t("settings.languageDetail.usage.title")} +

+

+ {t("settings.languageDetail.usage.description")} +

+
+
+ {Object.entries(usage).map(([key, count]) => ( +
+

+ {t(`settings.languageDetail.usage.${key}`)} +

+

{count}

+
+ ))} +
+
+
+
+ + + +
+
+

+ {t("settings.languageDetail.completion.title")} +

+

+ {t("settings.languageDetail.completion.description")} +

+
+ {completion}% +
+
+ {namespaceCompletion.map((item) => ( +
+
+ + {t(`settings.languageDetail.namespaces.${item.namespace}`)} + + {item.percent}% +
+
+
+
+

+ {t("settings.languageDetail.completion.value", { + translated: item.translated, + total: item.total, + })} +

+
+ ))} +
+ + + + { + if (!open) setDialog(null); + }} + loading={pending} + title={t(`settings.languageDetail.dialog.${dialogName}.title`)} + description={t(`settings.languageDetail.dialog.${dialogName}.description`, { + language: locale.nativeName, + })} + cancelLabel={t("settings.languageDetail.dialog.cancel")} + confirmLabel={t(`settings.languageDetail.dialog.${dialogName}.confirm`)} + onConfirm={confirmLifecycle} + /> +
+ ); +} + +function ReadinessRow({ + complete, + label, +}: { + complete: boolean; + label: string; +}) { + return ( +
+ {complete ? ( +
+ ); +} diff --git a/app/(dashboard)/settings/languages/[locale]/page.tsx b/app/(dashboard)/settings/languages/[locale]/page.tsx new file mode 100644 index 0000000..cde9afc --- /dev/null +++ b/app/(dashboard)/settings/languages/[locale]/page.tsx @@ -0,0 +1,38 @@ +import { notFound } from "next/navigation"; +import { getSqliteConnection } from "@/server/db/client"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { LanguageDetail } from "./language-detail"; + +export default async function LanguageDetailPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { actor } = await requireFreelancerBackend(); + const { locale: localeCode } = await params; + const service = new I18nService(getSqliteConnection().db); + const locales = service.listLocales(actor); + const locale = locales.find((item) => item.code === localeCode); + if (!locale) notFound(); + + const completion = service + .getCompletion(actor) + .find((item) => item.locale === locale.code)?.percent ?? 0; + + return ( + item.code !== locale.code && item.status !== "archived", + ) + .map(({ code, nativeName }) => ({ code, nativeName }))} + /> + ); +} diff --git a/app/(dashboard)/settings/languages/actions.ts b/app/(dashboard)/settings/languages/actions.ts new file mode 100644 index 0000000..d1a3be3 --- /dev/null +++ b/app/(dashboard)/settings/languages/actions.ts @@ -0,0 +1,25 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; + +export async function setInstanceDefaultLocaleAction(code: string) { + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const settings = service.setDefaultLocale(actor, code); + revalidateLanguageManagement(); + return { success: true, defaultLocale: settings.defaultLocale }; + } catch (error) { + console.error("Default locale update failed", error); + return { errorKey: "settings.languages.errors.defaultFailed" }; + } +} + +function revalidateLanguageManagement() { + revalidatePath("/", "layout"); + revalidatePath("/settings/languages"); + revalidatePath("/settings/language"); +} diff --git a/app/(dashboard)/settings/languages/languages-list.tsx b/app/(dashboard)/settings/languages/languages-list.tsx new file mode 100644 index 0000000..8669fc4 --- /dev/null +++ b/app/(dashboard)/settings/languages/languages-list.tsx @@ -0,0 +1,209 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Check, Languages, Plus, Settings2 } from "lucide-react"; +import { Badge, Button, Card, CardContent } from "poyraz-ui/atoms"; +import { toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { DestructiveConfirmation } from "@/components/system/destructive-confirmation"; +import type { LocaleStatus } from "@/server/db/schema"; +import { setInstanceDefaultLocaleAction } from "./actions"; + +type LanguageListItem = { + builtIn: boolean; + code: string; + completion: number; + fallbackName: string | null; + name: string; + nativeName: string; + status: LocaleStatus; + usage: number; +}; + +const filters = ["all", "draft", "active", "archived"] as const; +type Filter = (typeof filters)[number]; + +export function LanguagesList({ + initialDefaultLocale, + languages, +}: { + initialDefaultLocale: string; + languages: LanguageListItem[]; +}) { + const t = useTranslations(); + const router = useRouter(); + const [filter, setFilter] = useState("all"); + const [defaultLocale, setDefaultLocale] = useState(initialDefaultLocale); + const [pendingLocale, setPendingLocale] = useState(null); + const [pending, startTransition] = useTransition(); + const filtered = useMemo( + () => filter === "all" + ? languages + : languages.filter((language) => language.status === filter), + [filter, languages], + ); + + function confirmDefault() { + if (!pendingLocale) return; + startTransition(async () => { + const result = await setInstanceDefaultLocaleAction(pendingLocale.code); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + setDefaultLocale(result.defaultLocale ?? pendingLocale.code); + setPendingLocale(null); + toast.success(t("settings.languages.messages.defaultSaved")); + router.refresh(); + }); + } + + return ( + + +
+
+

+ {t("settings.languages.title")} +

+

+ {t("settings.languages.description")} +

+
+ +
+ +
+ {filters.map((item) => ( + + ))} +
+ + {filtered.length === 0 ? ( +
+
+ ) : ( +
+
+ {t("settings.languages.columns.language")} + {t("settings.languages.columns.status")} + {t("settings.languages.columns.fallback")} + {t("settings.languages.columns.completion")} + {t("settings.languages.columns.usage")} + {t("settings.languages.columns.actions")} +
+ {filtered.map((language) => { + const isDefault = language.code === defaultLocale; + return ( +
+
+
+ + {language.nativeName} + + {language.code} + {language.builtIn && ( + {t("settings.languages.badges.builtIn")} + )} + {isDefault && ( + {t("settings.languages.badges.default")} + )} +
+

{language.name}

+
+
+ + {t(`settings.languages.status.${language.status}`)} + +
+ + {language.fallbackName ?? t("settings.languages.values.none")} + +
+ + {language.completion}% + +
+
+
+
+ + {t("settings.languages.values.usage", { count: language.usage })} + +
+ {language.status === "active" && !isDefault && ( + + )} + +
+
+ ); + })} +
+ )} + + { + if (!open) setPendingLocale(null); + }} + loading={pending} + title={t("settings.languages.defaultDialog.title")} + description={t("settings.languages.defaultDialog.description", { + language: pendingLocale?.nativeName ?? "", + })} + cancelLabel={t("settings.languages.defaultDialog.cancel")} + confirmLabel={t("settings.languages.defaultDialog.confirm")} + onConfirm={confirmDefault} + /> + + + ); +} diff --git a/app/(dashboard)/settings/languages/new/actions.ts b/app/(dashboard)/settings/languages/new/actions.ts new file mode 100644 index 0000000..00a497a --- /dev/null +++ b/app/(dashboard)/settings/languages/new/actions.ts @@ -0,0 +1,83 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { DomainError } from "@/server/domain/errors"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; + +const SUPPORTED_BCP47_PATTERN = /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/; + +export async function createLanguageAction(formData: FormData) { + const rawCode = cleanText(formData.get("code")) ?? ""; + const name = cleanText(formData.get("name")) ?? ""; + const nativeName = cleanText(formData.get("nativeName")) ?? ""; + const fallbackLocale = cleanText(formData.get("fallbackLocale")) ?? ""; + const textDirection = cleanText(formData.get("textDirection")) ?? ""; + const code = canonicalizeSupportedLocale(rawCode); + + if (!code) return { errorKey: "settings.languageNew.errors.code" }; + if (!name || name.length > 80) { + return { errorKey: "settings.languageNew.errors.name" }; + } + if (!nativeName || nativeName.length > 80) { + return { errorKey: "settings.languageNew.errors.nativeName" }; + } + if (textDirection !== "ltr" && textDirection !== "rtl") { + return { errorKey: "settings.languageNew.errors.direction" }; + } + if (fallbackLocale === code) { + return { errorKey: "settings.languageNew.errors.selfFallback" }; + } + + try { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const locales = service.listLocales(actor); + if (locales.some((locale) => locale.code === code)) { + return { errorKey: "settings.languageNew.errors.duplicate" }; + } + if ( + !fallbackLocale + || !locales.some( + (locale) => locale.code === fallbackLocale && locale.status !== "archived", + ) + ) { + return { errorKey: "settings.languageNew.errors.fallback" }; + } + + const locale = service.createLocale(actor, { + code, + name, + nativeName, + fallbackLocale, + textDirection, + }); + revalidatePath("/settings/languages"); + return { success: true, locale: locale.code }; + } catch (error) { + console.error("Language creation failed", error); + if (error instanceof DomainError) { + if (error.details?.reason === "fallback_loop") { + return { errorKey: "settings.languageNew.errors.fallbackLoop" }; + } + if (error.details?.reason === "self_fallback") { + return { errorKey: "settings.languageNew.errors.selfFallback" }; + } + if (error.code === "CONFLICT") { + return { errorKey: "settings.languageNew.errors.duplicate" }; + } + } + return { errorKey: "settings.languageNew.errors.createFailed" }; + } +} + +function canonicalizeSupportedLocale(value: string): string | null { + try { + const [canonical] = Intl.getCanonicalLocales(value.replaceAll("_", "-")); + return canonical && SUPPORTED_BCP47_PATTERN.test(canonical) ? canonical : null; + } catch { + return null; + } +} diff --git a/app/(dashboard)/settings/languages/new/new-language-form.tsx b/app/(dashboard)/settings/languages/new/new-language-form.tsx new file mode 100644 index 0000000..4698c41 --- /dev/null +++ b/app/(dashboard)/settings/languages/new/new-language-form.tsx @@ -0,0 +1,169 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { ArrowLeft, Languages, Save } from "lucide-react"; +import { Button, Card, CardContent, Input, Label, RadioGroup, RadioGroupItem } from "poyraz-ui/atoms"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, toast } from "poyraz-ui/molecules"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { createLanguageAction } from "./actions"; + +type FallbackOption = { + code: string; + nativeName: string; +}; + +export function NewLanguageForm({ + defaultFallback, + fallbackOptions, +}: { + defaultFallback: string; + fallbackOptions: FallbackOption[]; +}) { + const t = useTranslations(); + const router = useRouter(); + const [fallbackLocale, setFallbackLocale] = useState(defaultFallback); + const [pending, startTransition] = useTransition(); + + function submit(formData: FormData) { + formData.set("fallbackLocale", fallbackLocale); + startTransition(async () => { + const result = await createLanguageAction(formData); + if (result.errorKey) { + toast.error(t(result.errorKey)); + return; + } + toast.success(t("settings.languageNew.messages.created")); + router.push(`/settings/languages/${encodeURIComponent(result.locale ?? "")}`); + }); + } + + return ( + + +
+ +
+

+ {t("settings.languageNew.title")} +

+

+ {t("settings.languageNew.description")} +

+
+
+ +
+
+
+
+ +
+
+
+ + +

+ {t("settings.languageNew.help.code")} +

+
+
+ + +
+
+ +
+ + +

+ {t("settings.languageNew.help.nativeName")} +

+
+ +
+
+ + +

+ {t("settings.languageNew.help.fallback")} +

+
+ +
+ + {t("settings.languageNew.fields.direction")} + + + {(["ltr", "rtl"] as const).map((direction) => ( + + ))} + +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/settings/languages/new/page.tsx b/app/(dashboard)/settings/languages/new/page.tsx new file mode 100644 index 0000000..679d730 --- /dev/null +++ b/app/(dashboard)/settings/languages/new/page.tsx @@ -0,0 +1,22 @@ +import { getSqliteConnection } from "@/server/db/client"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { NewLanguageForm } from "./new-language-form"; + +export default async function NewLanguagePage() { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const locales = service + .listLocales(actor) + .filter((locale) => locale.status !== "archived"); + const defaultLocale = service.getSettings(actor).defaultLocale; + + return ( + locale.code === defaultLocale) + ? defaultLocale + : locales[0]?.code ?? "tr"} + fallbackOptions={locales.map(({ code, nativeName }) => ({ code, nativeName }))} + /> + ); +} diff --git a/app/(dashboard)/settings/languages/page.tsx b/app/(dashboard)/settings/languages/page.tsx new file mode 100644 index 0000000..e2b685b --- /dev/null +++ b/app/(dashboard)/settings/languages/page.tsx @@ -0,0 +1,37 @@ +import { getSqliteConnection } from "@/server/db/client"; +import { I18nService } from "@/server/i18n/service"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { LanguagesList } from "./languages-list"; + +export default async function LanguagesSettingsPage() { + const { actor } = await requireFreelancerBackend(); + const service = new I18nService(getSqliteConnection().db); + const locales = service.listLocales(actor); + const settings = service.getSettings(actor); + const completion = new Map( + service.getCompletion(actor).map((item) => [item.locale, item.percent]), + ); + const localeNames = new Map(locales.map((locale) => [locale.code, locale.nativeName])); + const languages = locales.map((locale) => { + const usage = service.getLocaleUsage(actor, locale.code); + return { + builtIn: locale.builtIn, + code: locale.code, + completion: completion.get(locale.code) ?? 0, + fallbackName: locale.fallbackLocale + ? localeNames.get(locale.fallbackLocale) ?? locale.fallbackLocale + : null, + name: locale.name, + nativeName: locale.nativeName, + status: locale.status, + usage: usage.userPreferences + usage.clients + usage.portalInvitations, + }; + }); + + return ( + + ); +} diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx new file mode 100644 index 0000000..f05fd9b --- /dev/null +++ b/app/(dashboard)/settings/layout.tsx @@ -0,0 +1,33 @@ +import { PageHeader } from "@/components/system/page-header"; +import { requireFreelancer } from "@/server/auth/session"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; +import { createTranslator } from "@/server/i18n/translator"; +import { SettingsNavigation } from "./settings-navigation"; + +export default async function SettingsLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const context = await requireFreelancer(); + const locale = await resolveFreelancerLocale(context); + const t = createTranslator(locale.locale, ["settings"]).t; + + return ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/app/(dashboard)/settings/loading.tsx b/app/(dashboard)/settings/loading.tsx new file mode 100644 index 0000000..0403c56 --- /dev/null +++ b/app/(dashboard)/settings/loading.tsx @@ -0,0 +1,13 @@ +import { Card, CardContent } from "poyraz-ui/atoms"; + +export default function SettingsLoading() { + return ( + + +
+
+
+ + + ); +} diff --git a/app/(dashboard)/settings/not-found.tsx b/app/(dashboard)/settings/not-found.tsx new file mode 100644 index 0000000..9cede87 --- /dev/null +++ b/app/(dashboard)/settings/not-found.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useI18n } from "@/components/i18n/i18n-provider"; +import { Button, Card, CardContent } from "poyraz-ui/atoms"; +import Link from "next/link"; + +export default function SettingsNotFound() { + const { t } = useI18n(); + + return ( + + +

+ {t("settings.shell.notFoundTitle")} +

+

+ {t("settings.shell.notFoundDescription")} +

+ +
+
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index bebd4ce..13bc5f6 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,1206 +1,5 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Image from "next/image"; -import { - Blocks, - Brain, - Download, - Globe2, - ImageIcon, - Key, - Languages, - Monitor, - Moon, - Palette, - Save, - Shield, - Sun, - Trash2, - Upload, - User, -} from "lucide-react"; -import { - loadSettings, - commitI18nImportAction, - createLocaleAction, - exportI18nAction, - previewI18nImportAction, - removeBrandingAsset, - resetUiTranslationAction, - saveAiSettings, - saveColorMode, - saveGeneralSettings, - saveLanguagePreference, - saveUiTranslationAction, - setDefaultLocaleAction, - updateLocaleStatusAction, - updatePassword, - updateProfile, -} from "./actions"; -import { - Button, - Card, - CardContent, - Input, - Label, - RadioGroup, - RadioGroupItem, - Textarea, -} from "poyraz-ui/atoms"; -import { toast } from "poyraz-ui/molecules"; -import { applyColorMode } from "@/components/theme/color-mode-sync"; -import { LocalizedFields, type LocalizedFieldValues } from "@/components/i18n/localized-fields"; -import { isColorMode, type ColorMode } from "@/lib/color-mode"; -import { contentTranslationRegistry } from "@/lib/i18n/content"; - -type AiProvider = "groq" | "ollama" | "openai" | "gemini"; -type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; -type LocaleStatus = "draft" | "active" | "archived" | "test"; -type LocaleRecord = { - code: string; - name: string; - nativeName: string; - status: LocaleStatus; - fallbackLocale: string | null; - textDirection: "ltr" | "rtl"; - builtIn: boolean; - sortOrder: number; -}; -type TranslationRow = { locale: string; namespace: string; key: string; value: string }; -type ContentTranslationRow = { entityType: string; entityId: string; field: string; locale: string; value: string }; -type ReferenceKey = { - key: string; - namespace: string; - translationKey: string; - tr: string; - en: string; - parityOk: boolean; -}; -type TranslationCompletion = { - locale: string; - translated: number; - total: number; - percent: number; - missingKeys: string[]; -}; - -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; -}>; +import { redirect } from "next/navigation"; export default function SettingsPage() { - const [activeTab, setActiveTab] = useState("Genel"); - - // Profile States - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [avatarUrl, setAvatarUrl] = useState(""); - - // Security States - const formRef = useRef(null); - - // AI States - 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 [metaTitle, setMetaTitle] = useState("Neta"); - const [shortName, setShortName] = useState("Neta"); - const [primaryColor, setPrimaryColor] = useState("#C81E1E"); - 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 [language, setLanguage] = useState("tr"); - const [locales, setLocales] = useState([]); - const [defaultLocale, setDefaultLocale] = useState("tr"); - const [catalogVersion, setCatalogVersion] = useState(1); - const [translations, setTranslations] = useState([]); - const [brandingContentTranslations, setBrandingContentTranslations] = useState({}); - const [referenceKeys, setReferenceKeys] = useState([]); - const [completion, setCompletion] = useState([]); - const [newLocale, setNewLocale] = useState({ - code: "fr", - name: "French", - nativeName: "Français", - fallbackLocale: "en", - textDirection: "ltr" as "ltr" | "rtl", - }); - const [selectedLocale, setSelectedLocale] = useState("fr"); - const [selectedNamespace, setSelectedNamespace] = useState("navigation"); - const [translationSearch, setTranslationSearch] = useState(""); - const [editingValues, setEditingValues] = useState>({}); - const [importJson, setImportJson] = useState(""); - const [importPreview, setImportPreview] = useState(""); - const assetObjectUrlRefs = useRef>>({}); - - const tabs = [ - { name: "Genel", icon: Palette }, - { name: "Diller ve çeviriler", icon: Languages }, - { name: "Profile & Account", icon: User }, - { name: "AI Preferences", icon: Brain }, - { name: "Security", icon: Shield }, - ]; - - useEffect(() => { - let isActive = true; - - const fetchData = async () => { - const settings = await loadSettings(); - if (!isActive) return; - setFirstName(settings.firstName); - setLastName(settings.lastName); - setAvatarUrl(settings.avatarUrl); - setAiProvider(settings.aiProvider); - setHasApiKey(settings.hasApiKey); - setColorMode(settings.colorMode); - setWorkspaceName(settings.workspaceName); - setMetaTitle(settings.metaTitle); - setShortName(settings.shortName); - setPrimaryColor(settings.primaryColor); - setAssetUrls({ - lightLogo: settings.lightLogoUrl, - darkLogo: settings.darkLogoUrl, - favicon: settings.faviconUrl, - }); - setCustomAssets({ - lightLogo: settings.hasCustomLightLogo, - darkLogo: settings.hasCustomDarkLogo, - favicon: settings.hasCustomFavicon, - }); - setLanguage(settings.language); - setLocales(settings.i18n.locales); - setDefaultLocale(settings.i18n.defaultLocale); - setCatalogVersion(settings.i18n.catalogVersion); - setTranslations(settings.i18n.translations); - setReferenceKeys(settings.i18n.referenceKeys); - setCompletion(settings.i18n.completion); - setBrandingContentTranslations(toLocalizedValues(settings.contentTranslations.branding)); - setSelectedLocale(settings.i18n.locales.find((locale) => !locale.builtIn)?.code ?? "en"); - }; - - void fetchData(); - return () => { isActive = false; }; - }, []); - - useEffect(() => { - const objectUrls = assetObjectUrlRefs.current; - return () => { - for (const objectUrl of Object.values(objectUrls)) { - if (objectUrl) URL.revokeObjectURL(objectUrl); - } - }; - }, []); - - const handleProfileAction = async (formData: FormData) => { - const response = await updateProfile(formData); - if (response?.error) { - toast.error(`Hata: ${response.error}`); - } else { - toast.success("Profil güncellendi!"); - const avatar = formData.get("avatar"); - if (avatar instanceof File && avatar.size > 0) window.location.reload(); - } - }; - - const handlePasswordAction = async (formData: FormData) => { - const response = await updatePassword(formData); - if (response?.error) { - toast.error(`Hata: ${response.error}`); - } else { - toast.success("Şifre güncellendi!"); - formRef.current?.reset(); - } - }; - - const handleSaveAI = async () => { - const response = await saveAiSettings(aiProvider, apiKey); - if (response.error) { - toast.error(response.error); - return; - } - setHasApiKey(Boolean(response.hasApiKey)); - setApiKey(""); - 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 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) : ""; - assetObjectUrlRefs.current[asset] = objectUrl || undefined; - setPendingAssetUrls((current) => ({ ...current, [asset]: objectUrl })); - }; - - const handleGeneralSettingsAction = async (formData: FormData) => { - setIsSavingBranding(true); - try { - const response = await saveGeneralSettings(formData); - if (response.error) { - toast.error(response.error); - return; - } - - toast.success("Genel görünüm ve marka ayarları güncellendi."); - window.location.reload(); - } finally { - setIsSavingBranding(false); - } - }; - - const handleRemoveBrandingAsset = async (asset: BrandingAsset) => { - setIsSavingBranding(true); - try { - const response = await removeBrandingAsset(asset); - if (response.error) { - toast.error(response.error); - return; - } - - toast.success("Marka görseli kaldırıldı."); - window.location.reload(); - } finally { - setIsSavingBranding(false); - } - }; - - const refreshI18nSettings = async () => { - const settings = await loadSettings(); - setLanguage(settings.language); - setLocales(settings.i18n.locales); - setDefaultLocale(settings.i18n.defaultLocale); - setCatalogVersion(settings.i18n.catalogVersion); - setTranslations(settings.i18n.translations); - setReferenceKeys(settings.i18n.referenceKeys); - setCompletion(settings.i18n.completion); - }; - - const handleLanguagePreferenceChange = async (value: string) => { - setLanguage(value); - const response = await saveLanguagePreference(value); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Arayüz dili kaydedildi."); - window.location.reload(); - }; - - const handleCreateLocale = async () => { - const response = await createLocaleAction(newLocale); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Dil eklendi."); - setSelectedLocale(response.locale?.code ?? newLocale.code); - await refreshI18nSettings(); - }; - - const handleUpdateLocaleStatus = async (code: string, status: LocaleStatus) => { - const response = await updateLocaleStatusAction(code, status); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Dil durumu güncellendi."); - await refreshI18nSettings(); - }; - - const handleSetDefaultLocale = async (code: string) => { - const response = await setDefaultLocaleAction(code); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Varsayılan dil güncellendi."); - await refreshI18nSettings(); - }; - - const handleSaveTranslation = async (reference: ReferenceKey) => { - const value = editingValues[reference.key] ?? getTranslationValue(translations, selectedLocale, reference); - const response = await saveUiTranslationAction({ - locale: selectedLocale, - namespace: reference.namespace, - key: reference.translationKey, - value, - }); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Çeviri kaydedildi."); - await refreshI18nSettings(); - }; - - const handleResetTranslation = async (reference: ReferenceKey) => { - const response = await resetUiTranslationAction({ - locale: selectedLocale, - namespace: reference.namespace, - key: reference.translationKey, - }); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Override sıfırlandı."); - setEditingValues((current) => ({ ...current, [reference.key]: "" })); - await refreshI18nSettings(); - }; - - const handleExportI18n = async () => { - const response = await exportI18nAction(); - if (response.error || !response.package) { - toast.error(response.error ?? "Export oluşturulamadı."); - return; - } - setImportJson(JSON.stringify(response.package, null, 2)); - toast.success("Çeviri paketi aşağıdaki alana yazıldı."); - }; - - const handlePreviewImport = async () => { - const response = await previewI18nImportAction(importJson); - if (response.error || !response.preview) { - setImportPreview(""); - toast.error(response.error ?? "Import paketi okunamadı."); - return; - } - setImportPreview(`${response.preview.localeCount} dil, ${response.preview.translationCount} çeviri, default: ${response.preview.defaultLocale}`); - }; - - const handleCommitImport = async () => { - const response = await commitI18nImportAction(importJson); - if (response.error) { - toast.error(response.error); - return; - } - toast.success("Çeviri paketi içe aktarıldı."); - setImportPreview(""); - await refreshI18nSettings(); - }; - - const filteredReferenceKeys = referenceKeys.filter((reference) => { - const namespaceMatches = selectedNamespace === "all" || reference.namespace === selectedNamespace; - const query = translationSearch.trim().toLowerCase(); - const textMatches = !query || [reference.key, reference.tr, reference.en] - .some((value) => value.toLowerCase().includes(query)); - return namespaceMatches && textMatches; - }); - - return ( -
-
-
-

- Ayarlar -

-
-
- -
- {/* Settings Sidebar */} -
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ) - })} -
- - {/* Settings Content Area */} -
- {activeTab === "Genel" && ( - - -
-

Genel görünüm ve marka

-

- 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 - /> -

- Mobil uygulama ve ana ekrana ekleme alanlarında kullanılan kısa ad. -

-
-
- -
-
- - -
-
- -
-
-

Tarayıcı ikonu

-

- Favicon, web manifest ve mobil instance metadata alanlarında kullanılır. -

-
- -
- -
-
- -

- 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" - /> -
-
- -
-
-

Portal metinleri

-

- Müşteri portalında kullanılacak karşılama ve footer metinlerini aktif dillere göre girin. -

-
- locale.status !== "archived")} - fields={contentTranslationRegistry.branding} - values={brandingContentTranslations} - /> -
- -
- -
-
- -
-
-

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."} -

-
-
-
- )} - - {activeTab === "Diller ve çeviriler" && ( - - -
-
-

Diller ve çeviriler

-

- Instance dillerini, kişisel arayüz dilini ve katalog override metinlerini yönetin. -

-
-
- Catalog v{catalogVersion} -
-
- -
-
-
- -

Aktif diller

-
-
- {locales.map((locale) => { - const localeCompletion = completion.find((item) => item.locale === locale.code); - return ( -
-
-
-
- {locale.nativeName} - {locale.code} -
-

- {locale.name} · {locale.status} · fallback: {locale.fallbackLocale ?? "-"} -

-
- {locale.builtIn ? ( - Built-in - ) : null} -
-
-
-
-
- {localeCompletion?.percent ?? 0}% tamamlandı - {localeCompletion?.translated ?? 0}/{localeCompletion?.total ?? 0} -
-
- - {locale.status !== "active" ? ( - - ) : null} - {locale.status === "active" ? ( - - ) : null} - {!locale.builtIn && locale.status !== "archived" ? ( - - ) : null} -
-
- ); - })} -
-
- -
-

Yeni dil ekle

-
- setNewLocale((current) => ({ ...current, code: event.target.value }))} placeholder="fr" /> - setNewLocale((current) => ({ ...current, name: event.target.value }))} placeholder="French" /> - setNewLocale((current) => ({ ...current, nativeName: event.target.value }))} placeholder="Français" /> - - - -
-
-
- -
-
- - -
-
- - -
-
- -
-
-
- - -
-
- - setTranslationSearch(event.target.value)} placeholder="navigation.projects" /> -
-
- -
- {filteredReferenceKeys.slice(0, 60).map((reference) => { - const currentValue = getTranslationValue(translations, selectedLocale, reference); - const draftValue = editingValues[reference.key] ?? currentValue; - const isMissing = !currentValue; - return ( -
-
-
- {reference.key} - {isMissing ? Eksik : null} -
-

TR: {reference.tr}

-

EN: {reference.en}

-
-
-