diff --git a/public/locales/en/authentication/invite.json b/public/locales/en/authentication/invite.json index ce44ab605..2554a5472 100644 --- a/public/locales/en/authentication/invite.json +++ b/public/locales/en/authentication/invite.json @@ -1,4 +1,5 @@ { + "metaTitle": "Create Account", "title": "Create Account", "text": "Please define your credentials below", "form": { @@ -16,5 +17,19 @@ "buttons": { "submit": "Create account" } + }, + "notifications": { + "loading": { + "title": "Creating account", + "text": "Please wait" + }, + "success": { + "title": "Account created", + "text": "Your account has been created successfully" + }, + "error": { + "title": "Error", + "text": "Something went wrong" + } } } \ No newline at end of file diff --git a/public/locales/en/authentication/login.json b/public/locales/en/authentication/login.json index 116b3fa19..434127630 100644 --- a/public/locales/en/authentication/login.json +++ b/public/locales/en/authentication/login.json @@ -1,4 +1,5 @@ { + "metaTitle": "Login", "title": "Welcome back!", "text": "Please enter your credentials", "form": { diff --git a/public/locales/en/boards/manage.json b/public/locales/en/boards/manage.json deleted file mode 100644 index a60d7529f..000000000 --- a/public/locales/en/boards/manage.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "title": "Boards", - "cards": { - "statistics": { - "apps": "Apps", - "widgets": "Widgets", - "categories": "Categories" - }, - "buttons": { - "view": "View board" - }, - "menu": { - "setAsDefault": "Set as your default board", - "delete": { - "label": "Delete permanently", - "disabled": "Deletion disabled, because older Homarr components still rely on this.", - "modalTitle": "Delete board" - } - }, - "badges": { - "fileSystem": "File system", - "default": "Default" - } - }, - "buttons": { - "create": "Create new dashboard" - } -} \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e716bef39..4b5059ae0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -35,9 +35,5 @@ "small": "small", "medium": "medium", "large": "large" - }, - "header": { - "logout": "Logout", - "sign-in": "Sign in" } } \ No newline at end of file diff --git a/public/locales/en/layout/header.json b/public/locales/en/layout/header.json index a57e0824b..5eb309964 100644 --- a/public/locales/en/layout/header.json +++ b/public/locales/en/layout/header.json @@ -16,6 +16,7 @@ "switchTheme": "Switch theme", "preferences": "User preferences", "defaultBoard": "Default dashboard", + "manage": "Manage", "about": { "label": "About", "new": "New" @@ -23,5 +24,11 @@ "logout": "Logout from {{username}}", "login": "Login" } + }, + "modals": { + "movie": { + "title": "", + "topResults": "Top {{count}} results for {{search}}." + } } } \ No newline at end of file diff --git a/public/locales/en/layout/manage.json b/public/locales/en/layout/manage.json new file mode 100644 index 000000000..bcb6b8928 --- /dev/null +++ b/public/locales/en/layout/manage.json @@ -0,0 +1,26 @@ +{ + "navigation": { + "home": { + "title": "Home" + }, + "boards": { + "title": "Boards" + }, + "users": { + "title": "Users", + "items": { + "manage": "Manage", + "invites": "Invites" + } + }, + "help": { + "title": "Help", + "items": { + "documentation": "Documentation", + "report": "Report an issue / bug", + "discord": "Community Discord", + "contribute": "Contribute" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/manage/boards.json b/public/locales/en/manage/boards.json new file mode 100644 index 000000000..9982f75ef --- /dev/null +++ b/public/locales/en/manage/boards.json @@ -0,0 +1,44 @@ +{ + "metaTitle": "Boards", + "pageTitle": "Boards", + "cards": { + "statistics": { + "apps": "Apps", + "widgets": "Widgets", + "categories": "Categories" + }, + "buttons": { + "view": "View board" + }, + "menu": { + "setAsDefault": "Set as your default board", + "delete": { + "label": "Delete permanently", + "disabled": "Deletion disabled, because older Homarr components still rely on this." + } + }, + "badges": { + "fileSystem": "File system", + "default": "Default" + } + }, + "buttons": { + "create": "Create new board" + }, + "modals": { + "delete": { + "title": "Delete board", + "text": "Are you sure, that you want to delete this board? This action cannot be undone and your data will be lost permanently." + }, + "create": { + "title": "Create board", + "text": "The name cannot be changed after a board has been created.", + "form": { + "name": { + "label": "Name" + }, + "submit": "Create" + } + } + } +} \ No newline at end of file diff --git a/public/locales/en/manage/index.json b/public/locales/en/manage/index.json new file mode 100644 index 000000000..8fc17f7b3 --- /dev/null +++ b/public/locales/en/manage/index.json @@ -0,0 +1,23 @@ +{ + "metaTitle": "Manage", + "hero": { + "title": "Welcome back, {{username}}", + "fallbackUsername": "Anonymous", + "subtitle": "Welcome to Your Application Hub. Organize, Optimize and Conquer!" + }, + "quickActions": { + "title": "Quick actions", + "boards": { + "title": "Your boards", + "subtitle": "Create and manage your boards" + }, + "inviteUsers": { + "title": "Invite a new user", + "subtitle": "Create and send an invitation for registration" + }, + "manageUsers": { + "title": "Manage users", + "subtitle": "Delete and manage your users" + } + } +} \ No newline at end of file diff --git a/public/locales/en/user/manage.json b/public/locales/en/manage/users.json similarity index 61% rename from public/locales/en/user/manage.json rename to public/locales/en/manage/users.json index 3d1d4e92c..2e1959709 100644 --- a/public/locales/en/user/manage.json +++ b/public/locales/en/manage/users.json @@ -10,7 +10,10 @@ } }, "modals": { - "delete": "Delete user {{name}}" + "delete": { + "title": "Delete user {{name}}", + "text": "Are you sure, that you want to delete the user {{name}}? This will delete data associated with this account, but not any created dashboards by this user." + } }, "searchDoesntMatch": "Your search does not match any entries. Please adjust your filter." } \ No newline at end of file diff --git a/public/locales/en/user/create.json b/public/locales/en/manage/users/create.json similarity index 67% rename from public/locales/en/user/create.json rename to public/locales/en/manage/users/create.json index 26ab9546e..2124c9fa8 100644 --- a/public/locales/en/user/create.json +++ b/public/locales/en/manage/users/create.json @@ -1,4 +1,5 @@ { + "metaTitle": "Create user", "steps": { "account": { "title": "First step", @@ -15,7 +16,13 @@ "text": "Password", "password": { "label": "Password", - "requirement": "Includes at least 6 characters" + "requirements": { + "number": "Includes number", + "lowercase": "Includes lowercase letter", + "uppercase": "Includes uppercase letter", + "special": "Includes special character", + "length": "Includes at least {{count}} characters" + } } }, "finish": { @@ -35,15 +42,20 @@ }, "notSet": "Not set", "valid": "Valid" - }, - "alertConfirmed": "User has been created in the database. They can now log in." + } + }, + "completed": { + "alert": { + "title": "User was created", + "text": "The user was created in the database. They can now log in." + } } }, "buttons": { "next": "Next", "previous": "Previous", "confirm": "Confirm", - "generateRandomPw": "Generate random", + "generateRandomPassword": "Generate random", "createAnother": "Create another", "goBack": "Go back to users" } diff --git a/public/locales/en/manage/users/invites.json b/public/locales/en/manage/users/invites.json new file mode 100644 index 000000000..7f7115581 --- /dev/null +++ b/public/locales/en/manage/users/invites.json @@ -0,0 +1,50 @@ +{ + "metaTitle": "User invites", + "pageTitle": "Manage user invites", + "description": "Using invites, you can invite users to your Homarr instance. An invitation will only be valid for a certain time-span and can be used once. The expiration must be between 5 minutes and 12 months upon creation.", + "button": { + "createInvite": "Create invitation", + "deleteInvite": "Delete invite" + }, + "table": { + "header": { + "id": "ID", + "creator": "Creator", + "expires": "Expires", + "action": "Actions" + }, + "data": { + "expiresAt": "expired {{at}}", + "expiresIn": "in {{in}}" + } + }, + "modals": { + "create": { + "title": "Create invite", + "description": "After the expiration, an invite will no longer be valid and the recipient of the invite won't be able to create an account.", + "form": { + "expires": { + "label": "Expiration date" + }, + "submit": "Create" + } + }, + "copy": { + "title": "Copy invitation", + "description": "Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.", + "invitationLink": "Invitation link", + "details": { + "id": "ID", + "token": "Token" + }, + "button": { + "close": "Copy & Dismiss" + } + }, + "delete": { + "title": "Delete invite", + "description": "Are you sure, that you want to delete this invitation? Users with this link will no longer be able to create an account using that link." + } + }, + "noInvites": "There are no invitations yet." +} \ No newline at end of file diff --git a/public/locales/en/user/invites.json b/public/locales/en/user/invites.json deleted file mode 100644 index 9b1183c3d..000000000 --- a/public/locales/en/user/invites.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "title": "Manage user invites", - "text": "Using invites, you can invite users to your Homarr instance. An invitation will only be valid for a certain time-span and can be used once. The expiration must be between 5 minutes and 12 months upon creation.", - "button": { - "createInvite": "Create invitation", - "deleteInvite": "Delete invite" - }, - "table": { - "header": { - "id": "ID", - "creator": "Creator", - "expires": "Expires", - "action": "Actions" - }, - "data": { - "expiresAt": "expired {{at}}", - "expiresIn": "in {{in}}" - } - }, - "noInvites": "There are no invitations yet." -} \ No newline at end of file diff --git a/public/locales/en/widgets/location.json b/public/locales/en/widgets/location.json index d2afe05cd..a1bad6221 100644 --- a/public/locales/en/widgets/location.json +++ b/public/locales/en/widgets/location.json @@ -27,6 +27,10 @@ }, "population": { "fallback": "Unknown" + }, + "nothingFound": { + "title": "Nothing found", + "description": "Please try another search term" } } } diff --git a/public/locales/en/zod.json b/public/locales/en/zod.json index 41acbcf94..a87db9b85 100644 --- a/public/locales/en/zod.json +++ b/public/locales/en/zod.json @@ -12,11 +12,11 @@ "number": "This field must be greater than or equal to {{minimum}}" }, "too_big": { - "string": "This field must be at most {{minimum}} characters long", - "number": "This field must be less than or equal to {{minimum}}" + "string": "This field must be at most {{maximum}} characters long", + "number": "This field must be less than or equal to {{maximum}}" }, "custom": { - "password_match": "Passwords must match" + "passwordMatch": "Passwords must match" } } } \ No newline at end of file diff --git a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx index 257e4e248..43e89695f 100644 --- a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx +++ b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx @@ -180,25 +180,3 @@ const useStyles = createStyles(({ colors, colorScheme, radius }) => ({ }, }, })); - -/* - - - - {t('settings/customization/color-selector:colors')} - - - - - - - - - - - - - - - -*/ diff --git a/src/components/Board/Customize/form.ts b/src/components/Board/Customize/form.ts index 5bd77a6e8..eee8c6976 100644 --- a/src/components/Board/Customize/form.ts +++ b/src/components/Board/Customize/form.ts @@ -1,7 +1,7 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { createFormContext } from '@mantine/form'; import { z } from 'zod'; -import { boardCustomizationSchema } from '~/validations/dashboards'; +import { boardCustomizationSchema } from '~/validations/boards'; export const [ BoardCustomizationFormProvider, diff --git a/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx index c65adc110..d1e57a470 100644 --- a/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx +++ b/src/components/Dashboard/Tiles/Widgets/Inputs/LocationSelection.tsx @@ -168,8 +168,8 @@ const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySele
- Nothing found - Nothing was found, please try again + {t('modal.table.nothingFound.title')} + {t('modal.table.nothingFound.description')}
diff --git a/src/modals/create-dashboard/create-dashboard.modal.tsx b/src/components/Manage/Board/create-board.modal.tsx similarity index 56% rename from src/modals/create-dashboard/create-dashboard.modal.tsx rename to src/components/Manage/Board/create-board.modal.tsx index 32a0b0d23..56828266c 100644 --- a/src/modals/create-dashboard/create-dashboard.modal.tsx +++ b/src/components/Manage/Board/create-board.modal.tsx @@ -1,16 +1,18 @@ -import { Button, Group, Stack, Text, TextInput } from '@mantine/core'; +import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core'; import { useForm } from '@mantine/form'; import { ContextModalProps, modals } from '@mantine/modals'; +import { Trans, useTranslation } from 'next-i18next'; import { getStaticFallbackConfig } from '~/tools/config/getFallbackConfig'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; -import { createDashboardSchemaValidation } from '~/validations/dashboards'; +import { createBoardSchemaValidation } from '~/validations/boards'; -export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalProps<{}>) => { - const apiContext = api.useContext(); +export const CreateBoardModal = ({ id }: ContextModalProps<{}>) => { + const { t } = useTranslation('manage/boards'); + const utils = api.useContext(); const { isLoading, mutate } = api.config.save.useMutation({ onSuccess: async () => { - await apiContext.config.all.invalidate(); + await utils.config.all.invalidate(); modals.close(id); }, }); @@ -21,7 +23,7 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr initialValues: { name: '', }, - validate: i18nZodResolver(createDashboardSchemaValidation), + validate: i18nZodResolver(createBoardSchemaValidation), }); const handleSubmit = () => { @@ -35,9 +37,13 @@ export const CreateDashboardModal = ({ context, id, innerProps }: ContextModalPr return (
- A name cannot be changed after a dashboard has been created. + {t('modals.create.text')} - +
); }; + +export const openCreateBoardModal = () => { + modals.openContextModal({ + modal: 'createBoardModal', + title: ( + + <Trans i18nKey="manage/boards:modals.create.title" /> + + ), + innerProps: {}, + }); +}; diff --git a/src/modals/delete-board/delete-board.modal.tsx b/src/components/Manage/Board/delete-board.modal.tsx similarity index 50% rename from src/modals/delete-board/delete-board.modal.tsx rename to src/components/Manage/Board/delete-board.modal.tsx index 785bf4890..3124fa889 100644 --- a/src/modals/delete-board/delete-board.modal.tsx +++ b/src/components/Manage/Board/delete-board.modal.tsx @@ -1,26 +1,23 @@ -import { Button, Group, Stack, Text } from '@mantine/core'; +import { Button, Group, Stack, Text, Title } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; +import { Trans, useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; -export const DeleteBoardModal = ({ - context, - id, - innerProps, -}: ContextModalProps<{ boardName: string; onConfirm: () => Promise }>) => { - const apiContext = api.useContext(); +type InnerProps = { boardName: string; onConfirm: () => Promise }; + +export const DeleteBoardModal = ({ id, innerProps }: ContextModalProps) => { + const { t } = useTranslation('manage/boards'); + const utils = api.useContext(); const { isLoading, mutateAsync } = api.config.delete.useMutation({ onSuccess: async () => { - await apiContext.config.all.invalidate(); + await utils.config.all.invalidate(); modals.close(id); }, }); return ( - - Are you sure, that you want to delete this board? This action cannot be undone and your data - will be lost permanently. - + {t('modals.delete.text')} ); }; + +export const openDeleteBoardModal = (innerProps: InnerProps) => { + modals.openContextModal({ + modal: 'deleteBoardModal', + title: ( + + <Trans i18nKey="manage/boards:modals.delete.title" /> + + ), + innerProps, + }); +}; diff --git a/src/components/Manage/User/Create/create-account-step.tsx b/src/components/Manage/User/Create/create-account-step.tsx index fdd7ab1cb..de4554856 100644 --- a/src/components/Manage/User/Create/create-account-step.tsx +++ b/src/components/Manage/User/Create/create-account-step.tsx @@ -1,8 +1,9 @@ import { Button, Card, Flex, TextInput } from '@mantine/core'; -import { useForm, zodResolver } from '@mantine/form'; +import { useForm } from '@mantine/form'; import { IconArrowRight, IconAt, IconUser } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import { z } from 'zod'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; interface CreateAccountStepProps { nextStep: ({ eMail, username }: { username: string; eMail: string }) => void; @@ -10,7 +11,14 @@ interface CreateAccountStepProps { defaultEmail: string; } -export const CreateAccountStep = ({ defaultEmail, defaultUsername, nextStep }: CreateAccountStepProps) => { +export const CreateAccountStep = ({ + defaultEmail, + defaultUsername, + nextStep, +}: CreateAccountStepProps) => { + const { t } = useTranslation('manage/users/create'); + + const { i18nZodResolver } = useI18nZodResolver(); const form = useForm({ initialValues: { username: defaultUsername, @@ -18,11 +26,9 @@ export const CreateAccountStep = ({ defaultEmail, defaultUsername, nextStep }: C }, validateInputOnBlur: true, validateInputOnChange: true, - validate: zodResolver(createAccountStepValidationSchema), + validate: i18nZodResolver(createAccountStepValidationSchema), }); - const { t } = useTranslation('user/create'); - return ( void; + nextStep: () => void; +}; + +export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepProps) => { + const { t } = useTranslation('manage/users/create'); + + const utils = api.useContext(); + const { mutateAsync: createAsync, isLoading } = api.user.create.useMutation({ + onSettled: () => { + void utils.user.all.invalidate(); + }, + onSuccess: () => { + nextStep(); + }, + }); + + return ( + + {t('steps.finish.card.title')} + {t('steps.finish.card.text')} + + + + + + + + + + + + + + + + + + + + + + +
{t('steps.finish.table.header.property')}{t('steps.finish.table.header.value')}
+ + + {t('steps.finish.table.header.username')} + + {values.account.username}
+ + + {t('steps.finish.table.header.email')} + + + {values.account.eMail ? ( + {values.account.eMail} + ) : ( + + + {t('steps.finish.table.notSet')} + + )} +
+ + + {t('steps.finish.table.password')} + + + + + {t('steps.finish.table.valid')} + +
+ + + + + +
+ ); +}; diff --git a/src/components/Manage/User/Create/security-step.tsx b/src/components/Manage/User/Create/security-step.tsx index c3a1359df..cebae996c 100644 --- a/src/components/Manage/User/Create/security-step.tsx +++ b/src/components/Manage/User/Create/security-step.tsx @@ -9,7 +9,7 @@ import { Progress, Text, } from '@mantine/core'; -import { useForm, zodResolver } from '@mantine/form'; +import { useForm } from '@mantine/form'; import { IconArrowLeft, IconArrowRight, @@ -18,21 +18,22 @@ import { IconKey, IconX, } from '@tabler/icons-react'; -import { useState } from 'react'; import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; import { z } from 'zod'; import { api } from '~/utils/api'; -import { passwordSchema } from '~/validations/user'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; +import { minPasswordLength, passwordSchema } from '~/validations/user'; const requirements = [ - { re: /[0-9]/, label: 'Includes number' }, - { re: /[a-z]/, label: 'Includes lowercase letter' }, - { re: /[A-Z]/, label: 'Includes uppercase letter' }, - { re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' }, + { re: /[0-9]/, label: 'number' }, + { re: /[a-z]/, label: 'lowercase' }, + { re: /[A-Z]/, label: 'uppercase' }, + { re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' }, ]; function getStrength(password: string) { - let multiplier = password.length > 5 ? 0 : 1; + let multiplier = password.length >= minPasswordLength ? 0 : 1; requirements.forEach((requirement) => { if (!requirement.re.test(password)) { @@ -54,13 +55,16 @@ export const CreateAccountSecurityStep = ({ nextStep, prevStep, }: CreateAccountSecurityStepProps) => { + const { t } = useTranslation('manage/users/create'); + + const { i18nZodResolver } = useI18nZodResolver(); const form = useForm({ initialValues: { password: defaultPassword, }, validateInputOnBlur: true, validateInputOnChange: true, - validate: zodResolver(createAccountSecurityStepValidationSchema), + validate: i18nZodResolver(createAccountSecurityStepValidationSchema), }); const { mutateAsync, isLoading } = api.password.generate.useMutation(); @@ -74,8 +78,6 @@ export const CreateAccountSecurityStep = ({ /> )); - const { t } = useTranslation('user/create'); - const strength = getStrength(form.values.password); const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red'; @@ -114,7 +116,7 @@ export const CreateAccountSecurityStep = ({ variant="default" mt="xl" > - {t('buttons.generateRandomPw')} + {t('buttons.generateRandomPassword')} @@ -122,8 +124,8 @@ export const CreateAccountSecurityStep = ({ 5} + label="length" + meets={form.values.password.length >= minPasswordLength} /> {checks} @@ -152,6 +154,8 @@ export const CreateAccountSecurityStep = ({ }; const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => { + const { t } = useTranslation('manage/users/create'); + return ( - {meets ? : } {label} + {meets ? : }{' '} + + {t(`steps.security.password.requirements.${label}`, { + count: minPasswordLength, + })} + ); }; diff --git a/src/modals/copy-invite/copy-invite.modal.tsx b/src/components/Manage/User/Invite/copy-invite.modal.tsx similarity index 50% rename from src/modals/copy-invite/copy-invite.modal.tsx rename to src/components/Manage/User/Invite/copy-invite.modal.tsx index fc43115a8..44551e6b1 100644 --- a/src/modals/copy-invite/copy-invite.modal.tsx +++ b/src/components/Manage/User/Invite/copy-invite.modal.tsx @@ -1,31 +1,38 @@ -import { Button, CopyButton, Mark, Stack, Text } from '@mantine/core'; +import { Button, CopyButton, Mark, Stack, Text, Title } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; +import { Trans, useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import { RouterOutputs } from '~/utils/api'; -export const CopyInviteModal = ({ - id, - innerProps, -}: ContextModalProps<{ id: string; token: string; expire: Date }>) => { +type InnerProps = RouterOutputs['invites']['create']; + +export const CopyInviteModal = ({ id, innerProps }: ContextModalProps) => { + const { t } = useTranslation('manage/users/invites'); const inviteUrl = useInviteUrl(innerProps.id, innerProps.token); return ( - Your invitation has been generated. After this modal closes,{' '} - you'll not be able to copy this link anymore. If you do no longer wish to invite said - person, you can delete this invitation any time. + , + }} + /> - Invitation link + + {t('modals.copy.invitationLink')} + - ID: + {t('modals.copy.details.id')}: {innerProps.id} - Token: + {t('modals.copy.details.token')}: {innerProps.token} @@ -41,7 +48,7 @@ export const CopyInviteModal = ({ variant="default" fullWidth > - Copy & Dismiss + {t('modals.copy.button.close')} )} @@ -54,3 +61,15 @@ const useInviteUrl = (id: string, token: string) => { return `${window.location.href.replace(router.pathname, `/auth/invite/${id}?token=${token}`)}`; }; + +export const openCopyInviteModal = (data: InnerProps) => { + modals.openContextModal({ + modal: 'copyInviteModal', + title: ( + + <Trans i18nKey="manage/users/invites:modals.copy.title" /> + + ), + innerProps: data, + }); +}; diff --git a/src/modals/create-invite/create-invite.modal.tsx b/src/components/Manage/User/Invite/create-invite.modal.tsx similarity index 64% rename from src/modals/create-invite/create-invite.modal.tsx rename to src/components/Manage/User/Invite/create-invite.modal.tsx index 85f2ccb94..6edc1dcbc 100644 --- a/src/modals/create-invite/create-invite.modal.tsx +++ b/src/components/Manage/User/Invite/create-invite.modal.tsx @@ -1,24 +1,24 @@ -import { Button, Group, Stack, Text } from '@mantine/core'; -import { DateInput, DateTimePicker } from '@mantine/dates'; -import { useForm, zodResolver } from '@mantine/form'; +import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useForm } from '@mantine/form'; import { ContextModalProps, modals } from '@mantine/modals'; import dayjs from 'dayjs'; +import { Trans, useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { createInviteSchema } from '~/validations/invite'; +import { openCopyInviteModal } from './copy-invite.modal'; + export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { - const apiContext = api.useContext(); + const { t } = useTranslation('manage/users/invites'); + const utils = api.useContext(); const { isLoading, mutateAsync } = api.invites.create.useMutation({ onSuccess: async (data) => { - await apiContext.invites.all.invalidate(); + await utils.invites.all.invalidate(); modals.close(id); - modals.openContextModal({ - modal: 'copyInviteModal', - title: Copy invitation, - innerProps: data, - }); + openCopyInviteModal(data); }, }); @@ -36,10 +36,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { return ( - - After the expiration, an invite will no longer be valid and the recipient of the invite - won't be able to create an account. - + {t('modals.create.description')} ) => { maxDate={maxDate} withAsterisk valueFormat="DD MMM YYYY hh:mm A" - label="Expiration date" + label={t('modals.create.form.expires.label')} variant="filled" {...form.getInputProps('expirationDate')} /> @@ -60,7 +57,7 @@ export const CreateInviteModal = ({ id }: ContextModalProps<{}>) => { variant="light" color="gray" > - Cancel + {t('common:cancel')} ); }; + +export const openCreateInviteModal = () => { + modals.openContextModal({ + modal: 'createInviteModal', + title: ( + + <Trans i18nKey="manage/users/invites:modals.create.title" /> + + ), + innerProps: {}, + }); +}; diff --git a/src/modals/delete-invite/delete-invite.modal.tsx b/src/components/Manage/User/Invite/delete-invite.modal.tsx similarity index 57% rename from src/modals/delete-invite/delete-invite.modal.tsx rename to src/components/Manage/User/Invite/delete-invite.modal.tsx index 3d28fda23..9b4cf3826 100644 --- a/src/modals/delete-invite/delete-invite.modal.tsx +++ b/src/components/Manage/User/Invite/delete-invite.modal.tsx @@ -1,25 +1,20 @@ import { Button, Group, Stack, Text } from '@mantine/core'; import { ContextModalProps, modals } from '@mantine/modals'; +import { useTranslation } from 'next-i18next'; import { api } from '~/utils/api'; -export const DeleteInviteModal = ({ - context, - id, - innerProps, -}: ContextModalProps<{ tokenId: string }>) => { - const apiContext = api.useContext(); - const { isLoading, mutateAsync } = api.invites.delete.useMutation({ +export const DeleteInviteModal = ({ id, innerProps }: ContextModalProps<{ tokenId: string }>) => { + const { t } = useTranslation('manage/users/invites'); + const utils = api.useContext(); + const { isLoading, mutateAsync: deleteAsync } = api.invites.delete.useMutation({ onSuccess: async () => { - await apiContext.invites.all.invalidate(); + await utils.invites.all.invalidate(); modals.close(id); }, }); return ( - - Are you sure, that you want to delete this invitation? Users with this link will no longer - be able to create an account using that link. - + {t('modals.delete.description')} diff --git a/src/components/Manage/User/delete-user.modal.tsx b/src/components/Manage/User/delete-user.modal.tsx new file mode 100644 index 000000000..adbeae1ce --- /dev/null +++ b/src/components/Manage/User/delete-user.modal.tsx @@ -0,0 +1,56 @@ +import { Button, Group, Stack, Text, Title } from '@mantine/core'; +import { ContextModalProps, modals } from '@mantine/modals'; +import { Trans, useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; + +type InnerProps = { id: string; name: string }; + +export const DeleteUserModal = ({ id, innerProps }: ContextModalProps) => { + const { t } = useTranslation('manage/users'); + const utils = api.useContext(); + const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({ + onSuccess: async () => { + await utils.user.all.invalidate(); + modals.close(id); + }, + }); + return ( + + {t('modals.delete.text', innerProps)} + + + + + + + ); +}; + +export const openDeleteUserModal = (user: InnerProps) => { + modals.openContextModal({ + modal: 'deleteUserModal', + title: ( + + <Trans i18nKey="manage/users:modals.delete.title" values={{ name: user.name }} /> + + ), + innerProps: user, + }); +}; diff --git a/src/components/layout/Templates/ManageLayout.tsx b/src/components/layout/Templates/ManageLayout.tsx index 18a7dc86b..490846456 100644 --- a/src/components/layout/Templates/ManageLayout.tsx +++ b/src/components/layout/Templates/ManageLayout.tsx @@ -22,17 +22,19 @@ import { IconLayoutDashboard, IconMailForward, IconQuestionMark, - IconSettings2, IconUser, IconUsers, + TablerIconsProps, } from '@tabler/icons-react'; import { useSession } from 'next-auth/react'; import Image from 'next/image'; import Link from 'next/link'; -import { ReactNode } from 'react'; +import { ReactNode, RefObject, forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; +import { type navigation } from '../../../../public/locales/en/layout/manage.json'; import { MainHeader } from '../header/Header'; interface ManageLayoutProps { @@ -40,7 +42,8 @@ interface ManageLayoutProps { } export const ManageLayout = ({ children }: ManageLayoutProps) => { - const { attributes } = usePackageAttributesStore(); + const { t } = useTranslation('layout/manage'); + const packageVersion = usePackageAttributesStore((x) => x.attributes.packageVersion); const theme = useMantineTheme(); const screenLargerThanMd = useScreenLargerThan('md'); @@ -51,100 +54,19 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => { const data = useSession(); const isAdmin = data.data?.user.isAdmin ?? false; - const navigationLinks = ( - <> - - - - } - label="Home" - component={Link} - href="/manage/" - /> - - - - } - component={Link} - href="/manage/boards" - /> + const navigationLinkComponents = Object.entries(navigationLinks).map(([name, navigationLink]) => { + if (navigationLink.onlyAdmin && !isAdmin) { + return null; + } - {isAdmin && ( - <> - - - - } - > - } - label="Manage" - component={Link} - href="/manage/users" - /> - } - label="Invites" - component={Link} - href="/manage/users/invites" - /> - - - - - } - component={Link} - href="/manage/settings" - /> - - )} - - - - - } - > - } - component="a" - href="https://homarr.dev/docs/about" - label="Documentation" - /> - } - component="a" - href="https://github.com/ajnart/homarr/issues/new/choose" - label="Report an issue / bug" - /> - } - component="a" - href="https://discord.com/invite/aCsmEV5RgA" - label="Community Discord" - /> - } - component="a" - href="https://github.com/ajnart/homarr" - label="Contribute" - /> - - - ); + return ( + + ); + }); const burgerMenu = screenLargerThanMd ? undefined : ( @@ -161,7 +83,7 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => { navbar={ } @@ -174,9 +96,9 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => { Homarr - {attributes.packageVersion && ( + {packageVersion && ( - {attributes.packageVersion} + {packageVersion} )} @@ -189,8 +111,126 @@ export const ManageLayout = ({ children }: ManageLayoutProps) => { - {navigationLinks} + {navigationLinkComponents} ); }; + +type Icon = (props: TablerIconsProps) => JSX.Element; + +type NavigationLinkHref = { + icon: Icon; + href: string; + onlyAdmin?: boolean; +}; + +type NavigationLinkItems = { + icon: Icon; + items: Record; + onlyAdmin?: boolean; +}; + +type CustomNavigationLinkProps = { + name: keyof typeof navigationLinks; + navigationLink: (typeof navigationLinks)[keyof typeof navigationLinks]; +}; + +const CustomNavigationLink = forwardRef< + HTMLAnchorElement | HTMLButtonElement, + CustomNavigationLinkProps +>(({ name, navigationLink }, ref) => { + const { t } = useTranslation('layout/manage'); + + const commonProps = { + label: t(`navigation.${name}.title`), + icon: ( + + + + ), + }; + + if ('href' in navigationLink) { + return ( + } + component={Link} + href={navigationLink.href} + /> + ); + } + + return ( + }> + {Object.entries(navigationLink.items).map(([itemName, item]) => { + const commonItemProps = { + label: t(`navigation.${name}.items.${itemName}`), + icon: , + href: item.href, + }; + + if (item.href.startsWith('http')) { + return ; + } + + return ; + })} + + ); +}); + +type NavigationLinks = { + [key in keyof typeof navigation]: (typeof navigation)[key] extends { + items: Record; + } + ? NavigationLinkItems<(typeof navigation)[key]['items']> + : NavigationLinkHref; +}; + +const navigationLinks: NavigationLinks = { + home: { + icon: IconHome, + href: '/manage', + }, + boards: { + icon: IconLayoutDashboard, + href: '/manage/boards', + }, + users: { + icon: IconUser, + onlyAdmin: true, + items: { + manage: { + icon: IconUsers, + href: '/manage/users', + }, + invites: { + icon: IconMailForward, + href: '/manage/users/invites', + }, + }, + }, + help: { + icon: IconQuestionMark, + items: { + documentation: { + icon: IconBook2, + href: 'https://homarr.dev/docs/about', + }, + report: { + icon: IconBrandGithub, + href: 'https://github.com/ajnart/homarr/issues/new/choose', + }, + discord: { + icon: IconBrandDiscord, + href: 'https://discord.com/invite/aCsmEV5RgA', + }, + contribute: { + icon: IconGitFork, + href: 'https://github.com/ajnart/homarr', + }, + }, + }, +}; diff --git a/src/components/layout/header/AvatarMenu.tsx b/src/components/layout/header/AvatarMenu.tsx index 6cf28bdb1..4f7daaf88 100644 --- a/src/components/layout/header/AvatarMenu.tsx +++ b/src/components/layout/header/AvatarMenu.tsx @@ -55,7 +55,7 @@ export const AvatarMenu = () => { {t('actions.avatar.defaultBoard')} }> - Manage + {t('actions.avatar.manage')} diff --git a/src/components/layout/header/Search/MovieModal.tsx b/src/components/layout/header/Search/MovieModal.tsx index 0935cb0a9..ddc2e355a 100644 --- a/src/components/layout/header/Search/MovieModal.tsx +++ b/src/components/layout/header/Search/MovieModal.tsx @@ -15,7 +15,7 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconDownload, IconExternalLink, IconPlayerPlay } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; +import { Trans, useTranslation } from 'next-i18next'; import Image from 'next/image'; import { useRouter } from 'next/router'; import React, { useMemo } from 'react'; @@ -69,8 +69,9 @@ export const MovieModal = ({ opened, closeModal }: MovieModalProps) => { type MovieResultsProps = Omit, 'movie'>; const MovieResults = ({ search, type }: MovieResultsProps) => { + const { t } = useTranslation('layout/header'); const { name: configName } = useConfigContext(); - const { data: overseerrResults, isLoading } = api.overseerr.search.useQuery( + const { data: movies, isLoading } = api.overseerr.search.useQuery( { query: search, configName: configName!, @@ -94,10 +95,20 @@ const MovieResults = ({ search, type }: MovieResultsProps) => { return ( - Top {overseerrResults?.length} results for {search} + , + }} + /> - {overseerrResults?.map((result, index: number) => ( + {movies?.map((result, index: number) => ( diff --git a/src/env.js b/src/env.js index 1d3bd7bfd..4f52b8476 100644 --- a/src/env.js +++ b/src/env.js @@ -21,7 +21,7 @@ const env = createEnv({ process.env.VERCEL ? z.string().min(1) : z.string().url() ), DOCKER_HOST: z.string().optional(), - DOCKER_PORT: z.string().regex(/\d+/).transform(Number).optional(), + DOCKER_PORT: portSchema, }, /** diff --git a/src/modals/modals.ts b/src/modals.ts similarity index 70% rename from src/modals/modals.ts rename to src/modals.ts index fc4377e78..ba06fbc97 100644 --- a/src/modals/modals.ts +++ b/src/modals.ts @@ -6,12 +6,12 @@ import { WidgetsEditModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsEd import { WidgetsRemoveModal } from '~/components/Dashboard/Tiles/Widgets/WidgetsRemoveModal'; import { CategoryEditModal } from '~/components/Dashboard/Wrappers/Category/CategoryEditModal'; -import { CopyInviteModal } from './copy-invite/copy-invite.modal'; -import { CreateDashboardModal } from './create-dashboard/create-dashboard.modal'; -import { CreateInviteModal } from './create-invite/create-invite.modal'; -import { DeleteBoardModal } from './delete-board/delete-board.modal'; -import { DeleteInviteModal } from './delete-invite/delete-invite.modal'; -import { DeleteUserModal } from './delete-user/delete-user.modal'; +import { CreateBoardModal } from './components/Manage/Board/create-board.modal'; +import { DeleteBoardModal } from './components/Manage/Board/delete-board.modal'; +import { CopyInviteModal } from './components/Manage/User/Invite/copy-invite.modal'; +import { CreateInviteModal } from './components/Manage/User/Invite/create-invite.modal'; +import { DeleteInviteModal } from './components/Manage/User/Invite/delete-invite.modal'; +import { DeleteUserModal } from './components/Manage/User/delete-user.modal'; export const modals = { editApp: EditAppModal, @@ -24,7 +24,7 @@ export const modals = { deleteUserModal: DeleteUserModal, createInviteModal: CreateInviteModal, deleteInviteModal: DeleteInviteModal, - createDashboardModal: CreateDashboardModal, + createBoardModal: CreateBoardModal, copyInviteModal: CopyInviteModal, deleteBoardModal: DeleteBoardModal, }; diff --git a/src/modals/delete-user/delete-user.modal.tsx b/src/modals/delete-user/delete-user.modal.tsx deleted file mode 100644 index 71f10243d..000000000 --- a/src/modals/delete-user/delete-user.modal.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Button, Group, Stack, Text } from '@mantine/core'; -import { ContextModalProps, modals } from '@mantine/modals'; -import { api } from '~/utils/api'; - -export const DeleteUserModal = ({ - context, - id, - innerProps, -}: ContextModalProps<{ userId: string; username: string }>) => { - const apiContext = api.useContext(); - const { isLoading, mutateAsync } = api.user.deleteUser.useMutation({ - onSuccess: async () => { - await apiContext.user.all.invalidate(); - modals.close(id); - }, - }); - return ( - - - Are you sure, that you want to delete the user {innerProps.username}? This will delete data - associated with this account, but not any created dashboards by this user. - - - - - - - - ); -}; diff --git a/src/modules/Docker/DockerModule.tsx b/src/modules/Docker/DockerModule.tsx index 182146505..db459f7b7 100644 --- a/src/modules/Docker/DockerModule.tsx +++ b/src/modules/Docker/DockerModule.tsx @@ -19,7 +19,7 @@ export default function DockerMenuButton(props: any) { const dockerEnabled = config?.settings.customization.layout.enabledDocker || false; - const { data, refetch } = api.docker.containers.useQuery(undefined, { + const { data, refetch, isLoading } = api.docker.containers.useQuery(undefined, { enabled: dockerEnabled, }); useHotkeys([['mod+B', () => setOpened(!opened)]]); @@ -42,7 +42,7 @@ export default function DockerMenuButton(props: any) { padding="xl" position="right" size="100%" - title={} + title={} transitionProps={{ transition: 'pop', }} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8396c4beb..b1fa11f8e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -20,7 +20,7 @@ import { z } from 'zod'; import { CommonHead } from '~/components/layout/Meta/CommonHead'; import { env } from '~/env.js'; import { ColorSchemeProvider } from '~/hooks/use-colorscheme'; -import { modals } from '~/modals/modals'; +import { modals } from '~/modals'; import { queryClient } from '~/tools/server/configurations/tanstack/queryClient.tool'; import { ConfigType } from '~/types/config'; import { api } from '~/utils/api'; diff --git a/src/pages/auth/invite/[inviteId].tsx b/src/pages/auth/invite/[inviteId].tsx index 5deb9842e..7b46910ba 100644 --- a/src/pages/auth/invite/[inviteId].tsx +++ b/src/pages/auth/invite/[inviteId].tsx @@ -4,16 +4,18 @@ import { showNotification, updateNotification } from '@mantine/notifications'; import { IconCheck, IconX } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import Head from 'next/head'; import { useRouter } from 'next/router'; import { z } from 'zod'; import { getServerAuthSession } from '~/server/auth'; import { prisma } from '~/server/db'; -import { inviteNamespaces } from '~/tools/server/translation-namespaces'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { signUpFormSchema } from '~/validations/user'; +const notificationId = 'register'; + export default function AuthInvitePage() { const { t } = useTranslation('authentication/invite'); const { i18nZodResolver } = useI18nZodResolver(); @@ -28,11 +30,10 @@ export default function AuthInvitePage() { }); const handleSubmit = (values: z.infer) => { - const notificationId = 'register'; showNotification({ id: notificationId, - title: 'Creating account', - message: 'Please wait...', + title: t('notifications.loading.title'), + message: `${t('notifications.loading.text')}...`, loading: true, }); void mutateAsync( @@ -44,8 +45,8 @@ export default function AuthInvitePage() { onSuccess() { updateNotification({ id: notificationId, - title: 'Account created', - message: 'Your account has been created successfully', + title: t('notifications.success.title'), + message: t('notifications.success.text'), color: 'teal', icon: , }); @@ -54,8 +55,8 @@ export default function AuthInvitePage() { onError() { updateNotification({ id: notificationId, - title: 'Error', - message: 'Something went wrong', + title: t('notifications.error.title'), + message: t('notifications.error.text'), color: 'red', icon: , }); @@ -64,47 +65,55 @@ export default function AuthInvitePage() { ); }; + const metaTitle = `${t('metaTitle')} • Homarr`; + return ( - - - - {t('title')} - + <> + + {metaTitle} + - - {t('text')} - + + + + {t('title')} + -
- - + + {t('text')} + - + + + - + - - - -
-
+ + + +
+ +
+ + ); } @@ -157,7 +166,7 @@ export const getServerSideProps: GetServerSideProps = async ({ return { props: { - ...(await serverSideTranslations(locale ?? '', inviteNamespaces)), + ...(await getServerSideTranslations(['authentication/invite'], locale, req, res)), }, }; }; diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index f8fc0409d..4223a6793 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -14,17 +14,15 @@ import { IconAlertTriangle } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { signIn } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { z } from 'zod'; import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { signInSchema } from '~/validations/user'; -import { loginNamespaces } from '../../tools/server/translation-namespaces'; - export default function LoginPage() { const { t } = useTranslation('authentication/login'); const queryParams = useRouter().query as { error?: 'CredentialsSignin' | (string & {}) }; @@ -54,49 +52,54 @@ export default function LoginPage() { }); }; + const metaTitle = `${t('metaTitle')} • Homarr`; + return ( - + <> - Login • Homarr + {metaTitle} - - - {t('title')} - - - {t('text')} - + + + + {t('title')} + -
- - + + {t('text')} + - + + + - + - {queryParams.error === 'CredentialsSignin' && ( - } color="red"> - {t('alert')} - - )} - - -
-
+ + + {queryParams.error === 'CredentialsSignin' && ( + } color="red"> + {t('alert')} + + )} + + +
+
+ ); } @@ -114,8 +117,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res return { props: { - ...(await serverSideTranslations(locale ?? 'en', loginNamespaces)), - // Will be passed to the page component as props + ...(await getServerSideTranslations(['authentication/login'], locale, req, res)), }, }; }; diff --git a/src/pages/board/[slug]/customize.tsx b/src/pages/board/[slug]/customize.tsx index 05a22cbbd..6463a1f42 100644 --- a/src/pages/board/[slug]/customize.tsx +++ b/src/pages/board/[slug]/customize.tsx @@ -33,7 +33,7 @@ import { boardNamespaces } from '~/tools/server/translation-namespaces'; import { firstUpperCase } from '~/tools/shared/strings'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; -import { boardCustomizationSchema } from '~/validations/dashboards'; +import { boardCustomizationSchema } from '~/validations/boards'; const notificationId = 'board-customization-notification'; diff --git a/src/pages/manage/boards/index.tsx b/src/pages/manage/boards/index.tsx index 5ee0611da..c1fd95007 100644 --- a/src/pages/manage/boards/index.tsx +++ b/src/pages/manage/boards/index.tsx @@ -3,7 +3,6 @@ import { Badge, Button, Card, - Flex, Group, LoadingOverlay, Menu, @@ -26,9 +25,11 @@ import { IconTrash, } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; -import { useTranslation } from 'next-i18next'; +import { openCreateBoardModal } from '~/components/Manage/Board/create-board.modal'; +import { openDeleteBoardModal } from '~/components/Manage/Board/delete-board.modal'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { sleep } from '~/tools/client/time'; @@ -47,31 +48,26 @@ const BoardsPage = () => { const [deletingDashboards, { append, filter }] = useListState([]); - const { t } = useTranslation('boards/manage'); + const { t } = useTranslation('manage/boards'); + + const metaTitle = `${t('metaTitle')} • Homarr`; return ( - Boards • Homarr + {metaTitle} - {t('title')} - - + + {t('pageTitle')} - + {data && ( { { - modals.openContextModal({ - modal: 'deleteBoardModal', - title: {t('cards.menu.delete.modalTitle')}, - innerProps: { - boardName: board.name, - onConfirm: async () => { - append(board.name); - // give user feedback, that it's being deleted - await sleep(500); - filter((item, _) => item !== board.name); - }, + openDeleteBoardModal({ + boardName: board.name, + onConfirm: async () => { + append(board.name); + // give user feedback, that it's being deleted + await sleep(500); + filter((item, _) => item !== board.name); }, }); }} @@ -213,8 +205,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const translations = await getServerSideTranslations( manageNamespaces, ctx.locale, - undefined, - undefined + ctx.req, + ctx.res ); return { props: { diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx index 7eb27801d..ad7a0e1e2 100644 --- a/src/pages/manage/index.tsx +++ b/src/pages/manage/index.tsx @@ -12,6 +12,7 @@ import { import { IconArrowRight } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Image from 'next/image'; import Link from 'next/link'; @@ -19,24 +20,31 @@ import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { useScreenLargerThan } from '~/hooks/useScreenLargerThan'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { OnlyKeysWithStructure } from '~/types/helpers'; + +import { type quickActions } from '../../../public/locales/en/manage/index.json'; const ManagementPage = () => { + const { t } = useTranslation('manage/index'); const { classes } = useStyles(); const largerThanMd = useScreenLargerThan('md'); const { data: sessionData } = useSession(); + const metaTitle = `${t('metaTitle')} • Homarr`; return ( - Manage • Homarr + {metaTitle} - Welcome back, {sessionData?.user?.name ?? 'Anonymous'} + {t('hero.title', { + username: sessionData?.user?.name ?? t('hero.fallbackUsername'), + })} - Welcome to Your Application Hub. Organize, Optimize, and Conquer! + {t('hero.subtitle')} { src="/imgs/logo/logo.png" width={largerThanMd ? 200 : 100} height={largerThanMd ? 150 : 60} - alt="" + alt="Homarr Logo" /> @@ -57,7 +65,7 @@ const ManagementPage = () => { - Quick actions + {t('quickActions.title')} { { maxWidth: '48rem', cols: 1, spacing: 'md' }, ]} > - - - - - Your boards - Show a list of all your dashboards - - - - - - - - - - Invite a new user - Create and send an invitation for registration - - - - - - - - - - Manage users - Delete and manage your users - - - - - + + + ); }; +type QuickActionType = OnlyKeysWithStructure< + typeof quickActions, + { + title: string; + subtitle: string; + } +>; + +type QuickActionCardProps = { + type: QuickActionType; + href: string; +}; + +const QuickActionCard = ({ type, href }: QuickActionCardProps) => { + const { t } = useTranslation('manage/index'); + const { classes } = useStyles(); + + return ( + + + + + {t(`quickActions.${type}.title`)} + {t(`quickActions.${type}.subtitle`)} + + + + + + ); +}; + export const getServerSideProps: GetServerSideProps = async (ctx) => { const session = await getServerAuthSession(ctx); @@ -115,10 +125,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { } const translations = await getServerSideTranslations( - ['common'], + ['layout/manage', 'manage/index'], ctx.locale, - undefined, - undefined + ctx.req, + ctx.res ); return { props: { diff --git a/src/pages/manage/settings/index.tsx b/src/pages/manage/settings/index.tsx deleted file mode 100644 index 04607cf30..000000000 --- a/src/pages/manage/settings/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Text, Title } from '@mantine/core'; -import { GetServerSideProps } from 'next'; -import Head from 'next/head'; -import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; -import { getServerAuthSession } from '~/server/auth'; -import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; - -const SettingsPage = () => { - return ( - - - Settings • Homarr - - - Settings - Coming soon! - - ); -}; - -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const session = await getServerAuthSession(ctx); - - if (!session?.user.isAdmin) { - return { - notFound: true, - }; - } - - const translations = await getServerSideTranslations( - ['common'], - ctx.locale, - undefined, - undefined - ); - return { - props: { - ...translations, - }, - }; -}; - -export default SettingsPage; diff --git a/src/pages/manage/users/create.tsx b/src/pages/manage/users/create.tsx index 7ab509c18..5ca9ded6c 100644 --- a/src/pages/manage/users/create.tsx +++ b/src/pages/manage/users/create.tsx @@ -1,25 +1,17 @@ -import { Alert, Button, Card, Group, Stepper, Table, Text, Title } from '@mantine/core'; -import { useForm, zodResolver } from '@mantine/form'; -import { - IconArrowLeft, - IconCheck, - IconInfoCircle, - IconKey, - IconMail, - IconMailCheck, - IconUser, - IconUserPlus, -} from '@tabler/icons-react'; +import { Alert, Button, Group, Stepper } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconArrowLeft, IconKey, IconMailCheck, IconUser, IconUserPlus } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useState } from 'react'; -import { useTranslation } from 'next-i18next'; import { z } from 'zod'; import { CreateAccountStep, createAccountStepValidationSchema, } from '~/components/Manage/User/Create/create-account-step'; +import { ReviewInputStep } from '~/components/Manage/User/Create/review-input-step'; import { CreateAccountSecurityStep, createAccountSecurityStepValidationSchema, @@ -27,15 +19,17 @@ import { import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; -import { api } from '~/utils/api'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; const CreateNewUserPage = () => { + const { t } = useTranslation('manage/users/create'); const [active, setActive] = useState(0); const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + const { i18nZodResolver } = useI18nZodResolver(); - const form = useForm({ + const form = useForm({ initialValues: { account: { username: '', @@ -45,30 +39,14 @@ const CreateNewUserPage = () => { password: '', }, }, - validate: zodResolver( - z.object({ - account: createAccountStepValidationSchema, - security: createAccountSecurityStepValidationSchema, - }) - ), + validate: i18nZodResolver(createAccountSchema), }); - const context = api.useContext(); - const { mutateAsync, isLoading } = api.user.create.useMutation({ - onSettled: () => { - void context.user.all.invalidate(); - }, - onSuccess: () => { - nextStep(); - }, - }); - - const { t } = useTranslation('user/create'); - + const metaTitle = `${t('metaTitle')} • Homarr`; return ( - Create user • Homarr + {metaTitle} @@ -111,92 +89,11 @@ const CreateNewUserPage = () => { label={t('steps.finish.title')} description={t('steps.finish.title')} > - - {t('steps.finish.card.title')} - {t('steps.finish.card.text')} - - - - - - - - - - - - - - - - - - - - - - -
{t('steps.finish.table.header.property')}{t('steps.finish.table.header.value')}
- - - {t('steps.finish.table.header.username')} - - {form.values.account.username}
- - - {t('steps.finish.table.header.email')} - - - {form.values.account.eMail ? ( - {form.values.account.eMail} - ) : ( - - - {t('steps.finish.table.notSet')} - - )} -
- - - {t('steps.finish.table.password')} - - - - - {t('steps.finish.table.valid')} - -
- - - - - -
+ - - {t('steps.finish.alertConfirmed')} + + {t('steps.completed.alert.text')} @@ -225,6 +122,13 @@ const CreateNewUserPage = () => { ); }; +const createAccountSchema = z.object({ + account: createAccountStepValidationSchema, + security: createAccountSecurityStepValidationSchema, +}); + +export type CreateAccountSchema = z.infer; + export const getServerSideProps: GetServerSideProps = async (ctx) => { const session = await getServerAuthSession(ctx); @@ -237,8 +141,8 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const translations = await getServerSideTranslations( manageNamespaces, ctx.locale, - undefined, - undefined + ctx.req, + ctx.res ); return { props: { diff --git a/src/pages/manage/users/index.tsx b/src/pages/manage/users/index.tsx index 3049c7f20..834d1afab 100644 --- a/src/pages/manage/users/index.tsx +++ b/src/pages/manage/users/index.tsx @@ -15,15 +15,16 @@ import { useDebouncedValue } from '@mantine/hooks'; import { openContextModal } from '@mantine/modals'; import { IconPlus, IconTrash } from '@tabler/icons-react'; import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useState } from 'react'; -import { useTranslation } from 'next-i18next'; +import { openDeleteUserModal } from '~/components/Manage/User/delete-user.modal'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; -import { api } from '~/utils/api'; import { manageNamespaces } from '~/tools/server/translation-namespaces'; +import { api } from '~/utils/api'; const ManageUsersPage = () => { const [activePage, setActivePage] = useState(0); @@ -34,7 +35,7 @@ const ManageUsersPage = () => { search: debouncedSearch, }); - const { t } = useTranslation('user/manage'); + const { t } = useTranslation('manage/users'); return ( @@ -86,16 +87,7 @@ const ManageUsersPage = () => { { - openContextModal({ - modal: 'deleteUserModal', - title: ( - {t('modals.delete', { name: user.name })} - ), - innerProps: { - userId: user.id, - username: user.name ?? '', - }, - }); + openDeleteUserModal(user); }} color="red" variant="light" @@ -112,9 +104,7 @@ const ManageUsersPage = () => { - - {t('searchDoesntMatch')} - + {t('searchDoesntMatch')} diff --git a/src/pages/manage/users/invites.tsx b/src/pages/manage/users/invites.tsx index 8432e060e..5d491337f 100644 --- a/src/pages/manage/users/invites.tsx +++ b/src/pages/manage/users/invites.tsx @@ -13,9 +13,10 @@ import { modals } from '@mantine/modals'; import { IconPlus, IconTrash } from '@tabler/icons-react'; import dayjs from 'dayjs'; import { GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import { useState } from 'react'; -import { useTranslation } from 'next-i18next'; +import { openCreateInviteModal } from '~/components/Manage/User/Invite/create-invite.modal'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; @@ -23,40 +24,33 @@ import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; const ManageUserInvitesPage = () => { + const { classes } = useStyles(); + const { t } = useTranslation('manage/users/invites'); const [activePage, setActivePage] = useState(0); - const { data } = api.invites.all.useQuery({ + const { data: invites } = api.invites.all.useQuery({ page: activePage, }); - const { classes } = useStyles(); - - const handleFetchNextPage = async () => { + const nextPage = () => { setActivePage((prev) => prev + 1); }; - const handleFetchPreviousPage = async () => { + const previousPage = () => { setActivePage((prev) => prev - 1); }; - const { t } = useTranslation('user/invites'); - + const metaTitle = `${t('metaTitle')} • Homarr`; return ( - User invites • Homarr + {metaTitle} - {t('title')} - {t('text')} + {t('pageTitle')} + {t('description')} - {data && ( + {invites && ( <> @@ -76,7 +70,7 @@ const ManageUserInvitesPage = () => { - {data.invites.map((invite, index) => ( + {invites.invites.map((invite, index) => ( ))} - {data.invites.length === 0 && ( + {invites.invites.length === 0 && ( -
{invite.id} @@ -114,9 +108,9 @@ const ManageUserInvitesPage = () => {
+
{t('noInvites')}
@@ -126,18 +120,18 @@ const ManageUserInvitesPage = () => {
{ setActivePage(targetPage - 1); }} - onNextPage={handleFetchNextPage} - onPreviousPage={handleFetchPreviousPage} + onNextPage={nextPage} + onPreviousPage={previousPage} onFirstPage={() => { setActivePage(0); }} onLastPage={() => { - setActivePage(data.countPages - 1); + setActivePage(invites.countPages - 1); }} withEdges /> @@ -168,9 +162,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { const translations = await getServerSideTranslations( manageNamespaces, ctx.locale, - undefined, - undefined + ctx.req, + ctx.res ); + return { props: { ...translations, diff --git a/src/pages/onboard.tsx b/src/pages/onboard.tsx index 6b8523af7..02e8bf363 100644 --- a/src/pages/onboard.tsx +++ b/src/pages/onboard.tsx @@ -26,7 +26,6 @@ import { ReactNode, useMemo, useState } from 'react'; import { z } from 'zod'; import { prisma } from '~/server/db'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; -import { onboardNamespaces } from '~/tools/server/translation-namespaces'; import { api } from '~/utils/api'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { signUpFormSchema } from '~/validations/user'; @@ -225,12 +224,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => { }; } - const translations = await getServerSideTranslations( - onboardNamespaces, - ctx.locale, - ctx.req, - ctx.res - ); + const translations = await getServerSideTranslations([], ctx.locale, ctx.req, ctx.res); return { props: { diff --git a/src/pages/user/preferences.tsx b/src/pages/user/preferences.tsx index 8ec563950..f2754c730 100644 --- a/src/pages/user/preferences.tsx +++ b/src/pages/user/preferences.tsx @@ -200,12 +200,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res, locale await helpers.user.withSettings.prefetch(); await helpers.boards.all.prefetch(); - const translations = await getServerSideTranslations( - ['user/preferences'], - locale, - undefined, - undefined - ); + const translations = await getServerSideTranslations(['user/preferences'], locale, req, res); return { props: { ...translations, diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index 87a3e6987..072224155 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { configExists } from '~/tools/config/configExists'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { BackendConfigType, ConfigType } from '~/types/config'; -import { boardCustomizationSchema } from '~/validations/dashboards'; +import { boardCustomizationSchema } from '~/validations/boards'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; import { getConfig } from '../../../tools/config/getConfig'; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 5a8bfe416..6d3687b44 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -28,7 +28,7 @@ export const userRouter = createTRPCRouter({ }); } - await createUserInNotExist(ctx, input, { + await createUserIfNotPresent(ctx, input, { defaultSettings: { colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', @@ -62,7 +62,7 @@ export const userRouter = createTRPCRouter({ }); } - await createUserInNotExist(ctx, input, { + await createUserIfNotPresent(ctx, input, { defaultSettings: { colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', @@ -235,7 +235,7 @@ export const userRouter = createTRPCRouter({ return { users: users.map((user) => ({ id: user.id, - name: user.name, + name: user.name!, email: user.email, emailVerified: user.emailVerified, })), @@ -243,25 +243,25 @@ export const userRouter = createTRPCRouter({ }; }), create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => { - await createUserInNotExist(ctx, input); + await createUserIfNotPresent(ctx, input); }), deleteUser: adminProcedure .input( z.object({ - userId: z.string(), + id: z.string(), }) ) .mutation(async ({ ctx, input }) => { await ctx.prisma.user.delete({ where: { - id: input.userId, + id: input.id, }, }); }), }); -const createUserInNotExist = async ( +const createUserIfNotPresent = async ( ctx: TRPCContext, input: z.infer, options: { diff --git a/src/server/auth.ts b/src/server/auth.ts index 23e855e37..44d25ffbb 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,5 +1,6 @@ import { PrismaAdapter } from '@next-auth/prisma-adapter'; import bcrypt from 'bcryptjs'; +import Consola from 'consola'; import Cookies from 'cookies'; import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next'; import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth'; @@ -150,15 +151,15 @@ export const constructAuthOptions = ( return null; } - console.log(`user ${user.id} is trying to log in. checking password...`); + Consola.log(`user ${user.id} is trying to log in. checking password...`); const isValidPassword = await bcrypt.compare(data.password, user.password); if (!isValidPassword) { - console.log(`password for user ${user.id} was incorrect`); + Consola.log(`password for user ${user.id} was incorrect`); return null; } - console.log(`user ${user.id} successfully authorized`); + Consola.log(`user ${user.id} successfully authorized`); return { id: user.id, diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index f6703589c..9c95505de 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -1,10 +1,7 @@ export const boardNamespaces = [ - 'common', - 'zod', 'layout/element-selector/selector', 'layout/modals/add-app', 'layout/modals/change-position', - 'layout/modals/about', 'layout/common', 'layout/header/actions/toggle-edit-mode', 'layout/mobile/drawer', @@ -42,16 +39,9 @@ export const boardNamespaces = [ ]; export const manageNamespaces = [ - 'user/preferences', - 'user/manage', - 'user/invites', - 'user/create', - 'boards/manage', - 'zod', + 'manage/common', + 'manage/boards', + 'manage/users', + 'manage/users/invites', + 'manage/users/create', ]; - -export const loginNamespaces = ['authentication/login', 'zod']; - -export const inviteNamespaces = ['authentication/invite', 'zod']; - -export const onboardNamespaces = ['common', 'zod']; diff --git a/src/types/helpers.ts b/src/types/helpers.ts new file mode 100644 index 000000000..06435ee05 --- /dev/null +++ b/src/types/helpers.ts @@ -0,0 +1,3 @@ +export type OnlyKeysWithStructure = { + [P in keyof T]: T[P] extends TStructure ? P : never; +}[keyof T]; diff --git a/src/validations/dashboards.ts b/src/validations/boards.ts similarity index 94% rename from src/validations/dashboards.ts rename to src/validations/boards.ts index a4d3fc3c5..12b5af96c 100644 --- a/src/validations/dashboards.ts +++ b/src/validations/boards.ts @@ -1,7 +1,7 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { z } from 'zod'; -export const createDashboardSchemaValidation = z.object({ +export const createBoardSchemaValidation = z.object({ name: z.string().min(2).max(25), }); diff --git a/src/validations/user.ts b/src/validations/user.ts index 3dee871a9..c44679a49 100644 --- a/src/validations/user.ts +++ b/src/validations/user.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; import { CustomErrorParams } from '~/utils/i18n-zod-resolver'; +export const minPasswordLength = 8; + export const passwordSchema = z .string() - .min(8) + .min(minPasswordLength) .max(100) .refine((value) => /[0-9]/.test(value)) .refine((value) => /[a-z]/.test(value)) @@ -18,12 +20,12 @@ export const signInSchema = z.object({ export const signUpFormSchema = z .object({ username: z.string().min(3), - password: z.string().min(8), - passwordConfirmation: z.string().min(8), + password: z.string().min(minPasswordLength), + passwordConfirmation: z.string().min(minPasswordLength), }) .refine((data) => data.password === data.passwordConfirmation, { params: { - i18n: { key: 'password_match' }, + i18n: { key: 'passwordMatch' }, } satisfies CustomErrorParams, path: ['passwordConfirmation'], }); diff --git a/src/widgets/weather/WeatherIcon.tsx b/src/widgets/weather/WeatherIcon.tsx index 23325126e..a32d9ffef 100644 --- a/src/widgets/weather/WeatherIcon.tsx +++ b/src/widgets/weather/WeatherIcon.tsx @@ -11,7 +11,6 @@ import { IconSun, } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; -import { CustomTypeOptions } from 'i18next'; interface WeatherIconProps { code: number;