feat(i18n): localize portal account settings
This commit is contained in:
@@ -8,8 +8,12 @@ import {
|
|||||||
|
|
||||||
function inviteErrorCode(error: unknown): string {
|
function inviteErrorCode(error: unknown): string {
|
||||||
if (!(error instanceof PortalInvitationError)) return "auth.messages.portalInviteFailed";
|
if (!(error instanceof PortalInvitationError)) return "auth.messages.portalInviteFailed";
|
||||||
|
if (error.code === "INVALID_INPUT") return "auth.invite.invalidInput";
|
||||||
if (error.code === "INVITATION_EXPIRED") return "auth.invite.expired";
|
if (error.code === "INVITATION_EXPIRED") return "auth.invite.expired";
|
||||||
if (error.code === "INVITATION_NOT_PENDING") return "auth.invite.accepted";
|
if (error.code === "INVITATION_NOT_FOUND") return "auth.invite.notFound";
|
||||||
|
if (error.code === "INVITATION_NOT_PENDING") return "auth.invite.unavailable";
|
||||||
|
if (error.code === "EMAIL_ALREADY_REGISTERED") return "auth.invite.emailRegistered";
|
||||||
|
if (error.code === "CLIENT_ALREADY_LINKED") return "auth.invite.clientLinked";
|
||||||
return "auth.messages.portalInviteFailed";
|
return "auth.messages.portalInviteFailed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { appProfiles } from "@/server/db/schema";
|
||||||
|
import { getFileService } from "@/server/files/runtime";
|
||||||
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
import { cleanText } from "@/server/web/form-data";
|
||||||
|
|
||||||
|
export async function updatePortalProfileAction(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const { actor, context } = await requirePortalBackend();
|
||||||
|
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}`;
|
||||||
|
const avatar = formData.get("avatar");
|
||||||
|
const avatarChanged = avatar instanceof File && avatar.size > 0;
|
||||||
|
if (avatarChanged) {
|
||||||
|
getFileService().upload(actor, {
|
||||||
|
kind: "avatar",
|
||||||
|
originalName: avatar.name,
|
||||||
|
claimedMimeType: avatar.type,
|
||||||
|
bytes: new Uint8Array(await avatar.arrayBuffer()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
revalidatePath("/portal", "layout");
|
||||||
|
revalidatePath("/portal/settings/profile");
|
||||||
|
return { success: true, avatarChanged };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Portal profile update failed", error);
|
||||||
|
return { errorKey: "settings.profile.errors.updateFailed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
import { createTranslator } from "@/server/i18n/translator";
|
|
||||||
import { resolvePortalLocale } from "@/server/i18n/resolver";
|
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
import { PortalProfileForm } from "./portal-profile-form";
|
||||||
|
|
||||||
export default async function PortalProfileSettingsPage() {
|
export default async function PortalProfileSettingsPage() {
|
||||||
const { context } = await requirePortalBackend();
|
const { context } = await requirePortalBackend();
|
||||||
const locale = await resolvePortalLocale(context);
|
const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/);
|
||||||
const t = createTranslator(locale.locale, ["settings"]).t;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PortalProfileForm
|
||||||
<CardContent className="space-y-2 p-6 sm:p-8">
|
initial={{
|
||||||
<h2 className="text-xl font-semibold text-foreground">
|
firstName,
|
||||||
{t("settings.portal.profile.title")}
|
lastName: lastNameParts.join(" "),
|
||||||
</h2>
|
email: context.user.email,
|
||||||
<p className="text-sm text-muted-foreground">
|
avatarUrl: context.user.image ?? "",
|
||||||
{t("settings.portal.profile.description")}
|
}}
|
||||||
</p>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"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 { updatePortalProfileAction } from "./actions";
|
||||||
|
|
||||||
|
export function PortalProfileForm({
|
||||||
|
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 updatePortalProfileAction(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.portal.profile.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.portal.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="portal-profile-email">{t("settings.profile.fields.email")}</Label>
|
||||||
|
<Input id="portal-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 { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
import { cleanText } from "@/server/web/form-data";
|
||||||
|
|
||||||
|
export async function changePortalPasswordAction(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 requirePortalBackend();
|
||||||
|
await auth.api.changePassword({
|
||||||
|
headers: await headers(),
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/portal/settings/security");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Portal password change failed", error);
|
||||||
|
return { errorKey: "settings.security.errors.changeFailed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
import { createTranslator } from "@/server/i18n/translator";
|
|
||||||
import { resolvePortalLocale } from "@/server/i18n/resolver";
|
|
||||||
import { requirePortalBackend } from "@/server/web/portal";
|
import { requirePortalBackend } from "@/server/web/portal";
|
||||||
|
import { PortalSecurityForm } from "./portal-security-form";
|
||||||
|
|
||||||
export default async function PortalSecuritySettingsPage() {
|
export default async function PortalSecuritySettingsPage() {
|
||||||
const { context } = await requirePortalBackend();
|
await requirePortalBackend();
|
||||||
const locale = await resolvePortalLocale(context);
|
return <PortalSecurityForm />;
|
||||||
const t = createTranslator(locale.locale, ["settings"]).t;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="space-y-2 p-6 sm:p-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground">
|
|
||||||
{t("settings.portal.security.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("settings.portal.security.description")}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"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 { changePortalPasswordAction } from "./actions";
|
||||||
|
|
||||||
|
export function PortalSecurityForm() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function submit(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await changePortalPasswordAction(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.portal.security.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
{t("settings.portal.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
appProfiles,
|
appProfiles,
|
||||||
authAuditEvents,
|
authAuditEvents,
|
||||||
clients,
|
clients,
|
||||||
|
instanceI18nSettings,
|
||||||
instanceLocales,
|
instanceLocales,
|
||||||
portalInvitations,
|
portalInvitations,
|
||||||
session,
|
session,
|
||||||
@@ -298,6 +299,7 @@ export async function acceptPortalInvitation(input: {
|
|||||||
.run();
|
.run();
|
||||||
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
|
||||||
}
|
}
|
||||||
|
const preferenceLocale = resolveActivePortalLocale(tx, invitation.locale);
|
||||||
|
|
||||||
const [existingUser] = tx
|
const [existingUser] = tx
|
||||||
.select({ id: user.id })
|
.select({ id: user.id })
|
||||||
@@ -365,7 +367,7 @@ export async function acceptPortalInvitation(input: {
|
|||||||
|
|
||||||
const linkedClient = tx
|
const linkedClient = tx
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ authUserId, portalLocale: invitation.locale, updatedAt: now })
|
.set({ authUserId, portalLocale: preferenceLocale, updatedAt: now })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(clients.id, invitation.clientId),
|
eq(clients.id, invitation.clientId),
|
||||||
@@ -384,12 +386,12 @@ export async function acceptPortalInvitation(input: {
|
|||||||
tx.insert(userPreferences)
|
tx.insert(userPreferences)
|
||||||
.values({
|
.values({
|
||||||
ownerUserId: authUserId,
|
ownerUserId: authUserId,
|
||||||
language: invitation.locale,
|
language: preferenceLocale,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: userPreferences.ownerUserId,
|
target: userPreferences.ownerUserId,
|
||||||
set: {
|
set: {
|
||||||
language: invitation.locale,
|
language: preferenceLocale,
|
||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -418,7 +420,12 @@ export async function acceptPortalInvitation(input: {
|
|||||||
type: "invitation_accepted",
|
type: "invitation_accepted",
|
||||||
authUserId,
|
authUserId,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
metadata: { invitationId: invitation.id, clientId: invitation.clientId, locale: invitation.locale },
|
metadata: {
|
||||||
|
invitationId: invitation.id,
|
||||||
|
clientId: invitation.clientId,
|
||||||
|
locale: invitation.locale,
|
||||||
|
preferenceLocale,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
@@ -617,3 +624,30 @@ function assertPortalReadyLocale(
|
|||||||
|
|
||||||
return row.code;
|
return row.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveActivePortalLocale(
|
||||||
|
tx: Pick<ReturnType<typeof getSqliteConnection>["db"], "select">,
|
||||||
|
localeInput: string,
|
||||||
|
): string {
|
||||||
|
const defaultLocale = tx
|
||||||
|
.select({ defaultLocale: instanceI18nSettings.defaultLocale })
|
||||||
|
.from(instanceI18nSettings)
|
||||||
|
.where(eq(instanceI18nSettings.key, "default"))
|
||||||
|
.get()?.defaultLocale ?? "tr";
|
||||||
|
const candidates = [localeInput, defaultLocale, "tr", "en"];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const locale = candidate.trim();
|
||||||
|
const row = tx
|
||||||
|
.select({ code: instanceLocales.code, status: instanceLocales.status })
|
||||||
|
.from(instanceLocales)
|
||||||
|
.where(eq(instanceLocales.code, locale))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (row?.status === "active") {
|
||||||
|
return row.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "tr";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `project_revisions` ADD `source_locale` text;
|
||||||
@@ -85,6 +85,13 @@
|
|||||||
"when": 1784626279393,
|
"when": 1784626279393,
|
||||||
"tag": "0011_tricky_mordo",
|
"tag": "0011_tricky_mordo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784627370000,
|
||||||
|
"tag": "0012_portal_revision_locale",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ export const projectRevisions = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
|
sourceLocale: text("source_locale"),
|
||||||
status: text("status").$type<RevisionStatus>().default("pending").notNull(),
|
status: text("status").$type<RevisionStatus>().default("pending").notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
|||||||
Reference in New Issue
Block a user