UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: дизайн и русификация God Mode

This commit is contained in:
DCCONSTRUCTIONS 2026-04-29 00:38:04 +03:00
parent 5d336039ba
commit 7f47b85c36
57 changed files with 1104 additions and 588 deletions

View File

@ -41,17 +41,17 @@ export function InstanceAIForm(props: IInstanceAIForm) {
{ {
key: "LLM_MODEL", key: "LLM_MODEL",
type: "text", type: "text",
label: "LLM Model", label: "LLM-модель",
description: ( description: (
<> <>
Choose an OpenAI engine.{" "} Выберите модель OpenAI.{" "}
<a <a
href="https://platform.openai.com/docs/models/overview" href="https://platform.openai.com/docs/models/overview"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -62,17 +62,17 @@ export function InstanceAIForm(props: IInstanceAIForm) {
{ {
key: "LLM_API_KEY", key: "LLM_API_KEY",
type: "password", type: "password",
label: "API key", label: "API-ключ",
description: ( description: (
<> <>
You will find your API key{" "} API-ключ находится{" "}
<a <a
href="https://platform.openai.com/api-keys" href="https://platform.openai.com/api-keys"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -89,8 +89,8 @@ export function InstanceAIForm(props: IInstanceAIForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "AI Settings updated successfully", message: "ИИ-настройки обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -101,7 +101,7 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="pb-1 text-18 font-medium text-primary">OpenAI</div> <div className="pb-1 text-18 font-medium text-primary">OpenAI</div>
<div className="text-13 font-regular text-tertiary">If you use ChatGPT, this is for you.</div> <div className="text-13 font-regular text-tertiary">Используется для встроенных функций на базе OpenAI.</div>
</div> </div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3"> <div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-x-12 gap-y-8 lg:grid-cols-3">
{aiFormFields.map((field) => ( {aiFormFields.map((field) => (
@ -122,15 +122,15 @@ export function InstanceAIForm(props: IInstanceAIForm) {
<div className="flex flex-col items-start gap-4"> <div className="flex flex-col items-start gap-4">
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary"> <div className="nodedc-settings-note relative inline-flex max-w-2xl items-center gap-2 px-4 py-3 text-12 font-regular">
<Lightbulb className="size-4" /> <Lightbulb className="size-4 shrink-0 text-tertiary" />
<div> <div>
If you have a preferred AI models vendor, please get in{" "} Если нужен другой провайдер ИИ-моделей, свяжитесь{" "}
<a className="font-medium underline" href="https://plane.so/contact"> <a className="font-medium underline" href="https://plane.so/contact">
touch with us. с командой поддержки.
</a> </a>
</div> </div>
</div> </div>

View File

@ -25,8 +25,8 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "AI features for all your workspaces", title: "ИИ-функции для всех воркспейсов",
description: "Configure your AI API credentials so Plane AI features are turned on for all your workspaces.", description: "Настройте API-ключ и модель, чтобы включить ИИ-возможности во всех рабочих пространствах.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -45,6 +45,6 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Artificial Intelligence Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "ИИ-настройки - NODE.DC" }];
export default InstanceAIPage; export default InstanceAIPage;

View File

@ -58,9 +58,9 @@ export function InstanceGiteaConfigForm(props: Props) {
{ {
key: "GITEA_HOST", key: "GITEA_HOST",
type: "text", type: "text",
label: "Gitea Host", label: "Хост Gitea",
description: ( description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</> <>Укажите URL вашего Gitea-инстанса. Для официального сервиса используйте &quot;https://gitea.com&quot;.</>
), ),
placeholder: "https://gitea.com", placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST), error: Boolean(errors.GITEA_HOST),
@ -72,7 +72,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
You will get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://gitea.com/user/settings/applications" href="https://gitea.com/user/settings/applications"
@ -80,7 +80,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Gitea OAuth application settings. Gitea OAuth-приложения.
</a> </a>
</> </>
), ),
@ -94,7 +94,7 @@ export function InstanceGiteaConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://gitea.com/user/settings/applications" href="https://gitea.com/user/settings/applications"
@ -102,7 +102,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Gitea OAuth application settings. Gitea OAuth-приложения.
</a> </a>
</> </>
), ),
@ -124,8 +124,8 @@ export function InstanceGiteaConfigForm(props: Props) {
url: `${originURL}/auth/gitea/callback/`, url: `${originURL}/auth/gitea/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field{" "} <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`} href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
@ -133,7 +133,7 @@ export function InstanceGiteaConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -147,8 +147,8 @@ export function InstanceGiteaConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your Gitea authentication is configured. You should test it now.", message: "Аутентификация через Gitea настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
@ -178,7 +178,7 @@ export function InstanceGiteaConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные Gitea для NODE.DC</div>
{GITEA_FORM_FIELDS.map((field) => ( {GITEA_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -202,17 +202,17 @@ export function InstanceGiteaConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 md:col-span-1"> <div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => ( {GITEA_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -41,14 +41,14 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через Gitea ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -69,7 +69,7 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="Gitea" name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts." description="Вход и регистрация пользователей через аккаунты Gitea."
icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />} icon={<img src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -100,6 +100,6 @@ const InstanceGiteaAuthenticationPage = observer(function InstanceGiteaAuthentic
</PageWrapper> </PageWrapper>
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Gitea Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Gitea OAuth - NODE.DC" }];
export default InstanceGiteaAuthenticationPage; export default InstanceGiteaAuthenticationPage;

View File

@ -62,7 +62,7 @@ export function InstanceGithubConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
You will get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -70,7 +70,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitHub OAuth application settings. GitHub OAuth-приложения.
</a> </a>
</> </>
), ),
@ -84,7 +84,7 @@ export function InstanceGithubConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -92,7 +92,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitHub OAuth application settings. GitHub OAuth-приложения.
</a> </a>
</> </>
), ),
@ -103,8 +103,8 @@ export function InstanceGithubConfigForm(props: Props) {
{ {
key: "GITHUB_ORGANIZATION_ID", key: "GITHUB_ORGANIZATION_ID",
type: "text", type: "text",
label: "Organization ID", label: "ID организации",
description: <>The organization github ID.</>, description: <>ID организации GitHub.</>,
placeholder: "123456789", placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID), error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false, required: false,
@ -123,7 +123,8 @@ export function InstanceGithubConfigForm(props: Props) {
url: originURL, url: originURL,
description: ( description: (
<> <>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Authorized origin URL</CodeBlock> field{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized origin URL</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -131,7 +132,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -145,8 +146,8 @@ export function InstanceGithubConfigForm(props: Props) {
url: `${originURL}/auth/github/callback/`, url: `${originURL}/auth/github/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field{" "} <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://github.com/settings/applications/new" href="https://github.com/settings/applications/new"
@ -154,7 +155,7 @@ export function InstanceGithubConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</> </>
), ),
@ -168,8 +169,8 @@ export function InstanceGithubConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your GitHub authentication is configured. You should test it now.", message: "Аутентификация через GitHub настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
@ -199,7 +200,7 @@ export function InstanceGithubConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные GitHub для NODE.DC</div>
{GITHUB_FORM_FIELDS.map((field) => ( {GITHUB_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -223,32 +224,32 @@ export function InstanceGithubConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для GitHub</div>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
{/* common service details */} {/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => ( {GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}
</div> </div>
{/* web service details */} {/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg"> <div className="nodedc-settings-helper-card flex flex-col overflow-hidden">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase"> <div className="nodedc-settings-helper-card-header flex items-center gap-x-3 px-6 py-3 text-11 font-medium uppercase">
<Monitor className="h-3 w-3" /> <Monitor className="h-3 w-3" />
Web Веб
</div> </div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4"> <div className="flex flex-col gap-y-4 px-6 py-4">
{GITHUB_SERVICE_DETAILS.map((field) => ( {GITHUB_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -49,14 +49,14 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через GitHub ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -77,7 +77,7 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="GitHub" name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts." description="Вход и регистрация пользователей через аккаунты GitHub."
icon={ icon={
<img <img
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage} src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
@ -116,6 +116,6 @@ const InstanceGithubAuthenticationPage = observer(function InstanceGithubAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "GitHub Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "GitHub OAuth - NODE.DC" }];
export default InstanceGithubAuthenticationPage; export default InstanceGithubAuthenticationPage;

View File

@ -58,10 +58,10 @@ export function InstanceGitlabConfigForm(props: Props) {
{ {
key: "GITLAB_HOST", key: "GITLAB_HOST",
type: "text", type: "text",
label: "Host", label: "Хост",
description: ( description: (
<> <>
This is either https://gitlab.com or the <CodeBlock>domain.tld</CodeBlock> where you host GitLab. Укажите https://gitlab.com или <CodeBlock>domain.tld</CodeBlock>, если GitLab развернут у вас.
</> </>
), ),
placeholder: "https://gitlab.com", placeholder: "https://gitlab.com",
@ -74,7 +74,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Application ID", label: "Application ID",
description: ( description: (
<> <>
Get this from your{" "} Возьмите значение в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -82,7 +82,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application settings GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -97,7 +97,7 @@ export function InstanceGitlabConfigForm(props: Props) {
label: "Secret", label: "Secret",
description: ( description: (
<> <>
The client secret is also found in your{" "} Секрет клиента находится в настройках{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -105,7 +105,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application settings GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -128,7 +128,7 @@ export function InstanceGitlabConfigForm(props: Props) {
url: `${originURL}/auth/gitlab/callback/`, url: `${originURL}/auth/gitlab/callback/`,
description: ( description: (
<> <>
We will auto-generate this. Paste this into the <CodeBlock darkerShade>Redirect URI</CodeBlock> field of your{" "} Значение сформировано автоматически. Вставьте его в поле <CodeBlock darkerShade>Redirect URI</CodeBlock>{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://docs.gitlab.com/ee/integration/oauth_provider.html" href="https://docs.gitlab.com/ee/integration/oauth_provider.html"
@ -136,7 +136,7 @@ export function InstanceGitlabConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
GitLab OAuth application GitLab OAuth-приложения
</a> </a>
. .
</> </>
@ -151,8 +151,8 @@ export function InstanceGitlabConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your GitLab authentication is configured. You should test it now.", message: "Аутентификация через GitLab настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value,
@ -182,7 +182,7 @@ export function InstanceGitlabConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные GitLab для NODE.DC</div>
{GITLAB_FORM_FIELDS.map((field) => ( {GITLAB_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -206,17 +206,17 @@ export function InstanceGitlabConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 md:col-span-1"> <div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 pt-1.5 pb-4">
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для GitLab</div>
{GITLAB_SERVICE_FIELD.map((field) => ( {GITLAB_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -43,14 +43,14 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через GitLab ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -68,7 +68,7 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="GitLab" name="GitLab"
description="Allow members to login or sign up to plane with their GitLab accounts." description="Вход и регистрация пользователей через аккаунты GitLab."
icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />} icon={<img src={GitlabLogo} height={24} width={24} alt="GitLab Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -104,6 +104,6 @@ const InstanceGitlabAuthenticationPage = observer(function InstanceGitlabAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "GitLab Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "GitLab OAuth - NODE.DC" }];
export default InstanceGitlabAuthenticationPage; export default InstanceGitlabAuthenticationPage;

View File

@ -61,7 +61,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client ID", label: "Client ID",
description: ( description: (
<> <>
Your client ID lives in your Google API Console.{" "} Client ID находится в Google API Console.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred" href="https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#creatingcred"
@ -69,7 +69,7 @@ export function InstanceGoogleConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -83,7 +83,7 @@ export function InstanceGoogleConfigForm(props: Props) {
label: "Client secret", label: "Client secret",
description: ( description: (
<> <>
Your client secret should also be in your Google API Console.{" "} Client secret также находится в Google API Console.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid" href="https://developers.google.com/identity/oauth2/web/guides/get-google-api-clientid"
@ -91,7 +91,7 @@ export function InstanceGoogleConfigForm(props: Props) {
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more Подробнее
</a> </a>
</> </>
), ),
@ -113,15 +113,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: originURL, url: originURL,
description: ( description: (
<p> <p>
We will auto-generate this. Paste this into your{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
<CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> field. For this OAuth client{" "} <CodeBlock darkerShade>Authorized JavaScript origins</CodeBlock> для OAuth-клиента{" "}
<a <a
href="https://console.cloud.google.com/apis/credentials/oauthclient" href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</p> </p>
), ),
@ -135,15 +135,15 @@ export function InstanceGoogleConfigForm(props: Props) {
url: `${originURL}/auth/google/callback/`, url: `${originURL}/auth/google/callback/`,
description: ( description: (
<p> <p>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock>{" "} Значение сформировано автоматически. Вставьте его в поле{" "}
field. For this OAuth client{" "} <CodeBlock darkerShade>Authorized Redirect URI</CodeBlock> для OAuth-клиента{" "}
<a <a
href="https://console.cloud.google.com/apis/credentials/oauthclient" href="https://console.cloud.google.com/apis/credentials/oauthclient"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
here. здесь.
</a> </a>
</p> </p>
), ),
@ -157,8 +157,8 @@ export function InstanceGoogleConfigForm(props: Props) {
const response = await updateInstanceConfigurations(payload); const response = await updateInstanceConfigurations(payload);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Done!", title: "Готово",
message: "Your Google authentication is configured. You should test it now.", message: "Аутентификация через Google настроена. Проверьте вход перед включением в проде.",
}); });
reset({ reset({
GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value,
@ -187,7 +187,7 @@ export function InstanceGoogleConfigForm(props: Props) {
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8"> <div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div> <div className="pt-2.5 text-18 font-medium">Данные Google для NODE.DC</div>
{GOOGLE_FORM_FIELDS.map((field) => ( {GOOGLE_FORM_FIELDS.map((field) => (
<ControllerInput <ControllerInput
key={field.key} key={field.key}
@ -211,32 +211,32 @@ export function InstanceGoogleConfigForm(props: Props) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isDirty} disabled={!isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}> <Link href="/authentication" className={getButtonStyling("secondary", "lg")} onClick={handleGoBack}>
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1"> <div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div> <div className="pt-2 text-18 font-medium">Данные NODE.DC для Google</div>
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
{/* common service details */} {/* common service details */}
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4"> <div className="nodedc-settings-helper-card flex flex-col gap-y-4 px-6 py-4">
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => ( {GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}
</div> </div>
{/* web service details */} {/* web service details */}
<div className="flex flex-col overflow-hidden rounded-lg"> <div className="nodedc-settings-helper-card flex flex-col overflow-hidden">
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase"> <div className="nodedc-settings-helper-card-header flex items-center gap-x-3 px-6 py-3 text-11 font-medium uppercase">
<Monitor className="h-3 w-3" /> <Monitor className="h-3 w-3" />
Web Веб
</div> </div>
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4"> <div className="flex flex-col gap-y-4 px-6 py-4">
{GOOGLE_SERVICE_DETAILS.map((field) => ( {GOOGLE_SERVICE_DETAILS.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} /> <CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))} ))}

View File

@ -43,14 +43,14 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Configuration saved", title: "Конфигурация сохранена",
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, message: () => `Вход через Google ${value === "1" ? "включен" : "отключен"}.`,
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -68,8 +68,7 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
customHeader={ customHeader={
<AuthenticationMethodCard <AuthenticationMethodCard
name="Google" name="Google"
description="Allow members to login or sign up to plane with their Google description="Вход и регистрация пользователей через аккаунты Google."
accounts."
icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />} icon={<img src={GoogleLogo} height={24} width={24} alt="Google Logo" />}
config={ config={
<ToggleSwitch <ToggleSwitch
@ -105,6 +104,6 @@ const InstanceGoogleAuthenticationPage = observer(function InstanceGoogleAuthent
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Google Authentication - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Google OAuth - NODE.DC" }];
export default InstanceGoogleAuthenticationPage; export default InstanceGoogleAuthenticationPage;

View File

@ -55,9 +55,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
if (!canDisable) { if (!canDisable) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Cannot disable authentication", title: "Нельзя отключить вход",
message: message: "Должен остаться хотя бы один способ входа. Сначала включите другой способ аутентификации.",
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
}); });
return; return;
} }
@ -74,14 +73,14 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
const updateConfigPromise = updateInstanceConfigurations(payload); const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, { setPromiseToast(updateConfigPromise, {
loading: "Saving configuration", loading: "Сохранение конфигурации",
success: { success: {
title: "Success", title: "Сохранено",
message: () => "Configuration saved successfully", message: () => "Конфигурация обновлена",
}, },
error: { error: {
title: "Error", title: "Ошибка",
message: () => "Failed to save configuration", message: () => "Не удалось сохранить конфигурацию",
}, },
}); });
@ -111,8 +110,8 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Manage authentication modes for your instance", title: "Способы входа в инстанс",
description: "Configure authentication modes for your team and restrict sign-ups to be invite only.", description: "Настройте email, пароль, OAuth-провайдеры и правила регистрации пользователей.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -120,9 +119,9 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
<div className={cn("flex w-full items-center gap-14 rounded-sm")}> <div className={cn("flex w-full items-center gap-14 rounded-sm")}>
<div className="flex grow items-center gap-4"> <div className="flex grow items-center gap-4">
<div className="grow"> <div className="grow">
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div> <div className="pb-1 text-16 font-medium">Разрешить регистрацию без приглашения</div>
<div className={cn("text-11 leading-5 font-regular text-tertiary")}> <div className={cn("text-11 leading-5 font-regular text-tertiary")}>
Toggling this off will only let users sign up when they are invited. Если выключить, новые пользователи смогут зарегистрироваться только по приглашению.
</div> </div>
</div> </div>
</div> </div>
@ -143,7 +142,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
</div> </div>
</div> </div>
</div> </div>
<div className="text-lg pt-6 font-medium">Available authentication modes</div> <div className="text-lg pt-6 font-medium">Доступные способы входа</div>
{authenticationModes.map((method) => ( {authenticationModes.map((method) => (
<AuthenticationMethodCard <AuthenticationMethodCard
key={method.key} key={method.key}
@ -169,6 +168,6 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Authentication Settings - Plane Web" }]; export const meta: Route.MetaFunction = () => [{ title: "Аутентификация - NODE.DC" }];
export default InstanceAuthenticationPage; export default InstanceAuthenticationPage;

View File

@ -31,7 +31,7 @@ type TEmailSecurityKeys = "EMAIL_USE_TLS" | "EMAIL_USE_SSL" | "NONE";
const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = { const EMAIL_SECURITY_OPTIONS: { [key in TEmailSecurityKeys]: string } = {
EMAIL_USE_TLS: "TLS", EMAIL_USE_TLS: "TLS",
EMAIL_USE_SSL: "SSL", EMAIL_USE_SSL: "SSL",
NONE: "No email security", NONE: "Без шифрования",
}; };
export function InstanceEmailForm(props: IInstanceEmailForm) { export function InstanceEmailForm(props: IInstanceEmailForm) {
@ -63,7 +63,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST", key: "EMAIL_HOST",
type: "text", type: "text",
label: "Host", label: "Хост",
placeholder: "email.google.com", placeholder: "email.google.com",
error: Boolean(errors.EMAIL_HOST), error: Boolean(errors.EMAIL_HOST),
required: true, required: true,
@ -71,7 +71,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_PORT", key: "EMAIL_PORT",
type: "text", type: "text",
label: "Port", label: "Порт",
placeholder: "8080", placeholder: "8080",
error: Boolean(errors.EMAIL_PORT), error: Boolean(errors.EMAIL_PORT),
required: true, required: true,
@ -79,9 +79,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_FROM", key: "EMAIL_FROM",
type: "text", type: "text",
label: "Sender's email address", label: "Email отправителя",
description: description:
"This is the email address your users will see when getting emails from this instance. You will need to verify this address.", "Этот адрес будут видеть пользователи в письмах от инстанса. Адрес нужно подтвердить на стороне SMTP.",
placeholder: "no-reply@projectplane.so", placeholder: "no-reply@projectplane.so",
error: Boolean(errors.EMAIL_FROM), error: Boolean(errors.EMAIL_FROM),
required: true, required: true,
@ -92,7 +92,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST_USER", key: "EMAIL_HOST_USER",
type: "text", type: "text",
label: "Username", label: "Имя пользователя",
placeholder: "getitdone@projectplane.so", placeholder: "getitdone@projectplane.so",
error: Boolean(errors.EMAIL_HOST_USER), error: Boolean(errors.EMAIL_HOST_USER),
required: false, required: false,
@ -100,8 +100,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
{ {
key: "EMAIL_HOST_PASSWORD", key: "EMAIL_HOST_PASSWORD",
type: "password", type: "password",
label: "Password", label: "Пароль",
placeholder: "Password", placeholder: "Пароль",
error: Boolean(errors.EMAIL_HOST_PASSWORD), error: Boolean(errors.EMAIL_HOST_PASSWORD),
required: false, required: false,
}, },
@ -114,8 +114,8 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Email Settings updated successfully", message: "Настройки почты обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -163,12 +163,12 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
/> />
))} ))}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email security</h4> <h4 className="text-13 text-tertiary">Защита соединения</h4>
<CustomSelect <CustomSelect
value={emailSecurityKey} value={emailSecurityKey}
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]} label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange} onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-subtle" buttonClassName="nodedc-settings-select rounded-md border-subtle"
input input
> >
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => ( {Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
@ -183,9 +183,9 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1"> <div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
<div className="mr-8 flex items-center gap-10 pt-4"> <div className="mr-8 flex items-center gap-10 pt-4">
<div className="grow"> <div className="grow">
<div className="text-13 font-medium text-primary">Authentication</div> <div className="text-13 font-medium text-primary">Аутентификация</div>
<div className="text-11 font-regular text-tertiary"> <div className="text-11 font-regular text-tertiary">
This is optional, but we recommend setting up a username and a password for your SMTP server. Необязательно, но для SMTP-сервера обычно нужны имя пользователя и пароль.
</div> </div>
</div> </div>
</div> </div>
@ -215,7 +215,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isValid || !isDirty} disabled={!isValid || !isDirty}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@ -224,7 +224,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
loading={isSubmitting} loading={isSubmitting}
disabled={!isValid} disabled={!isValid}
> >
Send test email Отправить тестовое письмо
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -34,14 +34,14 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
await disableEmail(); await disableEmail();
setIsSMTPEnabled(false); setIsSMTPEnabled(false);
setToast({ setToast({
title: "Email feature disabled", title: "Почта отключена",
message: "Email feature has been disabled", message: "Отправка писем через SMTP отключена",
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
}); });
} catch (_error) { } catch (_error) {
setToast({ setToast({
title: "Error disabling email", title: "Не удалось отключить почту",
message: "Failed to disable email feature. Please try again.", message: "Повторите попытку.",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
}); });
} finally { } finally {
@ -60,13 +60,13 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Secure emails from your own instance", title: "Письма от вашего инстанса",
description: ( description: (
<> <>
Plane can send useful emails to you and your users from your own instance without talking to the Internet. NODE.DC может отправлять системные письма пользователям через ваш SMTP-сервер.
<div className="text-13 font-regular text-tertiary"> <div className="text-13 font-regular text-tertiary">
Set it up below and please test your settings before you save them.&nbsp; Заполните параметры ниже и проверьте отправку перед сохранением.&nbsp;
<span className="text-danger-primary">Misconfigs can lead to email bounces and errors.</span> <span className="text-danger-primary">Ошибки в конфигурации приводят к отказам доставки.</span>
</div> </div>
</> </>
), ),
@ -98,6 +98,6 @@ const InstanceEmailPage = observer(function InstanceEmailPage(_props: Route.Comp
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Email Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Настройки почты - NODE.DC" }];
export default InstanceEmailPage; export default InstanceEmailPage;

View File

@ -58,7 +58,7 @@ export function SendTestEmailModal(props: Props) {
setSendEmailStep(ESendEmailSteps.SUCCESS); setSendEmailStep(ESendEmailSteps.SUCCESS);
}) })
.catch((error) => { .catch((error) => {
setError(error?.error || "Failed to send email"); setError(error?.error || "Не удалось отправить письмо");
setSendEmailStep(ESendEmailSteps.FAILED); setSendEmailStep(ESendEmailSteps.FAILED);
}) })
.finally(() => { .finally(() => {
@ -91,13 +91,13 @@ export function SendTestEmailModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl"> <Dialog.Panel className="nodedc-glass-modal relative w-full transform rounded-[1.75rem] bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
<h3 className="text-16 leading-6 font-medium text-primary"> <h3 className="text-16 leading-6 font-medium text-primary">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL {sendEmailStep === ESendEmailSteps.SEND_EMAIL
? "Send test email" ? "Отправить тестовое письмо"
: sendEmailStep === ESendEmailSteps.SUCCESS : sendEmailStep === ESendEmailSteps.SUCCESS
? "Email send" ? "Письмо отправлено"
: "Failed"}{" "} : "Ошибка отправки"}{" "}
</h3> </h3>
<div className="pt-6 pb-2"> <div className="pt-6 pb-2">
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
@ -106,28 +106,25 @@ export function SendTestEmailModal(props: Props) {
type="email" type="email"
value={receiverEmail} value={receiverEmail}
onChange={(e) => setReceiverEmail(e.target.value)} onChange={(e) => setReceiverEmail(e.target.value)}
placeholder="Receiver email" placeholder="Email получателя"
className="w-full resize-none text-16" className="nodedc-settings-input w-full resize-none text-16"
tabIndex={1} tabIndex={1}
/> />
)} )}
{sendEmailStep === ESendEmailSteps.SUCCESS && ( {sendEmailStep === ESendEmailSteps.SUCCESS && (
<div className="flex flex-col gap-y-4 text-13"> <div className="flex flex-col gap-y-4 text-13">
<p> <p>Тестовое письмо отправлено на {receiverEmail}. Если письма нет во входящих, проверьте спам.</p>
We have sent the test email to {receiverEmail}. Please check your spam folder if you cannot find <p>Если письмо не пришло, проверьте SMTP-настройки и отправьте тест заново.</p>
it.
</p>
<p>If you still cannot find it, recheck your SMTP configuration and trigger a new test email.</p>
</div> </div>
)} )}
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>} {sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>}
<div className="mt-5 flex items-center justify-end gap-2"> <div className="mt-5 flex items-center justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}> <Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"} {sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Отмена" : "Закрыть"}
</Button> </Button>
{sendEmailStep === ESendEmailSteps.SEND_EMAIL && ( {sendEmailStep === ESendEmailSteps.SEND_EMAIL && (
<Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}> <Button variant="primary" size="lg" loading={isLoading} onClick={handleSubmit} tabIndex={3}>
{isLoading ? "Sending email" : "Send email"} {isLoading ? "Отправка" : "Отправить"}
</Button> </Button>
)} )}
</div> </div>

View File

@ -60,8 +60,8 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Settings updated successfully", message: "Основные настройки обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -70,41 +70,41 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-16 font-medium text-primary">Instance details</div> <div className="text-16 font-medium text-primary">Данные инстанса</div>
<div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3"> <div className="grid-col grid w-full grid-cols-1 items-center justify-between gap-8 md:grid-cols-2 lg:grid-cols-3">
<ControllerInput <ControllerInput
key="instance_name" key="instance_name"
name="instance_name" name="instance_name"
control={control} control={control}
type="text" type="text"
label="Name of instance" label="Название инстанса"
placeholder="Instance name" placeholder="Название инстанса"
error={Boolean(errors.instance_name)} error={Boolean(errors.instance_name)}
required required
/> />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Email</h4> <h4 className="text-13 text-tertiary">Email администратора</h4>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={instanceAdmins[0]?.user_detail?.email ?? ""} value={instanceAdmins[0]?.user_detail?.email ?? ""}
placeholder="Admin email" placeholder="Email администратора"
className="w-full cursor-not-allowed !text-placeholder" className="nodedc-settings-input w-full cursor-not-allowed !text-placeholder"
autoComplete="on" autoComplete="on"
disabled disabled
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Instance ID</h4> <h4 className="text-13 text-tertiary">ID инстанса</h4>
<Input <Input
id="instance_id" id="instance_id"
name="instance_id" name="instance_id"
type="text" type="text"
value={instance.instance_id} value={instance.instance_id}
className="w-full cursor-not-allowed rounded-md font-medium !text-placeholder" className="nodedc-settings-input w-full cursor-not-allowed rounded-md font-medium !text-placeholder"
disabled disabled
/> />
</div> </div>
@ -112,7 +112,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div> <div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Чат и телеметрия</div>
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} /> <IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
<div className="flex items-center gap-14"> <div className="flex items-center gap-14">
<div className="flex grow items-center gap-4"> <div className="flex grow items-center gap-4">
@ -122,17 +122,17 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
</div> </div>
</div> </div>
<div className="grow"> <div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Let Plane collect anonymous usage data</div> <div className="text-13 leading-5 font-medium text-primary">Разрешить анонимную телеметрию</div>
<div className="text-11 leading-5 font-regular text-tertiary"> <div className="text-11 leading-5 font-regular text-tertiary">
No PII is collected.This anonymized data is used to understand how you use Plane and build new features Персональные данные не собираются. Анонимные события помогают понимать, как используется система, с
in line with{" "} учетом{" "}
<a <a
href="https://developers.plane.so/self-hosting/telemetry" href="https://developers.plane.so/self-hosting/telemetry"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
our Telemetry Policy. политики телеметрии.
</a> </a>
</div> </div>
</div> </div>
@ -158,7 +158,7 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
}} }}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -64,10 +64,10 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC
</div> </div>
<div className="grow"> <div className="grow">
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div> <div className="text-13 leading-5 font-medium text-primary">Встроенный чат поддержки</div>
<div className="text-11 leading-5 font-regular text-tertiary"> <div className="text-11 leading-5 font-regular text-tertiary">
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off Разрешает пользователям писать в поддержку через Intercom или аналогичный сервис. При отключении
automatically. телеметрии чат отключается автоматически.
</div> </div>
</div> </div>

View File

@ -20,9 +20,8 @@ function GeneralPage() {
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "General settings", title: "Основные настройки",
description: description: "Имя инстанса, служебные идентификаторы, email администратора и режимы телеметрии.",
"Change the name of your instance and instance admin e-mail addresses. Enable or disable telemetry in your instance.",
}} }}
> >
{instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />} {instance && instanceAdmins && <GeneralConfigurationForm instance={instance} instanceAdmins={instanceAdmins} />}
@ -30,6 +29,6 @@ function GeneralPage() {
); );
} }
export const meta: Route.MetaFunction = () => [{ title: "General Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Основные настройки - NODE.DC" }];
export default observer(GeneralPage); export default observer(GeneralPage);

View File

@ -41,8 +41,8 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
.then(() => .then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success", title: "Сохранено",
message: "Image Configuration Settings updated successfully", message: "Настройки изображений обновлены",
}) })
) )
.catch((err) => console.error(err)); .catch((err) => console.error(err));
@ -55,17 +55,17 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
control={control} control={control}
type="password" type="password"
name="UNSPLASH_ACCESS_KEY" name="UNSPLASH_ACCESS_KEY"
label="Access key from your Unsplash account" label="Access key аккаунта Unsplash"
description={ description={
<> <>
You will find your access key in your Unsplash developer console.&nbsp; Ключ доступа находится в консоли разработчика Unsplash.&nbsp;
<a <a
href="https://unsplash.com/documentation#creating-a-developer-account" href="https://unsplash.com/documentation#creating-a-developer-account"
target="_blank" target="_blank"
className="text-accent-primary hover:underline" className="text-accent-primary hover:underline"
rel="noreferrer" rel="noreferrer"
> >
Learn more. Подробнее.
</a> </a>
</> </>
} }
@ -77,7 +77,7 @@ export function InstanceImageConfigForm(props: IInstanceImageConfigForm) {
<div> <div>
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving" : "Save changes"} {isSubmitting ? "Сохранение" : "Сохранить изменения"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -25,8 +25,8 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Third-party image libraries", title: "Внешние библиотеки изображений",
description: "Let your users search and choose images from third-party libraries", description: "Разрешите пользователям искать и выбирать изображения из внешних библиотек.",
}} }}
> >
{formattedConfig ? ( {formattedConfig ? (
@ -41,6 +41,6 @@ const InstanceImagePage = observer(function InstanceImagePage(_props: Route.Comp
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Images Settings - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Изображения - NODE.DC" }];
export default InstanceImagePage; export default InstanceImagePage;

View File

@ -38,9 +38,9 @@ function AdminLayout(_props: Route.ComponentProps) {
if (isUserLoggedIn) { if (isUserLoggedIn) {
return ( return (
<div className="relative flex h-screen w-screen overflow-hidden"> <div className="nodedc-admin-shell relative flex h-screen w-screen overflow-hidden">
<AdminSidebar /> <AdminSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1"> <main className="nodedc-admin-main relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<AdminHeader /> <AdminHeader />
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll"> <div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
<Outlet /> <Outlet />

View File

@ -36,11 +36,13 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
const handleSignOut = () => signOut(); const handleSignOut = () => signOut();
const getSidebarMenuItems = () => ( const getSidebarMenuItems = (align: "left" | "right" = "left") => (
<Menu.Items <Menu.Items
className={cn( className={cn(
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none", "nodedc-glass-popup-surface absolute z-20 mt-1.5 flex w-56 flex-col divide-y divide-white/6 px-2 py-2 text-11 outline-none",
{ {
"left-0": align === "left",
"right-0": align === "right",
"left-4": isSidebarCollapsed, "left-4": isSidebarCollapsed,
} }
)} )}
@ -52,11 +54,11 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item <Menu.Item
as="button" as="button"
type="button" type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover" className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
onClick={handleThemeSwitch} onClick={handleThemeSwitch}
> >
<Palette className="h-4 w-4 stroke-[1.5]" /> <Palette className="h-4 w-4 stroke-[1.5]" />
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode {resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
</Menu.Item> </Menu.Item>
</div> </div>
<div className="py-2"> <div className="py-2">
@ -65,10 +67,10 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
<Menu.Item <Menu.Item
as="button" as="button"
type="submit" type="submit"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 hover:bg-layer-1-hover" className="nodedc-settings-sidebar-item flex w-full items-center gap-2 px-2 py-1 text-left"
> >
<LogOut className="h-4 w-4 stroke-[1.5]" /> <LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out Выйти
</Menu.Item> </Menu.Item>
</form> </form>
</div> </div>
@ -81,10 +83,10 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
}, [csrfToken]); }, [csrfToken]);
return ( return (
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-subtle px-4 py-2.5"> <div className="px-3 pt-4 pb-2">
<div className="h-full w-full truncate"> <div className="h-full w-full truncate">
<div <div
className={`flex flex-grow items-center gap-x-2 truncate rounded-sm ${ className={`nodedc-admin-sidebar-profile flex flex-grow items-center gap-x-3 truncate px-3 py-3 ${
isSidebarCollapsed ? "justify-center" : "" isSidebarCollapsed ? "justify-center" : ""
}`} }`}
> >
@ -94,8 +96,8 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
"cursor-default": !isSidebarCollapsed, "cursor-default": !isSidebarCollapsed,
})} })}
> >
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-sm bg-layer-1"> <div className="nodedc-admin-sidebar-avatar-button flex size-10 flex-shrink-0 items-center justify-center">
<UserCog2 className="size-5 text-primary" /> <UserCog2 className="size-5 text-[rgb(var(--nodedc-card-active-rgb))]" />
</div> </div>
</Menu.Button> </Menu.Button>
{isSidebarCollapsed && ( {isSidebarCollapsed && (
@ -114,38 +116,39 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
</Menu> </Menu>
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<div className="flex w-full gap-2"> <div className="min-w-0 flex-1">
<h4 className="grow truncate text-body-md-medium text-primary">Instance admin</h4> <h4 className="truncate text-15 font-medium text-primary">Глобальный админ</h4>
<div className="truncate text-11 font-medium text-tertiary">Супер-администратор</div>
</div> </div>
)} )}
{!isSidebarCollapsed && currentUser && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="nodedc-admin-sidebar-action grid size-8 place-items-center outline-none">
<Avatar
name={currentUser.display_name}
src={getFileURL(currentUser.avatar_url)}
size={24}
shape="square"
className="!text-body-sm-medium"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems("right")}
</Transition>
</Menu>
)}
</div> </div>
</div> </div>
{!isSidebarCollapsed && currentUser && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none">
<Avatar
name={currentUser.display_name}
src={getFileURL(currentUser.avatar_url)}
size={24}
shape="square"
className="!text-body-sm-medium"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{getSidebarMenuItems()}
</Transition>
</Menu>
)}
</div> </div>
); );
}); });

View File

@ -20,17 +20,17 @@ import { useInstance, useTheme } from "@/hooks/store";
const helpOptions = [ const helpOptions = [
{ {
name: "Documentation", name: "Документация",
href: "https://docs.plane.so/", href: "https://docs.plane.so/",
Icon: PageIcon, Icon: PageIcon,
}, },
{ {
name: "Join our Forum", name: "Форум Plane",
href: "https://forum.plane.so", href: "https://forum.plane.so",
Icon: MessageSquare, Icon: MessageSquare,
}, },
{ {
name: "Report a bug", name: "Сообщить об ошибке",
href: "https://github.com/makeplane/plane/issues/new/choose", href: "https://github.com/makeplane/plane/issues/new/choose",
Icon: GithubIcon, Icon: GithubIcon,
}, },
@ -50,26 +50,26 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
return ( return (
<div <div
className={cn( className={cn(
"flex h-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4", "flex h-16 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-white/6 px-3",
{ {
"h-auto flex-col py-1.5": isSidebarCollapsed, "h-auto flex-col py-1.5": isSidebarCollapsed,
} }
)} )}
> >
<div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}> <div className={`flex items-center gap-1 ${isSidebarCollapsed ? "flex-col justify-center" : "w-full"}`}>
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}> <Tooltip tooltipContent="Перейти в приложение" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
<a <a
href={redirectionLink} href={redirectionLink}
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`} className="nodedc-admin-sidebar-action relative flex items-center gap-1 px-3 py-1 text-12 font-medium whitespace-nowrap"
> >
<NewTabIcon width={14} height={14} /> <NewTabIcon width={14} height={14} />
{!isSidebarCollapsed && "Redirect to Plane"} {!isSidebarCollapsed && "В приложение"}
</a> </a>
</Tooltip> </Tooltip>
<Tooltip tooltipContent="Help" position={isSidebarCollapsed ? "right" : "top"} className="ml-4"> <Tooltip tooltipContent="Помощь" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button <button
type="button" type="button"
className={`ml-auto grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${ className={`nodedc-admin-sidebar-action ml-auto grid place-items-center p-1.5 outline-none ${
isSidebarCollapsed ? "w-full" : "" isSidebarCollapsed ? "w-full" : ""
}`} }`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)} onClick={() => setIsNeedHelpOpen((prev) => !prev)}
@ -77,10 +77,10 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<HelpCircle className="size-4" /> <HelpCircle className="size-4" />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip tooltipContent="Toggle sidebar" position={isSidebarCollapsed ? "right" : "top"} className="ml-4"> <Tooltip tooltipContent="Свернуть меню" position={isSidebarCollapsed ? "right" : "top"} className="ml-4">
<button <button
type="button" type="button"
className={`grid place-items-center rounded-md p-1.5 text-secondary outline-none hover:bg-layer-1-hover hover:text-primary ${ className={`nodedc-admin-sidebar-action grid place-items-center p-1.5 outline-none ${
isSidebarCollapsed ? "w-full" : "" isSidebarCollapsed ? "w-full" : ""
}`} }`}
onClick={() => toggleSidebar(!isSidebarCollapsed)} onClick={() => toggleSidebar(!isSidebarCollapsed)}
@ -103,7 +103,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
<div <div
className={`absolute bottom-2 z-[15] min-w-[10rem] ${ className={`absolute bottom-2 z-[15] min-w-[10rem] ${
isSidebarCollapsed ? "left-full" : "-left-[75px]" isSidebarCollapsed ? "left-full" : "-left-[75px]"
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`} } nodedc-glass-popup-surface divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
ref={helpOptionsRef} ref={helpOptionsRef}
> >
<div className="space-y-1 pb-2"> <div className="space-y-1 pb-2">
@ -134,7 +134,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
); );
})} })}
</div> </div>
<div className="px-2 pt-2 pb-1 text-10">Version: v{instance?.current_version}</div> <div className="px-2 pt-2 pb-1 text-10">Версия: v{instance?.current_version}</div>
</div> </div>
</Transition> </Transition>
</div> </div>

View File

@ -21,6 +21,10 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
const { isSidebarCollapsed, toggleSidebar } = useTheme(); const { isSidebarCollapsed, toggleSidebar } = useTheme();
// derived values // derived values
const sidebarMenu = useSidebarMenu(); const sidebarMenu = useSidebarMenu();
const sidebarMenuGroups = [
{ label: "ИНСТАНС", items: sidebarMenu.slice(0, 4) },
{ label: "ВОЗМОЖНОСТИ", items: sidebarMenu.slice(4) },
];
const handleItemClick = () => { const handleItemClick = () => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
@ -29,36 +33,33 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
}; };
return ( return (
<div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-2.5 overflow-y-scroll px-4 py-4"> <div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col overflow-y-scroll px-3 py-4">
{sidebarMenu.map((item, index) => { {sidebarMenuGroups.map((group) => (
const isActive = item.href === pathName || pathName?.includes(item.href); <div key={group.label} className="shrink-0 border-b border-white/6 py-3 first:pt-0 last:border-b-0 last:pb-0">
return ( {!isSidebarCollapsed && <div className="nodedc-admin-sidebar-section-label">{group.label}</div>}
<Link key={index} href={item.href} onClick={handleItemClick}> <div className="flex flex-col gap-1">
<div> {group.items.map((item) => {
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}> const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
<div return (
className={cn( <Link key={item.href} href={item.href} onClick={handleItemClick}>
"group flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors outline-none", <Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
{ <div
"!bg-layer-transparent-active text-primary": isActive, className={cn(
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive, "nodedc-settings-sidebar-item group flex w-full items-center gap-3 px-3 py-2 text-14 font-medium transition-colors outline-none",
}, isSidebarCollapsed ? "justify-center" : "w-full"
isSidebarCollapsed ? "justify-center" : "w-[260px]" )}
)} data-active={isActive}
> >
{<item.Icon className="h-4 w-4 flex-shrink-0" />} {<item.Icon className="h-4 w-4 flex-shrink-0" />}
{!isSidebarCollapsed && ( {!isSidebarCollapsed && <div className="min-w-0 truncate transition-colors">{item.name}</div>}
<div className="w-full">
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
</div> </div>
)} </Tooltip>
</div> </Link>
</Tooltip> );
</div> })}
</Link> </div>
); </div>
})} ))}
</div> </div>
); );
}); });

View File

@ -44,7 +44,7 @@ export const AdminSidebar = observer(function AdminSidebar() {
return ( return (
<div <div
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `} className={`nodedc-glass-sidebar fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
> >
<div ref={ref} className="flex h-full w-full flex-1 flex-col"> <div ref={ref} className="flex h-full w-full flex-1 flex-col">
<AdminSidebarDropdown /> <AdminSidebarDropdown />

View File

@ -56,16 +56,16 @@ export function WorkspaceCreateForm() {
.then(async () => { .then(async () => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: "Готово",
message: "Workspace created successfully.", message: "Воркспейс создан.",
}); });
router.push(`/workspace`); router.push(`/workspace`);
}) })
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: "Workspace could not be created. Please try again.", message: "Не удалось создать воркспейс. Попробуйте еще раз.",
}); });
}); });
} else setSlugError(true); } else setSlugError(true);
@ -73,8 +73,8 @@ export function WorkspaceCreateForm() {
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: "Some error occurred while creating workspace. Please try again.", message: "При создании воркспейса произошла ошибка. Попробуйте еще раз.",
}); });
}); });
}; };
@ -91,7 +91,7 @@ export function WorkspaceCreateForm() {
<div className="space-y-8"> <div className="space-y-8">
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2"> <div className="grid-col grid w-full max-w-4xl grid-cols-1 items-start justify-between gap-x-10 gap-y-6 lg:grid-cols-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Name your workspace</h4> <h4 className="text-13 text-tertiary">Название воркспейса</h4>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Controller <Controller
control={control} control={control}
@ -113,8 +113,8 @@ export function WorkspaceCreateForm() {
}} }}
ref={ref} ref={ref}
hasError={Boolean(errors.name)} hasError={Boolean(errors.name)}
placeholder="Something familiar and recognizable is always best." placeholder="Короткое понятное название"
className="w-full" className="nodedc-settings-input w-full"
/> />
)} )}
/> />
@ -122,8 +122,8 @@ export function WorkspaceCreateForm() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">Set your workspace&apos;s URL</h4> <h4 className="text-13 text-tertiary">URL воркспейса</h4>
<div className="flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3"> <div className="nodedc-settings-input flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
<span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span> <span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span>
<Controller <Controller
control={control} control={control}
@ -149,29 +149,29 @@ export function WorkspaceCreateForm() {
)} )}
/> />
</div> </div>
{slugError && <p className="text-13 text-danger-primary">This URL is taken. Try something else.</p>} {slugError && <p className="text-13 text-danger-primary">Этот URL уже занят. Выберите другой.</p>}
{invalidSlug && ( {invalidSlug && (
<p className="text-13 text-danger-primary">{`URLs can contain only ( - ), ( _ ) and alphanumeric characters.`}</p> <p className="text-13 text-danger-primary">{`URL может содержать только латинские буквы, цифры, "-" и "_".`}</p>
)} )}
{errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>} {errors.slug && <span className="text-11 text-danger-primary">{errors.slug.message}</span>}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-13 text-tertiary">How many people will use this workspace?</h4> <h4 className="text-13 text-tertiary">Сколько людей будет работать в воркспейсе?</h4>
<div className="w-full"> <div className="w-full">
<Controller <Controller
name="organization_size" name="organization_size"
control={control} control={control}
rules={{ required: "This is a required field." }} rules={{ required: "Это обязательное поле." }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<CustomSelect <CustomSelect
value={value} value={value}
onChange={onChange} onChange={onChange}
label={ label={
ORGANIZATION_SIZE.find((c) => c === value) ?? ( ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-placeholder">Select a range</span> <span className="text-placeholder">Выберите диапазон</span>
) )
} }
buttonClassName="!border-[0.5px] !border-subtle !shadow-none" buttonClassName="nodedc-settings-select !border-[0.5px] !border-subtle !shadow-none"
input input
> >
{ORGANIZATION_SIZE.map((item) => ( {ORGANIZATION_SIZE.map((item) => (
@ -196,10 +196,10 @@ export function WorkspaceCreateForm() {
disabled={!isValid} disabled={!isValid}
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Creating workspace" : "Create workspace"} {isSubmitting ? "Создание" : "Создать воркспейс"}
</Button> </Button>
<Link className={getButtonStyling("secondary", "lg")} href="/workspace"> <Link className={getButtonStyling("secondary", "lg")} href="/workspace">
Go back Назад
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -16,8 +16,8 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: "Create a new workspace on this instance.", title: "Создать новый воркспейс",
description: "You will need to invite users from Workspace Settings after you create this workspace.", description: "После создания пригласите пользователей в настройках рабочего пространства.",
}} }}
> >
<WorkspaceCreateForm /> <WorkspaceCreateForm />
@ -25,6 +25,6 @@ const WorkspaceCreatePage = observer(function WorkspaceCreatePage(_props: Route.
); );
}); });
export const meta: Route.MetaFunction = () => [{ title: "Create Workspace - God Mode" }]; export const meta: Route.MetaFunction = () => [{ title: "Создание воркспейса - NODE.DC" }];
export default WorkspaceCreatePage; export default WorkspaceCreatePage;

View File

@ -5,14 +5,16 @@
*/ */
import Link from "next/link"; import Link from "next/link";
import { PlaneLockup } from "@plane/propel/icons";
export function AuthHeader() { export function AuthHeader() {
return ( return (
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6"> <div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
<Link href="/"> <Link href="/">
<PlaneLockup height={20} width={95} className="text-primary" /> <span className="tracking-normal text-16 font-semibold text-primary">NODE.DC</span>
</Link> </Link>
<span className="rounded-full bg-white/6 px-3 py-1 text-11 font-medium text-secondary">
Глобальное администрирование
</span>
</div> </div>
); );
} }

View File

@ -22,56 +22,56 @@ const errorCodeMessages: {
} = { } = {
// admin // admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: { [EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
title: `Admin already exists`, title: `Администратор уже существует`,
message: () => `Admin already exists. Please try again.`, message: () => `Администратор уже существует. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: {
title: `Email, password and first name required`, title: `Нужны email, пароль и имя`,
message: () => `Email, password and first name required. Please try again.`, message: () => `Укажите email, пароль и имя. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: { [EAdminAuthErrorCodes.INVALID_ADMIN_EMAIL]: {
title: `Invalid admin email`, title: `Некорректный email администратора`,
message: () => `Invalid admin email. Please try again.`, message: () => `Некорректный email администратора. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: { [EAdminAuthErrorCodes.INVALID_ADMIN_PASSWORD]: {
title: `Invalid admin password`, title: `Некорректный пароль администратора`,
message: () => `Invalid admin password. Please try again.`, message: () => `Некорректный пароль администратора. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { [EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: {
title: `Email and password required`, title: `Нужны email и пароль`,
message: () => `Email and password required. Please try again.`, message: () => `Укажите email и пароль. Попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { [EAdminAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED]: {
title: `Authentication failed`, title: `Ошибка входа`,
message: () => `Authentication failed. Please try again.`, message: () => `Не удалось войти. Проверьте данные и попробуйте еще раз.`,
}, },
[EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: { [EAdminAuthErrorCodes.ADMIN_USER_ALREADY_EXIST]: {
title: `Admin user already exists`, title: `Администратор уже существует`,
message: () => ( message: () => (
<div> <div>
Admin user already exists.&nbsp; Администратор уже существует.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}> <Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
Sign In Войти
</Link> </Link>
&nbsp;now. &nbsp;сейчас.
</div> </div>
), ),
}, },
[EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { [EAdminAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: {
title: `Admin user does not exist`, title: `Администратор не найден`,
message: () => ( message: () => (
<div> <div>
Admin user does not exist.&nbsp; Администратор не найден.&nbsp;
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}> <Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
Sign In Войти
</Link> </Link>
&nbsp;now. &nbsp;сейчас.
</div> </div>
), ),
}, },
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: { [EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`, title: `Аккаунт деактивирован`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, message: () => `Аккаунт деактивирован. Свяжитесь с ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "администратором"}.`,
}, },
}; };
@ -92,8 +92,8 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
return { return {
type: EErrorAlertType.BANNER_ALERT, type: EErrorAlertType.BANNER_ALERT,
code: errorCode, code: errorCode,
title: errorCodeMessages[errorCode]?.title || "Error", title: errorCodeMessages[errorCode]?.title || "Ошибка",
message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", message: errorCodeMessages[errorCode]?.message(email) || "Что-то пошло не так. Попробуйте еще раз.",
}; };
return undefined; return undefined;

View File

@ -22,7 +22,7 @@ function RootLayout() {
}, [replace, isUserLoggedIn]); }, [replace, isUserLoggedIn]);
return ( return (
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10"> <div className="nodedc-auth-shell relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
<Outlet /> <Outlet />
</div> </div>
); );

View File

@ -45,6 +45,6 @@ function HomePage() {
export default observer(HomePage); export default observer(HomePage);
export const meta: Route.MetaFunction = () => [ export const meta: Route.MetaFunction = () => [
{ title: "Admin Instance Setup & Sign-In" }, { title: "NODE.DC - вход в админ-панель" },
{ name: "description", content: "Configure your Plane instance or sign in to the admin portal." }, { name: "description", content: "Настройка инстанса NODE.DC и вход в глобальную админ-панель." },
]; ];

View File

@ -112,10 +112,10 @@ export function InstanceSignInForm() {
<> <>
<AuthHeader /> <AuthHeader />
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
<FormHeader <FormHeader
heading="Manage your Plane instance" heading="Управление инстансом NODE.DC"
subHeading="Configure instance-wide settings to secure your instance" subHeading="Войдите, чтобы менять глобальные настройки системы"
/> />
<form <form
className="space-y-4" className="space-y-4"
@ -135,10 +135,10 @@ export function InstanceSignInForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email"> <label className="text-13 font-medium text-tertiary" htmlFor="email">
Email <span className="text-danger-primary">*</span> Электронная почта <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="email" id="email"
name="email" name="email"
type="email" type="email"
@ -153,16 +153,16 @@ export function InstanceSignInForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password"> <label className="text-13 font-medium text-tertiary" htmlFor="password">
Password <span className="text-danger-primary">*</span> Пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="password" id="password"
name="password" name="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
inputSize="md" inputSize="md"
placeholder="Enter your password" placeholder="Введите пароль"
value={formData.password} value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
autoComplete="off" autoComplete="off"
@ -188,7 +188,7 @@ export function InstanceSignInForm() {
</div> </div>
<div className="py-2"> <div className="py-2">
<Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}> <Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Sign in"} {isSubmitting ? <Spinner height="20px" width="20px" /> : "Войти"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -17,19 +17,16 @@ function PageNotFound() {
<div className="grid h-full place-items-center p-4"> <div className="grid h-full place-items-center p-4">
<div className="space-y-8 text-center"> <div className="space-y-8 text-center">
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80"> <div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
<img src={Image404} alt="404 - Page not found" className="h-full w-full object-contain" /> <img src={Image404} alt="404 - страница не найдена" className="h-full w-full object-contain" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-16 font-semibold">Oops! Something went wrong.</h3> <h3 className="text-16 font-semibold">Страница не найдена</h3>
<p className="text-13 text-secondary"> <p className="text-13 text-secondary">Похоже, раздел был удален, переименован или временно недоступен.</p>
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
temporarily unavailable.
</p>
</div> </div>
<Link to="/general/"> <Link to="/general/">
<span className="flex justify-center py-4"> <span className="flex justify-center py-4">
<Button variant="secondary" size="lg"> <Button variant="secondary" size="lg">
Go to general settings Перейти в основные настройки
</Button> </Button>
</span> </span>
</Link> </Link>

View File

@ -21,9 +21,8 @@ import interVariableWoff2 from "@fontsource-variable/inter/files/inter-latin-wgh
import "@fontsource/material-symbols-rounded"; import "@fontsource/material-symbols-rounded";
import "@fontsource/ibm-plex-mono"; import "@fontsource/ibm-plex-mono";
const APP_TITLE = "Plane | Simple, extensible, open-source project management tool."; const APP_TITLE = "NODE.DC | Глобальное администрирование";
const APP_DESCRIPTION = const APP_DESCRIPTION = "Панель глобального администрирования инстанса NODE.DC.";
"Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon }, { rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
@ -43,7 +42,7 @@ export const links: LinksFunction = () => [
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="ru" suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -66,15 +65,14 @@ export const meta: Route.MetaFunction = () => [
{ property: "og:url", content: "https://plane.so/" }, { property: "og:url", content: "https://plane.so/" },
{ {
name: "keywords", name: "keywords",
content: content: "NODE.DC, администрирование, рабочие пространства, проекты, пользователи, настройки инстанса",
"software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration",
}, },
{ name: "twitter:site", content: "@planepowers" }, { name: "twitter:site", content: "@nodedc" },
]; ];
export default function Root() { export default function Root() {
return ( return (
<div className="min-h-screen bg-canvas"> <div className="nodedc-admin-root min-h-screen bg-canvas">
<Outlet /> <Outlet />
</div> </div>
); );
@ -91,7 +89,7 @@ export function HydrateFallback() {
export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error: _error }: Route.ErrorBoundaryProps) {
return ( return (
<div> <div>
<p>Something went wrong.</p> <p>Что-то пошло не так.</p>
</div> </div>
); );
} }

View File

@ -22,7 +22,7 @@ export function AuthenticationMethodCard(props: Props) {
return ( return (
<div <div
className={cn("flex w-full items-center gap-14 rounded-lg bg-layer-2", { className={cn("nodedc-settings-card flex w-full items-center gap-14 rounded-lg bg-layer-2", {
"border border-subtle px-4 py-3": withBorder, "border border-subtle px-4 py-3": withBorder,
})} })}
> >

View File

@ -35,7 +35,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
{GiteaConfigured ? ( {GiteaConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/gitea" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/gitea" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(GiteaConfig))} value={Boolean(parseInt(GiteaConfig))}
@ -51,7 +51,7 @@ export const GiteaConfiguration = observer(function GiteaConfiguration(props: Pr
) : ( ) : (
<Link href="/authentication/gitea" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/gitea" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
{isGithubConfigured ? ( {isGithubConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/github" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/github" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))} value={Boolean(parseInt(enableGithubConfig))}
@ -49,7 +49,7 @@ export const GithubConfiguration = observer(function GithubConfiguration(props:
) : ( ) : (
<Link href="/authentication/github" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/github" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
{isGitlabConfigured ? ( {isGitlabConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/gitlab" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/gitlab" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))} value={Boolean(parseInt(enableGitlabConfig))}
@ -49,7 +49,7 @@ export const GitlabConfiguration = observer(function GitlabConfiguration(props:
) : ( ) : (
<Link href="/authentication/gitlab" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/gitlab" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -34,7 +34,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
{isGoogleConfigured ? ( {isGoogleConfigured ? (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/authentication/google" className={cn(getButtonStyling("link", "base"), "font-medium")}> <Link href="/authentication/google" className={cn(getButtonStyling("link", "base"), "font-medium")}>
Edit Изменить
</Link> </Link>
<ToggleSwitch <ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))} value={Boolean(parseInt(enableGoogleConfig))}
@ -49,7 +49,7 @@ export const GoogleConfiguration = observer(function GoogleConfiguration(props:
) : ( ) : (
<Link href="/authentication/google" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}> <Link href="/authentication/google" className={cn(getButtonStyling("secondary", "base"), "text-tertiary")}>
<Settings2 className="h-4 w-4 p-0.5 text-tertiary" /> <Settings2 className="h-4 w-4 p-0.5 text-tertiary" />
Configure Настроить
</Link> </Link>
)} )}
</> </>

View File

@ -11,26 +11,35 @@ type Props = {
label?: string; label?: string;
href?: string; href?: string;
icon?: React.ReactNode | undefined; icon?: React.ReactNode | undefined;
isCurrent?: boolean;
}; };
export function BreadcrumbLink(props: Props) { export function BreadcrumbLink(props: Props) {
const { href, label, icon } = props; const { href, label, icon, isCurrent = false } = props;
const content = (
<>
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>}
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
</>
);
return ( return (
<Tooltip tooltipContent={label} position="bottom"> <Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2" tabIndex={-1}> <li className="flex items-center" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5"> {href && !isCurrent ? (
{href ? ( <Link className="nodedc-admin-breadcrumb-pill flex items-center gap-1.5 text-13 font-medium" href={href}>
<Link className="flex items-center gap-1 text-13 font-medium text-tertiary hover:text-primary" href={href}> {content}
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>} </Link>
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div> ) : (
</Link> <div
) : ( className="nodedc-admin-breadcrumb-pill flex cursor-default items-center gap-1.5 text-13 font-medium"
<div className="flex cursor-default items-center gap-1 text-13 font-medium text-primary"> data-current={isCurrent}
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>} aria-current={isCurrent ? "page" : undefined}
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div> >
</div> {content}
)} </div>
</div> )}
</li> </li>
</Tooltip> </Tooltip>
); );

View File

@ -16,7 +16,7 @@ export function CodeBlock({ children, className, darkerShade }: TProps) {
return ( return (
<span <span
className={cn( className={cn(
"rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary", "nodedc-code-chip rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
{ {
"border-subtle bg-layer-1 text-secondary": darkerShade, "border-subtle bg-layer-1 text-secondary": darkerShade,
}, },

View File

@ -45,27 +45,25 @@ export function ConfirmDiscardModal(props: Props) {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]"> <Dialog.Panel className="nodedc-glass-modal relative transform overflow-hidden rounded-[1.75rem] bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary"> <Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary">
You have unsaved changes Есть несохраненные изменения
</Dialog.Title> </Dialog.Title>
<div className="mt-2"> <div className="mt-2">
<p className="text-13 text-placeholder"> <p className="text-13 text-placeholder">Если уйти назад, текущие правки будут потеряны.</p>
Changes you made will be lost if you go back. Do you wish to go back?
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 p-4 sm:px-6"> <div className="flex items-center justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}> <Button variant="secondary" size="lg" onClick={handleClose}>
Keep editing Продолжить редактирование
</Button> </Button>
<Link href={onDiscardHref} className={getButtonStyling("primary", "base")}> <Link href={onDiscardHref} className={getButtonStyling("primary", "base")}>
Go back Уйти назад
</Link> </Link>
</div> </div>
</Dialog.Panel> </Dialog.Panel>

View File

@ -46,7 +46,7 @@ export function ControllerInput(props: Props) {
<Controller <Controller
control={control} control={control}
name={name} name={name}
rules={{ required: required ? `${label} is required.` : false }} rules={{ required: required ? `Поле "${label}" обязательно.` : false }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange, ref } }) => (
<Input <Input
id={name} id={name}
@ -57,7 +57,7 @@ export function ControllerInput(props: Props) {
ref={ref} ref={ref}
hasError={error} hasError={error}
placeholder={placeholder} placeholder={placeholder}
className={cn("w-full rounded-md font-medium", { className={cn("nodedc-settings-input w-full rounded-md font-medium", {
"pr-10": type === "password", "pr-10": type === "password",
})} })}
/> />

View File

@ -27,7 +27,7 @@ export function ControllerSwitch<T extends FieldValues>(props: Props<T>) {
return ( return (
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<h4 className="text-sm text-custom-text-300">Refresh user attributes from {label} during sign in</h4> <h4 className="text-sm text-custom-text-300">Обновлять атрибуты пользователя из {label} при входе</h4>
<div className="relative"> <div className="relative">
<Controller <Controller
control={control} control={control}

View File

@ -32,18 +32,18 @@ export function CopyField(props: Props) {
<Button <Button
variant="secondary" variant="secondary"
size="lg" size="lg"
className="flex items-center justify-between py-2" className="nodedc-settings-secondary-button flex w-full items-center justify-between gap-3 py-2 text-left"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
setToast({ setToast({
type: TOAST_TYPE.INFO, type: TOAST_TYPE.INFO,
title: "Copied to clipboard", title: "Скопировано",
message: `The ${label} has been successfully copied to your clipboard`, message: `${label} скопировано в буфер обмена`,
}); });
}} }}
> >
<p className="text-13 font-medium">{url}</p> <p className="min-w-0 truncate text-13 font-medium">{url}</p>
<CopyIcon width={18} height={18} color="#B9B9B9" /> <CopyIcon width={18} height={18} color="#B9B9B9" className="shrink-0" />
</Button> </Button>
<div className="text-11 text-tertiary">{description}</div> <div className="text-11 text-tertiary">{description}</div>
</div> </div>

View File

@ -5,15 +5,15 @@
*/ */
export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = { export const CORE_HEADER_SEGMENT_LABELS: Record<string, string> = {
general: "General", general: "Основное",
ai: "Artificial Intelligence", ai: "Искусственный интеллект",
email: "Email", email: "Почта",
authentication: "Authentication", authentication: "Аутентификация",
image: "Image", image: "Изображения",
google: "Google", google: "Google",
github: "GitHub", github: "GitHub",
gitlab: "GitLab", gitlab: "GitLab",
gitea: "Gitea", gitea: "Gitea",
workspace: "Workspace", workspace: "Воркспейсы",
create: "Create", create: "Создание",
}; };

View File

@ -4,11 +4,10 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { Fragment } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Menu, Settings } from "lucide-react"; import { ChevronRight, Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "../breadcrumb-link"; import { BreadcrumbLink } from "../breadcrumb-link";
// hooks // hooks
@ -22,6 +21,7 @@ export const HamburgerToggle = observer(function HamburgerToggle() {
return ( return (
<button <button
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden" className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
aria-label="Открыть меню"
onClick={() => toggleSidebar(!isSidebarCollapsed)} onClick={() => toggleSidebar(!isSidebarCollapsed)}
> >
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" /> <Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
@ -56,32 +56,28 @@ export const AdminHeader = observer(function AdminHeader() {
const breadcrumbItems = generateBreadcrumbItems(pathName || ""); const breadcrumbItems = generateBreadcrumbItems(pathName || "");
return ( return (
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4"> <div className="nodedc-admin-header nodedc-glass-modal relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<HamburgerToggle /> <HamburgerToggle />
{breadcrumbItems.length >= 0 && ( {breadcrumbItems.length >= 0 && (
<div> <nav className="min-w-0" aria-label="Навигация God Mode">
<Breadcrumbs> <ol className="nodedc-admin-breadcrumbs">
<Breadcrumbs.Item <BreadcrumbLink href="/general/" label="Настройки" icon={<Settings className="h-4 w-4" />} />
component={ {breadcrumbItems.map((item, index) => {
<BreadcrumbLink if (!item.title) return null;
href="/general/" const isCurrent = index === breadcrumbItems.length - 1;
label="Settings"
icon={<Settings className="h-4 w-4 text-tertiary" />} return (
/> <Fragment key={`${item.href}-${item.title}`}>
} <li className="nodedc-admin-breadcrumb-separator" aria-hidden="true">
/> <ChevronRight className="size-4" />
{breadcrumbItems.map( </li>
(item) => <BreadcrumbLink href={item.href} label={item.title} isCurrent={isCurrent} />
item.title && ( </Fragment>
<Breadcrumbs.Item );
key={item.title} })}
component={<BreadcrumbLink href={item.href} label={item.title} />} </ol>
/> </nav>
)
)}
</Breadcrumbs>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -24,20 +24,19 @@ export const NewUserPopup = observer(function NewUserPopup() {
if (!isNewUserPopup) return <></>; if (!isNewUserPopup) return <></>;
return ( return (
<div className="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6"> <div className="nodedc-glass-modal shadow-md absolute right-8 bottom-8 w-96 rounded-[1.75rem] border border-subtle bg-surface-1 p-6">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="grow"> <div className="grow">
<div className="text-14 font-semibold">Create workspace</div> <div className="text-14 font-semibold">Создать воркспейс</div>
<div className="py-2 text-13 font-medium text-tertiary"> <div className="py-2 text-13 font-medium text-tertiary">
Instance setup done! Welcome to Plane instance portal. Start your journey with by creating your first Настройка инстанса завершена. Создайте первое рабочее пространство, чтобы начать работу.
workspace.
</div> </div>
<div className="flex items-center gap-4 pt-2"> <div className="flex items-center gap-4 pt-2">
<Link href="/workspace/create" className={getButtonStyling("primary", "lg")}> <Link href="/workspace/create" className={getButtonStyling("primary", "lg")}>
Create workspace Создать воркспейс
</Link> </Link>
<Button variant="secondary" size="lg" onClick={toggleNewUserPopup}> <Button variant="secondary" size="lg" onClick={toggleNewUserPopup}>
Close Закрыть
</Button> </Button>
</div> </div>
</div> </div>
@ -46,7 +45,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight} src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
height={80} height={80}
width={80} width={80}
alt="Plane icon" alt="NODE.DC"
/> />
</div> </div>
</div> </div>

View File

@ -10,7 +10,8 @@ type TPageHeader = {
}; };
export function PageHeader(props: TPageHeader) { export function PageHeader(props: TPageHeader) {
const { title = "God Mode - Plane", description = "Plane god mode" } = props; const { title = "NODE.DC - глобальное администрирование", description = "Глобальное администрирование NODE.DC" } =
props;
return ( return (
<> <>

View File

@ -24,16 +24,16 @@ export const PageWrapper = (props: TPageWrapperProps) => {
return ( return (
<div <div
className={cn("mx-auto h-full w-full space-y-6 py-4", { className={cn("nodedc-page mx-auto h-full w-full space-y-6 py-4", {
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md", "max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
"px-4 lg:px-12": size === "lg", "px-4 lg:px-12": size === "lg",
})} })}
> >
{customHeader ? ( {customHeader ? (
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div> <div className="nodedc-page-header mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
) : ( ) : (
header && ( header && (
<div className="mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4"> <div className="nodedc-page-header mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}> <div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
<div className="text-h5-semibold text-primary">{header.title}</div> <div className="text-h5-semibold text-primary">{header.title}</div>
<div className="text-body-sm-regular text-secondary">{header.description}</div> <div className="text-body-sm-regular text-secondary">{header.description}</div>
@ -42,7 +42,7 @@ export const PageWrapper = (props: TPageWrapperProps) => {
</div> </div>
) )
)} )}
<div className="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4"> <div className="nodedc-page-body vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
{children} {children}
</div> </div>
</div> </div>

View File

@ -27,15 +27,15 @@ export const InstanceFailureView = observer(function InstanceFailureView() {
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
<div className="relative flex flex-col items-center justify-center space-y-4"> <div className="relative flex flex-col items-center justify-center space-y-4">
<img src={instanceImage} alt="Instance failure illustration" /> <img src={instanceImage} alt="Ошибка загрузки инстанса" />
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3> <h3 className="text-center text-20 font-medium text-on-color">Не удалось загрузить данные инстанса.</h3>
<p className="text-center text-14 font-medium"> <p className="text-center text-14 font-medium">
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue. Проверьте соединение с API и попробуйте обновить страницу.
</p> </p>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<Button size="lg" onClick={handleRetry}> <Button size="lg" onClick={handleRetry}>
Retry Повторить
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -14,15 +14,15 @@ export function InstanceNotReady() {
<div className="relative container mx-auto flex h-full w-full items-center justify-center px-5"> <div className="relative container mx-auto flex h-full w-full items-center justify-center px-5">
<div className="relative w-auto max-w-2xl space-y-8 py-10"> <div className="relative w-auto max-w-2xl space-y-8 py-10">
<div className="relative flex flex-col items-center justify-center space-y-4"> <div className="relative flex flex-col items-center justify-center space-y-4">
<h1 className="pb-3 text-24 font-bold">Welcome aboard Plane!</h1> <h1 className="pb-3 text-24 font-bold">Добро пожаловать в NODE.DC</h1>
<img src={PlaneTakeOffImage} alt="Plane Logo" /> <img src={PlaneTakeOffImage} alt="NODE.DC" />
<p className="text-14 font-medium text-placeholder">Get started by setting up your instance and workspace</p> <p className="text-14 font-medium text-placeholder">Начните с настройки инстанса и первого воркспейса</p>
</div> </div>
<div> <div>
<Link href={"/setup/?auth_enabled=0"}> <Link href={"/setup/?auth_enabled=0"}>
<Button size="xl" className="w-full"> <Button size="xl" className="w-full">
Get started Начать
</Button> </Button>
</Link> </Link>
</div> </div>

View File

@ -140,10 +140,10 @@ export function InstanceSetupForm() {
<> <>
<AuthHeader /> <AuthHeader />
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6"> <div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6"> <div className="nodedc-auth-card relative flex w-full max-w-[22.5rem] flex-col gap-6">
<FormHeader <FormHeader
heading="Setup your Plane Instance" heading="Настройка инстанса NODE.DC"
subHeading="Post setup you will be able to manage this Plane instance." subHeading="После настройки вы получите доступ к глобальному администрированию."
/> />
{errorData.type && {errorData.type &&
errorData?.message && errorData?.message &&
@ -163,15 +163,15 @@ export function InstanceSetupForm() {
<div className="flex flex-col items-center gap-4 sm:flex-row"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="first_name"> <label className="text-13 font-medium text-tertiary" htmlFor="first_name">
First name <span className="text-danger-primary">*</span> Имя <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="first_name" id="first_name"
name="first_name" name="first_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Wilber" placeholder="Иван"
value={formData.first_name} value={formData.first_name}
onChange={(e) => { onChange={(e) => {
const validation = validatePersonName(e.target.value); const validation = validatePersonName(e.target.value);
@ -186,15 +186,15 @@ export function InstanceSetupForm() {
</div> </div>
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="last_name"> <label className="text-13 font-medium text-tertiary" htmlFor="last_name">
Last name <span className="text-danger-primary">*</span> Фамилия <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="last_name" id="last_name"
name="last_name" name="last_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Wright" placeholder="Иванов"
value={formData.last_name} value={formData.last_name}
onChange={(e) => { onChange={(e) => {
const validation = validatePersonName(e.target.value); const validation = validatePersonName(e.target.value);
@ -210,10 +210,10 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="email"> <label className="text-13 font-medium text-tertiary" htmlFor="email">
Email <span className="text-danger-primary">*</span> Электронная почта <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="email" id="email"
name="email" name="email"
type="email" type="email"
@ -231,15 +231,15 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="company_name"> <label className="text-13 font-medium text-tertiary" htmlFor="company_name">
Company name <span className="text-danger-primary">*</span> Название компании <span className="text-danger-primary">*</span>
</label> </label>
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="company_name" id="company_name"
name="company_name" name="company_name"
type="text" type="text"
inputSize="md" inputSize="md"
placeholder="Company name" placeholder="Название компании"
value={formData.company_name} value={formData.company_name}
onChange={(e) => { onChange={(e) => {
const validation = validateCompanyName(e.target.value, false); const validation = validateCompanyName(e.target.value, false);
@ -253,16 +253,16 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="password"> <label className="text-13 font-medium text-tertiary" htmlFor="password">
Set a password <span className="text-danger-primary">*</span> Задайте пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
className="w-full border border-subtle !bg-surface-1 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 placeholder:text-placeholder"
id="password" id="password"
name="password" name="password"
type={showPassword.password ? "text" : "password"} type={showPassword.password ? "text" : "password"}
inputSize="md" inputSize="md"
placeholder="New password" placeholder="Новый пароль"
value={formData.password} value={formData.password}
onChange={(e) => handleFormChange("password", e.target.value)} onChange={(e) => handleFormChange("password", e.target.value)}
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
@ -298,7 +298,7 @@ export function InstanceSetupForm() {
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password"> <label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
Confirm password <span className="text-danger-primary">*</span> Подтвердите пароль <span className="text-danger-primary">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<Input <Input
@ -308,8 +308,8 @@ export function InstanceSetupForm() {
inputSize="md" inputSize="md"
value={formData.confirm_password} value={formData.confirm_password}
onChange={(e) => handleFormChange("confirm_password", e.target.value)} onChange={(e) => handleFormChange("confirm_password", e.target.value)}
placeholder="Confirm password" placeholder="Повторите пароль"
className="w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder" className="nodedc-settings-input w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
onFocus={() => setIsRetryPasswordInputFocused(true)} onFocus={() => setIsRetryPasswordInputFocused(true)}
onBlur={() => setIsRetryPasswordInputFocused(false)} onBlur={() => setIsRetryPasswordInputFocused(false)}
autoComplete="new-password" autoComplete="new-password"
@ -336,9 +336,7 @@ export function InstanceSetupForm() {
</div> </div>
{!!formData.confirm_password && {!!formData.confirm_password &&
formData.password !== formData.confirm_password && formData.password !== formData.confirm_password &&
renderPasswordMatchError && ( renderPasswordMatchError && <span className="text-13 text-danger-primary">Пароли не совпадают</span>}
<span className="text-13 text-danger-primary">Passwords don{"'"}t match</span>
)}
</div> </div>
<div className="relative flex gap-2"> <div className="relative flex gap-2">
@ -352,7 +350,7 @@ export function InstanceSetupForm() {
/> />
</div> </div>
<label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled"> <label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled">
Allow Plane to anonymously collect usage events.{" "} Разрешить NODE.DC анонимно собирать события использования.{" "}
<a <a
tabIndex={-1} tabIndex={-1}
href="https://developers.plane.so/self-hosting/telemetry" href="https://developers.plane.so/self-hosting/telemetry"
@ -360,14 +358,14 @@ export function InstanceSetupForm() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium" className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium"
> >
See More Подробнее
</a> </a>
</label> </label>
</div> </div>
<div className="py-2"> <div className="py-2">
<Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}> <Button type="submit" size="xl" className="w-full" disabled={isButtonDisabled}>
{isSubmitting ? <Spinner height="20px" width="20px" /> : "Continue"} {isSubmitting ? <Spinner height="20px" width="20px" /> : "Продолжить"}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -35,17 +35,16 @@ export const getCoreAuthenticationModesMap: (
}) => ({ }) => ({
"unique-codes": { "unique-codes": {
key: "unique-codes", key: "unique-codes",
name: "Unique codes", name: "Одноразовые коды",
description: description: "Вход и регистрация по кодам из email. Для этого способа нужен настроенный SMTP.",
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />, icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN", enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN",
}, },
"passwords-login": { "passwords-login": {
key: "passwords-login", key: "passwords-login",
name: "Passwords", name: "Пароли",
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", description: "Пользователи создают аккаунты с паролем и входят по email.",
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />, icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_EMAIL_PASSWORD", enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
@ -53,7 +52,7 @@ export const getCoreAuthenticationModesMap: (
google: { google: {
key: "google", key: "google",
name: "Google", name: "Google",
description: "Allow members to log in or sign up for Plane with their Google accounts.", description: "Вход и регистрация через аккаунты Google.",
icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />, icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GOOGLE_ENABLED", enabledConfigKey: "IS_GOOGLE_ENABLED",
@ -61,7 +60,7 @@ export const getCoreAuthenticationModesMap: (
github: { github: {
key: "github", key: "github",
name: "GitHub", name: "GitHub",
description: "Allow members to log in or sign up for Plane with their GitHub accounts.", description: "Вход и регистрация через аккаунты GitHub.",
icon: ( icon: (
<img <img
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage} src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
@ -76,7 +75,7 @@ export const getCoreAuthenticationModesMap: (
gitlab: { gitlab: {
key: "gitlab", key: "gitlab",
name: "GitLab", name: "GitLab",
description: "Allow members to log in or sign up to plane with their GitLab accounts.", description: "Вход и регистрация через аккаунты GitLab.",
icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />, icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITLAB_ENABLED", enabledConfigKey: "IS_GITLAB_ENABLED",
@ -84,7 +83,7 @@ export const getCoreAuthenticationModesMap: (
gitea: { gitea: {
key: "gitea", key: "gitea",
name: "Gitea", name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.", description: "Вход и регистрация через аккаунты Gitea.",
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />, icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITEA_ENABLED", enabledConfigKey: "IS_GITEA_ENABLED",

View File

@ -15,38 +15,38 @@ export type TCoreSidebarMenuKey = "general" | "email" | "workspace" | "authentic
export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = { export const coreSidebarMenuLinks: Record<TCoreSidebarMenuKey, TSidebarMenuItem> = {
general: { general: {
Icon: Cog, Icon: Cog,
name: "General", name: "Основное",
description: "Identify your instances and get key details.", description: "Имя инстанса, ID и телеметрия.",
href: `/general/`, href: `/general/`,
}, },
email: { email: {
Icon: Mail, Icon: Mail,
name: "Email", name: "Почта",
description: "Configure your SMTP controls.", description: "SMTP и тестовая отправка.",
href: `/email/`, href: `/email/`,
}, },
workspace: { workspace: {
Icon: WorkspaceIcon, Icon: WorkspaceIcon,
name: "Workspaces", name: "Воркспейсы",
description: "Manage all workspaces on this instance.", description: "Все рабочие пространства инстанса.",
href: `/workspace/`, href: `/workspace/`,
}, },
authentication: { authentication: {
Icon: LockIcon, Icon: LockIcon,
name: "Authentication", name: "Аутентификация",
description: "Configure authentication modes.", description: "Вход, регистрация и OAuth.",
href: `/authentication/`, href: `/authentication/`,
}, },
ai: { ai: {
Icon: BrainCog, Icon: BrainCog,
name: "Artificial intelligence", name: "ИИ",
description: "Configure your OpenAI creds.", description: "OpenAI модель и ключ API.",
href: `/ai/`, href: `/ai/`,
}, },
image: { image: {
Icon: Image, Icon: Image,
name: "Images in Plane", name: "Изображения",
description: "Allow third-party image libraries.", description: "Внешние библиотеки изображений.",
href: `/image/`, href: `/image/`,
}, },
}; };

View File

@ -24,7 +24,7 @@ const DEFAULT_SWR_CONFIG = {
export function CoreProviders({ children }: { children: React.ReactNode }) { export function CoreProviders({ children }: { children: React.ReactNode }) {
return ( return (
<ThemeProvider themes={["light", "dark"]} defaultTheme="system" enableSystem> <ThemeProvider themes={["light", "dark"]} defaultTheme="dark" enableSystem>
<AppProgressBar /> <AppProgressBar />
<ToastWithTheme /> <ToastWithTheme />
<SWRConfig value={DEFAULT_SWR_CONFIG}> <SWRConfig value={DEFAULT_SWR_CONFIG}>

View File

@ -1,5 +1,27 @@
@import "@plane/tailwind-config/index.css"; @import "@plane/tailwind-config/index.css";
:root {
--nodedc-accent-rgb: 195 255 102;
--nodedc-on-accent-rgb: 11 17 23;
--nodedc-card-passive-rgb: 42 43 46;
--nodedc-on-card-passive-rgb: 245 247 251;
--nodedc-card-active-rgb: 195 255 102;
--nodedc-on-card-active-rgb: 11 17 23;
--brand-default: rgb(var(--nodedc-accent-rgb));
--brand-300: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 65%, white);
--brand-700: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 75%, black);
--bg-accent-primary: rgb(var(--nodedc-accent-rgb));
--bg-accent-primary-hover: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white);
--bg-accent-primary-active: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 90%, black);
--txt-on-color: rgb(var(--nodedc-on-accent-rgb));
--txt-icon-on-color: rgb(var(--nodedc-on-accent-rgb));
}
html,
body {
background: #050506;
}
.shadow-custom { .shadow-custom {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */ box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
} }
@ -40,3 +62,463 @@
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
will-change: transform, opacity; will-change: transform, opacity;
} }
@layer components {
.nodedc-admin-root {
min-height: 100vh;
background:
radial-gradient(circle at 12% -8%, rgba(var(--nodedc-accent-rgb), 0.18), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(var(--nodedc-card-active-rgb), 0.1), transparent 30rem), #050506;
color: var(--text-color-primary);
}
.nodedc-admin-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(5, 5, 7, 0.94);
}
.nodedc-admin-main {
background: transparent !important;
}
.nodedc-auth-shell {
background:
radial-gradient(circle at 20% 0%, rgba(var(--nodedc-accent-rgb), 0.2), transparent 30rem),
radial-gradient(circle at 78% 10%, rgba(var(--nodedc-card-active-rgb), 0.11), transparent 26rem), #050506 !important;
}
.nodedc-auth-card {
border-radius: 1.75rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.78);
padding: 1.5rem;
-webkit-backdrop-filter: blur(34px);
backdrop-filter: blur(34px);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.nodedc-glass-sidebar {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(8, 8, 11, 0.9) !important;
border-right: 1px solid rgba(255, 255, 255, 0.07) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.015),
0 18px 48px rgba(0, 0, 0, 0.26);
}
.nodedc-glass-modal,
.nodedc-glass-surface {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.88) !important;
border: 0 !important;
outline: none !important;
-webkit-backdrop-filter: blur(38px);
backdrop-filter: blur(38px);
box-shadow:
0 22px 58px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.nodedc-glass-popup-surface {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.93) !important;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
-webkit-backdrop-filter: blur(42px);
backdrop-filter: blur(42px);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36);
}
.nodedc-admin-header {
min-height: 4.25rem;
border: 0 !important;
border-radius: 0 0 1.35rem 1.35rem;
margin: 0.65rem 0.75rem 0;
width: calc(100% - 1.5rem) !important;
}
.nodedc-admin-breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
}
.nodedc-admin-breadcrumb-pill {
min-height: 2.5rem;
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.04) !important;
color: rgba(255, 255, 255, 0.72) !important;
padding: 0.55rem 0.9rem !important;
box-shadow: none !important;
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-admin-breadcrumb-pill:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.065) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-breadcrumb-pill[data-current="true"] {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-breadcrumb-pill[data-current="true"] * {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-breadcrumb-separator {
color: rgba(255, 255, 255, 0.32);
}
.nodedc-page {
padding: 1rem 0 1.5rem;
}
.nodedc-page-header {
border: 0 !important;
border-radius: 1.35rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.025);
padding: 1.1rem 1.25rem !important;
}
.nodedc-page-body {
padding-top: 0.15rem;
}
.nodedc-settings-card {
border: 0 !important;
outline: none !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-card:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.046) !important;
}
.nodedc-admin-sidebar-profile {
border: 0 !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.016) 100%),
rgba(255, 255, 255, 0.04) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-admin-sidebar-avatar-button {
border: 0 !important;
border-radius: 0.65rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(0, 0, 0, 0.32) !important;
color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: none !important;
}
.nodedc-admin-sidebar-section-label {
padding: 0.55rem 0.9rem 0.45rem;
color: rgba(255, 255, 255, 0.54);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.nodedc-settings-sidebar-item {
min-height: 2.75rem;
border: 0 !important;
outline: none !important;
border-radius: 1.1rem !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.76) !important;
padding-inline: 0.95rem !important;
box-shadow: none !important;
}
.nodedc-settings-sidebar-item:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important;
}
.nodedc-settings-sidebar-item[data-active="true"] {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.045) !important;
color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: none !important;
}
.nodedc-settings-sidebar-item[data-active="true"] * {
color: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-admin-sidebar-action {
min-height: 2.25rem;
border: 0 !important;
border-radius: 999px !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.045) !important;
color: rgba(255, 255, 255, 0.7) !important;
box-shadow: none !important;
}
.nodedc-admin-sidebar-action:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.07) !important;
color: var(--text-color-primary) !important;
}
.nodedc-settings-input,
.nodedc-admin-root input:not([type="checkbox"]) {
min-height: 3rem;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-input:focus,
.nodedc-settings-input:focus-visible,
.nodedc-admin-root input:not([type="checkbox"]):focus,
.nodedc-admin-root input:not([type="checkbox"]):focus-visible {
outline: none !important;
box-shadow: inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.28) !important;
}
.nodedc-settings-input input {
min-height: auto !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.nodedc-settings-select {
min-height: 3rem !important;
border: 0 !important;
outline: none !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.032) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
}
.nodedc-admin-root [data-slot="button"],
.nodedc-admin-root button,
.nodedc-admin-root a[class*="rounded"] {
outline: none !important;
}
.nodedc-settings-save-button,
.nodedc-admin-root [data-slot="button"].bg-accent-primary,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]),
.nodedc-admin-root a.bg-accent-primary {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow: none !important;
padding-inline: 1.35rem !important;
}
.nodedc-settings-save-button:hover,
.nodedc-admin-root [data-slot="button"].bg-accent-primary:hover,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]):hover,
.nodedc-admin-root a.bg-accent-primary:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-settings-save-button *,
.nodedc-admin-root [data-slot="button"].bg-accent-primary *,
.nodedc-admin-root button.bg-accent-primary:not([role="switch"]) *,
.nodedc-admin-root a.bg-accent-primary * {
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
}
.nodedc-admin-root button[role="switch"] {
min-height: auto !important;
padding: 0 !important;
border-radius: 9999px !important;
border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(255, 255, 255, 0.13) !important;
box-shadow: none !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"] {
border-color: transparent !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
}
.nodedc-admin-root button[role="switch"]:hover {
background: rgba(255, 255, 255, 0.17) !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"]:hover {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 88%, white) !important;
}
.nodedc-admin-root button[role="switch"] > span[aria-hidden="true"] {
background: rgba(255, 255, 255, 0.72) !important;
}
.nodedc-admin-root button[role="switch"][aria-checked="true"] > span[aria-hidden="true"] {
background: rgba(9, 10, 9, 0.94) !important;
}
.nodedc-admin-root input[type="checkbox"] {
min-height: 1rem !important;
width: 1rem !important;
height: 1rem !important;
padding: 0 !important;
border-radius: 0.35rem !important;
border-color: rgba(255, 255, 255, 0.22) !important;
background: rgba(255, 255, 255, 0.06) !important;
box-shadow: none !important;
}
.nodedc-admin-root input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.36) !important;
background: rgba(255, 255, 255, 0.09) !important;
}
.nodedc-admin-root input[type="checkbox"]:checked,
.nodedc-admin-root input[type="checkbox"]:indeterminate {
border-color: transparent !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
}
.nodedc-admin-root input[type="checkbox"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 4px rgba(var(--nodedc-accent-rgb), 0.18) !important;
}
.nodedc-settings-secondary-button {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-color-primary) !important;
box-shadow: none !important;
padding-inline: 1.25rem !important;
}
.nodedc-admin-root [data-slot="button"].border-strong,
.nodedc-admin-root a.border-strong,
.nodedc-admin-root button.border-strong:not([role="switch"]) {
min-height: 2.75rem;
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.022) 100%),
rgba(255, 255, 255, 0.055) !important;
color: rgba(255, 255, 255, 0.76) !important;
box-shadow: none !important;
padding-inline: 1.25rem !important;
}
.nodedc-settings-secondary-button:hover,
.nodedc-admin-root [data-slot="button"].border-strong:hover,
.nodedc-admin-root a.border-strong:hover,
.nodedc-admin-root button.border-strong:not([role="switch"]):hover {
background: rgba(255, 255, 255, 0.1) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-root [data-slot="button"]:disabled,
.nodedc-admin-root button:disabled:not([role="switch"]),
.nodedc-admin-root [aria-disabled="true"] {
border: 0 !important;
background: rgba(255, 255, 255, 0.035) !important;
color: rgba(255, 255, 255, 0.34) !important;
opacity: 1 !important;
box-shadow: none !important;
}
.nodedc-settings-note {
border: 0 !important;
border-radius: 1.25rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.045) !important;
color: rgba(255, 255, 255, 0.72) !important;
box-shadow: none !important;
}
.nodedc-settings-note a {
color: rgb(var(--nodedc-card-active-rgb)) !important;
text-decoration-color: rgba(var(--nodedc-card-active-rgb), 0.48) !important;
}
.nodedc-settings-helper-card {
border: 0 !important;
border-radius: 1.35rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.045) !important;
box-shadow: none !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-settings-helper-card-header {
border: 0 !important;
background: rgba(255, 255, 255, 0.035) !important;
color: rgba(255, 255, 255, 0.72) !important;
}
.nodedc-admin-root .bg-layer-1,
.nodedc-admin-root .bg-layer-2,
.nodedc-admin-root .bg-layer-3 {
background-color: rgba(255, 255, 255, 0.04) !important;
}
.nodedc-admin-root .border-subtle,
.nodedc-admin-root .border-subtle-1 {
border-color: rgba(255, 255, 255, 0.07) !important;
}
.nodedc-admin-root :focus-visible {
outline: none !important;
}
.nodedc-code-chip {
border: 0 !important;
border-radius: 0.65rem !important;
background: rgba(255, 255, 255, 0.08) !important;
color: rgb(var(--nodedc-card-active-rgb)) !important;
}
}

View File

@ -4,11 +4,10 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import React, { Fragment, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { Placement } from "@popperjs/core"; import type { Placement } from "@popperjs/core";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// headless ui
import { Popover, Portal, Transition } from "@headlessui/react";
// ui // ui
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
@ -45,93 +44,132 @@ export function FiltersDropdown(props: Props) {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | HTMLDivElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | HTMLDivElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto", placement: placement ?? "auto",
}); });
return ( const toggleDropdown = useCallback(() => {
<Popover as="div"> if (!disabled) setIsOpen((current) => !current);
{({ open, close }) => ( }, [disabled]);
<>
<Popover.Button as={React.Fragment}> const closeDropdown = useCallback(() => setIsOpen(false), []);
{menuButton ? (
<button const handleTriggerKeyDown = useCallback(
type="button" (event: React.KeyboardEvent<HTMLDivElement>) => {
ref={setReferenceElement} if (disabled) return;
className={menuButtonWrapperClassName} if (event.key === "Enter" || event.key === " ") {
disabled={disabled} event.preventDefault();
> toggleDropdown();
{typeof menuButton === "function" ? menuButton({ open }) : menuButton} }
</button> if (event.key === "Escape") closeDropdown();
) : ( },
<div ref={setReferenceElement}> [closeDropdown, disabled, toggleDropdown]
<div className="hidden @4xl:flex"> );
<Button
disabled={disabled} useEffect(() => {
variant="secondary" if (!isOpen) return;
prependIcon={icon}
tabIndex={tabIndex} const handleDocumentMouseDown = (event: MouseEvent) => {
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide relative" const target = event.target;
data-active={open} if (!(target instanceof Node)) return;
size="lg"
> if (referenceElement?.contains(target) || popperElement?.contains(target)) return;
<> closeDropdown();
<div className={`${open ? "text-[rgb(var(--nodedc-accent-rgb))]" : "text-secondary"}`}> };
<span>{title}</span>
</div> const handleDocumentKeyDown = (event: KeyboardEvent) => {
{isFiltersApplied && ( if (event.key === "Escape") closeDropdown();
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-accent-primary" /> };
)}
</> document.addEventListener("mousedown", handleDocumentMouseDown);
</Button> document.addEventListener("keydown", handleDocumentKeyDown);
</div>
<div className="flex @4xl:hidden"> return () => {
<Button document.removeEventListener("mousedown", handleDocumentMouseDown);
disabled={disabled} document.removeEventListener("keydown", handleDocumentKeyDown);
ref={setReferenceElement} };
variant="secondary" }, [closeDropdown, isOpen, popperElement, referenceElement]);
tabIndex={tabIndex}
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide" const dropdownPanel =
size="lg" isOpen &&
> createPortal(
{miniIcon || title} <div className="fixed z-[760] translate-y-0">
</Button> <div
</div> className={`nodedc-dropdown-surface my-1 overflow-hidden ${dropdownClassName ?? ""}`}
</div> ref={setPopperElement}
)} style={styles.popper}
</Popover.Button> {...attributes.popper}
<Transition >
as={Fragment} <div
enter="transition ease-out duration-200" className={`flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem] ${
enterFrom="opacity-0 translate-y-1" dropdownContentClassName ?? ""
enterTo="opacity-100 translate-y-0" }`}
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
> >
<Portal> {typeof children === "function" ? children({ closeDropdown }) : children}
{/** translate-y-0 is a hack to create new stacking context. Required for safari */} </div>
<Popover.Panel className="fixed z-[760] translate-y-0"> </div>
<div </div>,
className={`nodedc-dropdown-surface my-1 overflow-hidden ${dropdownClassName ?? ""}`} document.body
ref={setPopperElement} );
style={styles.popper}
{...attributes.popper} return (
> <>
<div {menuButton ? (
className={`flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem] ${ <button
dropdownContentClassName ?? "" type="button"
}`} ref={setReferenceElement}
> className={menuButtonWrapperClassName}
{typeof children === "function" ? children({ closeDropdown: close }) : children} disabled={disabled}
</div> tabIndex={tabIndex}
onClick={toggleDropdown}
>
{typeof menuButton === "function" ? menuButton({ open: isOpen }) : menuButton}
</button>
) : (
<div
ref={setReferenceElement}
role="button"
tabIndex={disabled ? -1 : tabIndex}
aria-disabled={disabled}
onClick={toggleDropdown}
onKeyDown={handleTriggerKeyDown}
>
<div className="hidden @4xl:flex">
<Button
disabled={disabled}
variant="secondary"
prependIcon={icon}
tabIndex={-1}
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide relative"
data-active={isOpen}
size="lg"
>
<>
<div className={`${isOpen ? "text-[rgb(var(--nodedc-accent-rgb))]" : "text-secondary"}`}>
<span>{title}</span>
</div> </div>
</Popover.Panel> {isFiltersApplied && (
</Portal> <span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-accent-primary" />
</Transition> )}
</> </>
</Button>
</div>
<div className="flex @4xl:hidden">
<Button
disabled={disabled}
variant="secondary"
tabIndex={-1}
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide"
size="lg"
>
{miniIcon || title}
</Button>
</div>
</div>
)} )}
</Popover> {dropdownPanel}
</>
); );
} }