feat(settings): restructure settings layout and separate route modules
This commit is contained in:
@@ -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<ReturnType<typeof requireFreelancerBackend>>["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<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>,
|
|
||||||
exceptIds: ReadonlySet<string> = 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();
|
|
||||||
}
|
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AiProvider>(initial.provider);
|
||||||
|
const [savedProvider, setSavedProvider] = useState<AiProvider>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.ai.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.ai.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={submit} className="space-y-8">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">
|
||||||
|
{t("settings.ai.provider.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.ai.provider.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RadioGroup
|
||||||
|
name="provider"
|
||||||
|
value={provider}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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) => (
|
||||||
|
<Label
|
||||||
|
key={option}
|
||||||
|
htmlFor={`provider-${option}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={`provider-${option}`}
|
||||||
|
value={option}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block font-medium text-foreground">
|
||||||
|
{t(`settings.ai.providers.${option}.name`)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs font-normal text-muted-foreground">
|
||||||
|
{t(`settings.ai.providers.${option}.description`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 border-t border-border pt-8 lg:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model">{t("settings.ai.fields.model")}</Label>
|
||||||
|
<Input
|
||||||
|
id="model"
|
||||||
|
name="model"
|
||||||
|
value={model}
|
||||||
|
onChange={(event) => setModel(event.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder={t(`settings.ai.providers.${provider}.defaultModel`)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.ai.help.model")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider === "ollama" ? (
|
||||||
|
<Alert>
|
||||||
|
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.ai.ollama.title")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("settings.ai.ollama.description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label htmlFor="apiKey">{t("settings.ai.fields.apiKey")}</Label>
|
||||||
|
{providerHasApiKey && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Check className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{t("settings.ai.apiKey.configured")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
name="apiKey"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
maxLength={4_096}
|
||||||
|
placeholder={providerHasApiKey
|
||||||
|
? t("settings.ai.apiKey.masked")
|
||||||
|
: t("settings.ai.apiKey.placeholder")}
|
||||||
|
/>
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<KeyRound className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{providerHasApiKey
|
||||||
|
? t("settings.ai.help.apiKeyExisting")
|
||||||
|
: t("settings.ai.help.apiKeyNew")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.ai.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <AiSettingsForm initial={settings} />;
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof getBrandingService>["getPublic"]>;
|
||||||
|
type Actor = Awaited<ReturnType<typeof requireFreelancerBackend>>["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<string | null>,
|
||||||
|
exceptIds: ReadonlySet<string> = 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");
|
||||||
|
}
|
||||||
@@ -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<BrandingAsset, string>;
|
||||||
|
|
||||||
|
type AppearanceSettingsFormProps = {
|
||||||
|
initial: {
|
||||||
|
colorMode: ColorMode;
|
||||||
|
primaryColor: string;
|
||||||
|
urls: AssetState;
|
||||||
|
custom: Record<BrandingAsset, boolean>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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<AssetState>({
|
||||||
|
lightLogo: "",
|
||||||
|
darkLogo: "",
|
||||||
|
favicon: "",
|
||||||
|
});
|
||||||
|
const objectUrls = useRef<Partial<AssetState>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.appearance.theme.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.appearance.theme.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RadioGroup
|
||||||
|
value={colorMode}
|
||||||
|
onValueChange={changeColorMode}
|
||||||
|
disabled={savingTheme}
|
||||||
|
aria-label={t("settings.appearance.theme.ariaLabel")}
|
||||||
|
className="grid gap-3 sm:grid-cols-3"
|
||||||
|
>
|
||||||
|
{themeOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const selected = colorMode === option.value;
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
key={option.value}
|
||||||
|
htmlFor={`color-mode-${option.value}`}
|
||||||
|
className={`flex min-h-36 cursor-pointer flex-col justify-between gap-5 rounded-md border p-4 ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||||
|
: "border-border bg-card hover:border-primary/50 hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-md border 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={t(`settings.appearance.theme.${option.value}.label`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-semibold text-foreground">
|
||||||
|
{t(`settings.appearance.theme.${option.value}.label`)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs font-normal text-muted-foreground">
|
||||||
|
{t(`settings.appearance.theme.${option.value}.description`)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.appearance.brand.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.appearance.brand.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={saveBranding} className="space-y-8">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<Palette className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
{t("settings.appearance.color.title")}
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-sm items-center gap-3">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(event) => setPrimaryColor(event.target.value.toUpperCase())}
|
||||||
|
aria-label={t("settings.appearance.color.picker")}
|
||||||
|
className="h-11 w-16 shrink-0 cursor-pointer p-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="primaryColor"
|
||||||
|
name="primaryColor"
|
||||||
|
value={primaryColor}
|
||||||
|
onChange={(event) => setPrimaryColor(event.target.value.toUpperCase())}
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
maxLength={7}
|
||||||
|
required
|
||||||
|
className="font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.appearance.color.help")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 border-t border-border pt-7 lg:grid-cols-2">
|
||||||
|
<AssetField
|
||||||
|
asset="lightLogo"
|
||||||
|
title={t("settings.appearance.assets.lightLogo")}
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
currentUrl={initial.urls.lightLogo}
|
||||||
|
pendingUrl={pendingUrls.lightLogo}
|
||||||
|
custom={initial.custom.lightLogo}
|
||||||
|
tone="light"
|
||||||
|
disabled={savingBrand}
|
||||||
|
onSelect={selectAsset}
|
||||||
|
onRemove={removeAsset}
|
||||||
|
removeLabel={t("settings.appearance.actions.remove")}
|
||||||
|
previewAlt={t("settings.appearance.assets.previewAlt", {
|
||||||
|
asset: t("settings.appearance.assets.lightLogo"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<AssetField
|
||||||
|
asset="darkLogo"
|
||||||
|
title={t("settings.appearance.assets.darkLogo")}
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
currentUrl={initial.urls.darkLogo}
|
||||||
|
pendingUrl={pendingUrls.darkLogo}
|
||||||
|
custom={initial.custom.darkLogo}
|
||||||
|
tone="dark"
|
||||||
|
disabled={savingBrand}
|
||||||
|
onSelect={selectAsset}
|
||||||
|
onRemove={removeAsset}
|
||||||
|
removeLabel={t("settings.appearance.actions.remove")}
|
||||||
|
previewAlt={t("settings.appearance.assets.previewAlt", {
|
||||||
|
asset: t("settings.appearance.assets.darkLogo"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-border pt-7">
|
||||||
|
<AssetField
|
||||||
|
asset="favicon"
|
||||||
|
title={t("settings.appearance.assets.favicon")}
|
||||||
|
description={t("settings.appearance.assets.faviconHelp")}
|
||||||
|
accept="image/png"
|
||||||
|
currentUrl={initial.urls.favicon}
|
||||||
|
pendingUrl={pendingUrls.favicon}
|
||||||
|
custom={initial.custom.favicon}
|
||||||
|
tone="neutral"
|
||||||
|
disabled={savingBrand}
|
||||||
|
onSelect={selectAsset}
|
||||||
|
onRemove={removeAsset}
|
||||||
|
removeLabel={t("settings.appearance.actions.remove")}
|
||||||
|
previewAlt={t("settings.appearance.assets.previewAlt", {
|
||||||
|
asset: t("settings.appearance.assets.favicon"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={savingBrand}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.appearance.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid gap-4 rounded-md border border-border p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={asset}>{title}</Label>
|
||||||
|
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id={asset}
|
||||||
|
name={asset}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={(event) => onSelect(asset, event.target.files?.[0])}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
{custom ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
effect="shine"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRemove(asset)}
|
||||||
|
className="gap-2 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{removeLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={`flex min-h-28 items-center justify-center overflow-hidden rounded-md border border-border p-4 ${toneClass}`}>
|
||||||
|
{previewUrl ? (
|
||||||
|
<Image
|
||||||
|
src={previewUrl}
|
||||||
|
alt={previewAlt}
|
||||||
|
width={220}
|
||||||
|
height={80}
|
||||||
|
unoptimized
|
||||||
|
className="max-h-20 w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageIcon
|
||||||
|
className={`h-7 w-7 ${tone === "dark" ? "text-neutral-400" : "text-muted-foreground"}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppearanceSettingsForm
|
||||||
|
initial={{
|
||||||
|
colorMode: preferences.colorMode,
|
||||||
|
primaryColor: branding.primaryColor,
|
||||||
|
urls: {
|
||||||
|
lightLogo: branding.lightLogoUrl ?? "",
|
||||||
|
darkLogo: branding.darkLogoUrl ?? "",
|
||||||
|
favicon: branding.iconUrl ?? "",
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
lightLogo: Boolean(branding.lightLogoFileId),
|
||||||
|
darkLogo: Boolean(branding.darkLogoFileId),
|
||||||
|
favicon: Boolean(branding.iconFileId),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-6 sm:p-8">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{t("settings.shell.errorTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.shell.errorDescription")}
|
||||||
|
</p>
|
||||||
|
<Button effect="shine" variant="default" onClick={reset}>
|
||||||
|
{t("settings.shell.retry")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.general.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.general.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={submit} className="space-y-8">
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
{t("settings.general.sections.identity")}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="workspaceName">{t("settings.general.fields.workspaceName")}</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">
|
||||||
|
{t("settings.general.help.workspaceName")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="metaTitle">{t("settings.general.fields.metaTitle")}</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">
|
||||||
|
{t("settings.general.help.metaTitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-2">
|
||||||
|
<Label htmlFor="shortName">{t("settings.general.fields.shortName")}</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">
|
||||||
|
{t("settings.general.help.shortName")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t border-border pt-7">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<TextCursorInput className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
{t("settings.general.sections.portalContent")}
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.general.help.portalContent")}
|
||||||
|
</p>
|
||||||
|
<LocalizedFields
|
||||||
|
idPrefix="branding-content"
|
||||||
|
defaultLocale={defaultLocale}
|
||||||
|
locales={locales}
|
||||||
|
fields={localizedFields}
|
||||||
|
values={initial.translations}
|
||||||
|
labels={{
|
||||||
|
defaultBadge: t("settings.localized.defaultBadge"),
|
||||||
|
missingRequired: t("settings.localized.missingRequired"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.general.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<GeneralSettingsForm
|
||||||
|
defaultLocale={localization.defaultLocale}
|
||||||
|
locales={localization.locales.filter((locale) => 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<Record<string, Record<string, string>>>((result, row) => {
|
||||||
|
result[row.locale] = result[row.locale] ?? {};
|
||||||
|
result[row.locale][row.field] = row.value;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.languagePreference.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.languagePreference.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Globe2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.languagePreference.default.title")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{defaultLanguage
|
||||||
|
? t("settings.languagePreference.default.value", {
|
||||||
|
language: defaultLanguage.nativeName,
|
||||||
|
code: defaultLanguage.code,
|
||||||
|
})
|
||||||
|
: defaultLocale}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{preferenceNeedsSelection && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.languagePreference.fallback.title")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("settings.languagePreference.fallback.description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={language}
|
||||||
|
onValueChange={setLanguage}
|
||||||
|
className="grid gap-3 sm:grid-cols-2"
|
||||||
|
aria-label={t("settings.languagePreference.listAriaLabel")}
|
||||||
|
>
|
||||||
|
{activeLocales.map((locale) => (
|
||||||
|
<Label
|
||||||
|
key={locale.code}
|
||||||
|
htmlFor={`language-${locale.code}`}
|
||||||
|
className="flex cursor-pointer items-center gap-4 rounded-xl border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`language-${locale.code}`} value={locale.code} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium text-foreground">
|
||||||
|
{locale.nativeName}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-muted-foreground">
|
||||||
|
{locale.name} · {locale.code}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{language === locale.code && (
|
||||||
|
<Check className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{defaultLocale === locale.code && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t("settings.languagePreference.default.badge")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
disabled={!language}
|
||||||
|
onClick={submit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.languagePreference.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<LanguagePreferenceForm
|
||||||
|
activeLocales={activeLocales}
|
||||||
|
defaultLocale={defaultLocale}
|
||||||
|
initialLanguage={preferenceIsActive ? preferredLanguage : resolved.locale}
|
||||||
|
preferenceNeedsSelection={!preferenceIsActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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<LifecycleAction | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-7 p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Button asChild size="icon-sm" variant="secondary" effect="shine">
|
||||||
|
<Link href="/settings/languages" aria-label={t("settings.languageDetail.actions.back")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{locale.nativeName}</h2>
|
||||||
|
<Badge variant="secondary">{locale.code}</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`settings.languages.status.${locale.status}`)}
|
||||||
|
</Badge>
|
||||||
|
{locale.builtIn && (
|
||||||
|
<Badge variant="secondary">{t("settings.languages.badges.builtIn")}</Badge>
|
||||||
|
)}
|
||||||
|
{isDefault && (
|
||||||
|
<Badge variant="default">{t("settings.languages.badges.default")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{locale.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="secondary" effect="shine" className="gap-2">
|
||||||
|
<Link href="#translation-completion">
|
||||||
|
<Languages className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.languageDetail.actions.translations")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locale.builtIn ? (
|
||||||
|
<Alert>
|
||||||
|
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.languageDetail.builtIn.title")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("settings.languageDetail.builtIn.description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<form action={updateMetadata} className="max-w-3xl space-y-6 border-t border-border pt-7">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="detail-name">{t("settings.languageDetail.fields.name")}</Label>
|
||||||
|
<Input id="detail-name" name="name" defaultValue={locale.name} maxLength={80} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="detail-native-name">
|
||||||
|
{t("settings.languageDetail.fields.nativeName")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="detail-native-name"
|
||||||
|
name="nativeName"
|
||||||
|
defaultValue={locale.nativeName}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.languageDetail.fields.fallback")}</Label>
|
||||||
|
<Select value={fallbackLocale} onValueChange={setFallbackLocale}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fallbackOptions.map((option) => (
|
||||||
|
<SelectItem key={option.code} value={option.code}>
|
||||||
|
{option.nativeName} ({option.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="text-sm font-medium text-foreground">
|
||||||
|
{t("settings.languageDetail.fields.direction")}
|
||||||
|
</legend>
|
||||||
|
<RadioGroup
|
||||||
|
name="textDirection"
|
||||||
|
defaultValue={locale.textDirection}
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{(["ltr", "rtl"] as const).map((direction) => (
|
||||||
|
<Label
|
||||||
|
key={direction}
|
||||||
|
htmlFor={`detail-direction-${direction}`}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border p-3"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
id={`detail-direction-${direction}`}
|
||||||
|
value={direction}
|
||||||
|
/>
|
||||||
|
{t(`settings.languageNew.direction.${direction}`)}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.languageDetail.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-5 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{t("settings.languageDetail.readiness.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.languageDetail.readiness.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReadinessRow
|
||||||
|
complete={criticalComplete}
|
||||||
|
label={t("settings.languageDetail.readiness.critical", {
|
||||||
|
count: readiness.missingCriticalKeys.length,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<ReadinessRow
|
||||||
|
complete={locale.status === "active"}
|
||||||
|
label={t("settings.languageDetail.readiness.active")}
|
||||||
|
/>
|
||||||
|
<ReadinessRow
|
||||||
|
complete={readiness.archiveReferences === 0}
|
||||||
|
label={t("settings.languageDetail.readiness.references", {
|
||||||
|
count: readiness.archiveReferences,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{!criticalComplete && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.languageDetail.readiness.blockedTitle")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("settings.languageDetail.readiness.blockedDescription", {
|
||||||
|
count: readiness.missingCriticalKeys.length,
|
||||||
|
})}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-border pt-5">
|
||||||
|
{locale.status !== "active" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
disabled={!readiness.canActivate}
|
||||||
|
onClick={() => setDialog("activate")}
|
||||||
|
>
|
||||||
|
{t("settings.languageDetail.actions.activate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isDefault && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
effect="shine"
|
||||||
|
disabled={!readiness.canSetDefault}
|
||||||
|
onClick={() => setDialog("default")}
|
||||||
|
>
|
||||||
|
{t("settings.languageDetail.actions.makeDefault")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!locale.builtIn && locale.status !== "archived" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
effect="shine"
|
||||||
|
disabled={!readiness.canArchive}
|
||||||
|
onClick={() => setDialog("archive")}
|
||||||
|
>
|
||||||
|
{t("settings.languageDetail.actions.archive")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-5 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{t("settings.languageDetail.usage.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.languageDetail.usage.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{Object.entries(usage).map(([key, count]) => (
|
||||||
|
<div key={key} className="rounded-lg border border-border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`settings.languageDetail.usage.${key}`)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-foreground">{count}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card id="translation-completion" className="scroll-mt-8">
|
||||||
|
<CardContent className="space-y-5 p-6">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{t("settings.languageDetail.completion.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.languageDetail.completion.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-semibold text-foreground">{completion}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{namespaceCompletion.map((item) => (
|
||||||
|
<div key={item.namespace} className="rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{t(`settings.languageDetail.namespaces.${item.namespace}`)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${item.percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{t("settings.languageDetail.completion.value", {
|
||||||
|
translated: item.translated,
|
||||||
|
total: item.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DestructiveConfirmation
|
||||||
|
open={Boolean(dialog)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadinessRow({
|
||||||
|
complete,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
complete: boolean;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
{complete ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="text-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<LanguageDetail
|
||||||
|
locale={locale}
|
||||||
|
defaultLocale={service.getSettings(actor).defaultLocale}
|
||||||
|
completion={completion}
|
||||||
|
namespaceCompletion={service.getNamespaceCompletion(actor, locale.code)}
|
||||||
|
readiness={service.getLocaleReadiness(actor, locale.code)}
|
||||||
|
usage={service.getLocaleUsage(actor, locale.code)}
|
||||||
|
fallbackOptions={locales
|
||||||
|
.filter(
|
||||||
|
(item) => item.code !== locale.code && item.status !== "archived",
|
||||||
|
)
|
||||||
|
.map(({ code, nativeName }) => ({ code, nativeName }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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<Filter>("all");
|
||||||
|
const [defaultLocale, setDefaultLocale] = useState(initialDefaultLocale);
|
||||||
|
const [pendingLocale, setPendingLocale] = useState<LanguageListItem | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-7 p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.languages.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.languages.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="default" effect="shine" className="gap-2">
|
||||||
|
<Link href="/settings/languages/new">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.languages.actions.add")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex gap-2 overflow-x-auto border-y border-border py-4"
|
||||||
|
aria-label={t("settings.languages.filters.ariaLabel")}
|
||||||
|
>
|
||||||
|
{filters.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
effect="shine"
|
||||||
|
variant={filter === item ? "default" : "secondary"}
|
||||||
|
onClick={() => setFilter(item)}
|
||||||
|
>
|
||||||
|
{t(`settings.languages.filters.${item}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex min-h-52 flex-col items-center justify-center rounded-xl border border-dashed border-border text-center">
|
||||||
|
<Languages className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<h3 className="font-medium text-foreground">
|
||||||
|
{t("settings.languages.empty.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("settings.languages.empty.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="hidden grid-cols-[minmax(170px,1.4fr)_100px_minmax(120px,1fr)_110px_90px_minmax(200px,auto)] gap-4 px-4 text-xs font-medium text-muted-foreground lg:grid">
|
||||||
|
<span>{t("settings.languages.columns.language")}</span>
|
||||||
|
<span>{t("settings.languages.columns.status")}</span>
|
||||||
|
<span>{t("settings.languages.columns.fallback")}</span>
|
||||||
|
<span>{t("settings.languages.columns.completion")}</span>
|
||||||
|
<span>{t("settings.languages.columns.usage")}</span>
|
||||||
|
<span className="text-right">{t("settings.languages.columns.actions")}</span>
|
||||||
|
</div>
|
||||||
|
{filtered.map((language) => {
|
||||||
|
const isDefault = language.code === defaultLocale;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={language.code}
|
||||||
|
className="grid gap-4 rounded-xl border border-border bg-card p-4 lg:grid-cols-[minmax(170px,1.4fr)_100px_minmax(120px,1fr)_110px_90px_minmax(200px,auto)] lg:items-center"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{language.nativeName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">{language.code}</Badge>
|
||||||
|
{language.builtIn && (
|
||||||
|
<Badge variant="outline">{t("settings.languages.badges.builtIn")}</Badge>
|
||||||
|
)}
|
||||||
|
{isDefault && (
|
||||||
|
<Badge variant="default">{t("settings.languages.badges.default")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{language.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant={language.status === "archived" ? "outline" : "secondary"}>
|
||||||
|
{t(`settings.languages.status.${language.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{language.fallbackName ?? t("settings.languages.values.none")}
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{language.completion}%
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${language.completion}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{t("settings.languages.values.usage", { count: language.usage })}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||||
|
{language.status === "active" && !isDefault && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
effect="shine"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => setPendingLocale(language)}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{t("settings.languages.actions.makeDefault")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild size="sm" variant="secondary" effect="shine" className="gap-1.5">
|
||||||
|
<Link href={`/settings/languages/${encodeURIComponent(language.code)}`}>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{t("settings.languages.actions.manage")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DestructiveConfirmation
|
||||||
|
open={Boolean(pendingLocale)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Button asChild size="icon-sm" variant="secondary" effect="shine">
|
||||||
|
<Link href="/settings/languages" aria-label={t("settings.languageNew.actions.back")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.languageNew.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.languageNew.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Languages className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<p>{t("settings.languageNew.draftNotice")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={submit} className="max-w-2xl space-y-7">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="locale-code">{t("settings.languageNew.fields.code")}</Label>
|
||||||
|
<Input
|
||||||
|
id="locale-code"
|
||||||
|
name="code"
|
||||||
|
placeholder={t("settings.languageNew.placeholders.code")}
|
||||||
|
maxLength={12}
|
||||||
|
autoCapitalize="none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.languageNew.help.code")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language-name">{t("settings.languageNew.fields.name")}</Label>
|
||||||
|
<Input
|
||||||
|
id="language-name"
|
||||||
|
name="name"
|
||||||
|
placeholder={t("settings.languageNew.placeholders.name")}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="native-name">{t("settings.languageNew.fields.nativeName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="native-name"
|
||||||
|
name="nativeName"
|
||||||
|
placeholder={t("settings.languageNew.placeholders.nativeName")}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.languageNew.help.nativeName")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.languageNew.fields.fallback")}</Label>
|
||||||
|
<Select value={fallbackLocale} onValueChange={setFallbackLocale}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("settings.languageNew.placeholders.fallback")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fallbackOptions.map((locale) => (
|
||||||
|
<SelectItem key={locale.code} value={locale.code}>
|
||||||
|
{locale.nativeName} ({locale.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.languageNew.help.fallback")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="space-y-2">
|
||||||
|
<legend className="text-sm font-medium text-foreground">
|
||||||
|
{t("settings.languageNew.fields.direction")}
|
||||||
|
</legend>
|
||||||
|
<RadioGroup
|
||||||
|
name="textDirection"
|
||||||
|
defaultValue="ltr"
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{(["ltr", "rtl"] as const).map((direction) => (
|
||||||
|
<Label
|
||||||
|
key={direction}
|
||||||
|
htmlFor={`direction-${direction}`}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border p-3"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`direction-${direction}`} value={direction} />
|
||||||
|
{t(`settings.languageNew.direction.${direction}`)}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.languageNew.actions.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NewLanguageForm
|
||||||
|
defaultFallback={locales.some((locale) => locale.code === defaultLocale)
|
||||||
|
? defaultLocale
|
||||||
|
: locales[0]?.code ?? "tr"}
|
||||||
|
fallbackOptions={locales.map(({ code, nativeName }) => ({ code, nativeName }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<LanguagesList
|
||||||
|
initialDefaultLocale={settings.defaultLocale}
|
||||||
|
languages={languages}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||||
|
<PageHeader title={t("settings.title")} />
|
||||||
|
<div className="flex min-w-0 flex-col gap-8 md:flex-row md:items-start">
|
||||||
|
<SettingsNavigation
|
||||||
|
labels={{
|
||||||
|
general: t("settings.navigation.general"),
|
||||||
|
appearance: t("settings.navigation.appearance"),
|
||||||
|
profile: t("settings.navigation.profile"),
|
||||||
|
security: t("settings.navigation.security"),
|
||||||
|
ai: t("settings.navigation.ai"),
|
||||||
|
language: t("settings.navigation.language"),
|
||||||
|
languages: t("settings.navigation.languages"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="min-w-0 flex-1">{children}</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Card, CardContent } from "poyraz-ui/atoms";
|
||||||
|
|
||||||
|
export default function SettingsLoading() {
|
||||||
|
return (
|
||||||
|
<Card aria-busy="true">
|
||||||
|
<CardContent className="space-y-4 p-6 sm:p-8">
|
||||||
|
<div className="h-7 w-48 animate-pulse rounded-md bg-muted" />
|
||||||
|
<div className="h-11 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
<div className="h-32 w-full animate-pulse rounded-md bg-muted" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 p-6 sm:p-8">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{t("settings.shell.notFoundTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.shell.notFoundDescription")}
|
||||||
|
</p>
|
||||||
|
<Button asChild effect="shine" variant="default">
|
||||||
|
<Link href="/settings/general">{t("settings.shell.backToGeneral")}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { auth } from "@/server/auth/auth";
|
||||||
|
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { appProfiles } from "@/server/db/schema";
|
||||||
|
import { getFileService } from "@/server/files/runtime";
|
||||||
|
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||||
|
import { cleanText } from "@/server/web/form-data";
|
||||||
|
|
||||||
|
export async function updateProfileAction(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const { context } = await requireFreelancerBackend();
|
||||||
|
const firstName = cleanText(formData.get("firstName"));
|
||||||
|
const lastName = cleanText(formData.get("lastName"));
|
||||||
|
if (!firstName || firstName.length > 80) {
|
||||||
|
return { errorKey: "settings.profile.errors.firstName" };
|
||||||
|
}
|
||||||
|
if (!lastName || lastName.length > 120) {
|
||||||
|
return { errorKey: "settings.profile.errors.lastName" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
const avatarChanged = avatar instanceof File && avatar.size > 0;
|
||||||
|
if (avatarChanged) {
|
||||||
|
getFileService().upload(domainActorFromSession(context), {
|
||||||
|
kind: "avatar",
|
||||||
|
originalName: avatar.name,
|
||||||
|
claimedMimeType: avatar.type,
|
||||||
|
bytes: new Uint8Array(await avatar.arrayBuffer()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
revalidatePath("/settings/profile");
|
||||||
|
return { success: true, avatarChanged };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Profile update failed", error);
|
||||||
|
return { errorKey: "settings.profile.errors.updateFailed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||||
|
import { ProfileSettingsForm } from "./profile-settings-form";
|
||||||
|
|
||||||
|
export default async function ProfileSettingsPage() {
|
||||||
|
const { context } = await requireFreelancerBackend();
|
||||||
|
const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileSettingsForm
|
||||||
|
initial={{
|
||||||
|
firstName,
|
||||||
|
lastName: lastNameParts.join(" "),
|
||||||
|
email: context.user.email,
|
||||||
|
avatarUrl: context.user.image ?? "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Save, User } 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 { updateProfileAction } from "./actions";
|
||||||
|
|
||||||
|
export function ProfileSettingsForm({
|
||||||
|
initial,
|
||||||
|
}: {
|
||||||
|
initial: {
|
||||||
|
avatarUrl: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [firstName, setFirstName] = useState(initial.firstName);
|
||||||
|
const [lastName, setLastName] = useState(initial.lastName);
|
||||||
|
|
||||||
|
function submit(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateProfileAction(formData);
|
||||||
|
if (result.errorKey) {
|
||||||
|
toast.error(t(result.errorKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(t("settings.profile.messages.saved"));
|
||||||
|
if (result.avatarChanged) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.profile.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.profile.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={submit} className="max-w-2xl space-y-7">
|
||||||
|
<section className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
|
{initial.avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={initial.avatarUrl}
|
||||||
|
alt={t("settings.profile.avatarAlt")}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
unoptimized
|
||||||
|
className="h-20 w-20 rounded-full border border-border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-full border border-border bg-muted/50">
|
||||||
|
<User className="h-9 w-9 text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor="avatar">{t("settings.profile.fields.avatar")}</Label>
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.profile.help.avatar")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">{t("settings.profile.fields.firstName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(event) => setFirstName(event.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">{t("settings.profile.fields.lastName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(event) => setLastName(event.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profile-email">{t("settings.profile.fields.email")}</Label>
|
||||||
|
<Input id="profile-email" value={initial.email} disabled readOnly />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.profile.help.email")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.profile.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { auth } from "@/server/auth/auth";
|
||||||
|
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||||
|
import { cleanText } from "@/server/web/form-data";
|
||||||
|
|
||||||
|
export async function changePasswordAction(formData: FormData) {
|
||||||
|
const currentPassword = cleanText(formData.get("currentPassword")) ?? "";
|
||||||
|
const newPassword = cleanText(formData.get("newPassword")) ?? "";
|
||||||
|
const confirmPassword = cleanText(formData.get("confirmPassword")) ?? "";
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
return { errorKey: "settings.security.errors.currentRequired" };
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8 || newPassword.length > 128) {
|
||||||
|
return { errorKey: "settings.security.errors.newLength" };
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return { errorKey: "settings.security.errors.confirmMismatch" };
|
||||||
|
}
|
||||||
|
if (newPassword === currentPassword) {
|
||||||
|
return { errorKey: "settings.security.errors.samePassword" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requireFreelancerBackend();
|
||||||
|
await auth.api.changePassword({
|
||||||
|
headers: await headers(),
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/settings/security");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password change failed", error);
|
||||||
|
return { errorKey: "settings.security.errors.changeFailed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||||
|
import { SecuritySettingsForm } from "./security-settings-form";
|
||||||
|
|
||||||
|
export default async function SecuritySettingsPage() {
|
||||||
|
await requireFreelancerBackend();
|
||||||
|
return <SecuritySettingsForm />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useTransition } from "react";
|
||||||
|
import { KeyRound, Save, ShieldCheck } from "lucide-react";
|
||||||
|
import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
|
||||||
|
import { Alert, AlertDescription, AlertTitle, toast } from "poyraz-ui/molecules";
|
||||||
|
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||||
|
import { changePasswordAction } from "./actions";
|
||||||
|
|
||||||
|
export function SecuritySettingsForm() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function submit(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await changePasswordAction(formData);
|
||||||
|
if (result.errorKey) {
|
||||||
|
toast.error(t(result.errorKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formRef.current?.reset();
|
||||||
|
toast.success(t("settings.security.messages.saved"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
|
{t("settings.security.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.security.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
|
<AlertTitle>{t("settings.security.sessions.title")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("settings.security.sessions.description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<form ref={formRef} action={submit} className="max-w-2xl space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currentPassword">
|
||||||
|
{t("settings.security.fields.currentPassword")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">
|
||||||
|
{t("settings.security.fields.newPassword")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">
|
||||||
|
{t("settings.security.fields.confirmPassword")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
{t("settings.security.help.password")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-border pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
effect="shine"
|
||||||
|
loading={pending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("settings.security.actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Languages,
|
||||||
|
Palette,
|
||||||
|
Settings2,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Button } from "poyraz-ui/atoms";
|
||||||
|
|
||||||
|
export type SettingsNavigationLabels = {
|
||||||
|
ai: string;
|
||||||
|
appearance: string;
|
||||||
|
general: string;
|
||||||
|
language: string;
|
||||||
|
languages: string;
|
||||||
|
profile: string;
|
||||||
|
security: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ key: "general", href: "/settings/general", icon: Settings2 },
|
||||||
|
{ key: "appearance", href: "/settings/appearance", icon: Palette },
|
||||||
|
{ key: "profile", href: "/settings/profile", icon: User },
|
||||||
|
{ key: "security", href: "/settings/security", icon: Shield },
|
||||||
|
{ key: "ai", href: "/settings/ai", icon: Brain },
|
||||||
|
{ key: "language", href: "/settings/language", icon: Languages },
|
||||||
|
{ key: "languages", href: "/settings/languages", icon: Languages },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function SettingsNavigation({ labels }: { labels: SettingsNavigationLabels }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label={labels.general}
|
||||||
|
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-60 md:self-start md:flex-col md:overflow-y-auto md:pb-0"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.href}
|
||||||
|
asChild
|
||||||
|
effect="shine"
|
||||||
|
variant={active ? "default" : "secondary"}
|
||||||
|
className="h-auto shrink-0 justify-start gap-3 px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<Link href={item.href} aria-current={active ? "page" : undefined}>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{labels[item.key]}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user