mirror of
https://github.com/ajnart/homarr.git
synced 2026-03-02 02:10:59 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -27,7 +27,7 @@
|
||||
"Umami"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||
"i18n-ally.enabledFrameworks": ["next-intl"],
|
||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.extract.keyMaxLength": 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20.18.0-alpine AS base
|
||||
FROM node:22.11.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import "@homarr/auth/env.mjs";
|
||||
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.mjs";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
@@ -34,4 +38,4 @@ const config = {
|
||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||
|
||||
export default config;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@mantine/tiptap": "^7.13.4",
|
||||
"@million/lint": "1.0.11",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.20.0",
|
||||
"@tabler/icons-react": "^3.21.0",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"@tanstack/react-query-devtools": "^5.59.16",
|
||||
"@tanstack/react-query-next-experimental": "5.59.16",
|
||||
@@ -70,7 +70,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.80.4",
|
||||
"sass": "^1.80.5",
|
||||
"superjson": "2.2.1",
|
||||
"swagger-ui-react": "^5.17.14",
|
||||
"use-deep-compare-effect": "^1.8.1"
|
||||
@@ -80,7 +80,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^20.17.1",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { defaultLocale } from "@homarr/translation";
|
||||
import { I18nProviderClient } from "@homarr/translation/client";
|
||||
|
||||
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
|
||||
return (
|
||||
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
||||
{children}
|
||||
</I18nProviderClient>
|
||||
);
|
||||
};
|
||||
@@ -7,18 +7,20 @@ import "@homarr/ui/styles.css";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
import { composeWrappers } from "./compose";
|
||||
@@ -59,10 +61,15 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||
if (!isLocaleSupported(props.params.locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const colorScheme = getColorScheme();
|
||||
const tCommon = await getScopedI18n("common");
|
||||
const direction = tCommon("direction");
|
||||
const i18nMessages = await getI18nMessages();
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
@@ -70,7 +77,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
},
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
]);
|
||||
@@ -78,7 +85,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang="en"
|
||||
lang={props.params.locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
@@ -62,6 +63,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
label: t("items.users.label"),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CopyMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
{({ copy, copied }) => (
|
||||
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
|
||||
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
|
||||
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||
|
||||
const onClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("media.action.delete.label"),
|
||||
children: t.rich("media.action.delete.description", { bName: () => <b>{media.name}</b> }),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await mutateAsync({ id: media.id });
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { SwitchProps } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
|
||||
|
||||
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
const t = useI18n();
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setChecked(event.target.checked);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("includeFromAllUsers", event.target.checked.toString());
|
||||
if (params.has("page")) params.set("page", "1"); // Reset page to 1
|
||||
router.replace(`${pathName}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={defaultChecked}
|
||||
checked={checked}
|
||||
label={t("management.page.media.includeFromAllUsers")}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMedia = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => (
|
||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
import { UploadMedia } from "./_actions/upload-media";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
includeFromAllUsers: z
|
||||
.string()
|
||||
.regex(/true|false/)
|
||||
.catch("false")
|
||||
.transform((value) => value === "true"),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface MediaListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("media.plural")}</Title>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />}
|
||||
</Group>
|
||||
|
||||
<UploadMedia />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh></TableTh>
|
||||
<TableTh>{t("media.field.name")}</TableTh>
|
||||
<TableTh>{t("media.field.size")}</TableTh>
|
||||
<TableTh>{t("media.field.creator")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{medias.map((media) => (
|
||||
<Row key={media.id} media={media} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ media }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
src={`/api/user-medias/${media.id}`}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
<UserAvatar user={media.creator} size="sm" />
|
||||
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
|
||||
{media.creator.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableTd>
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<DeleteMedia media={media} />
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
@@ -60,7 +60,10 @@ export const UserCreateStepperComponent = () => {
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordsDoNotMatch",
|
||||
params: {},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
|
||||
@@ -58,7 +58,7 @@ export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
|
||||
},
|
||||
);
|
||||
},
|
||||
[group.id, mutate, t],
|
||||
[group.id, mutate, t, disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,8 +10,8 @@ export const ReservedGroupAlert = async () => {
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("group.reservedNotice.message", {
|
||||
checkoutDocs: (
|
||||
{t.rich("group.reservedNotice.message", {
|
||||
checkoutDocs: () => (
|
||||
<Anchor
|
||||
size="sm"
|
||||
component={Link}
|
||||
|
||||
@@ -45,9 +45,7 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = {
|
||||
[key in GroupPermissionKey]: boolean;
|
||||
};
|
||||
type FormType = Record<GroupPermissionKey, boolean>;
|
||||
|
||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||
const form = useFormContext();
|
||||
|
||||
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const image = await db.query.medias.findFirst({
|
||||
where: eq(medias.id, params.id),
|
||||
columns: {
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", image.contentType);
|
||||
headers.set("Content-Length", image.content.length.toString());
|
||||
|
||||
return new NextResponse(image.content, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||
import { Combobox, Group, InputBase, Loader, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { localeAttributes, supportedLanguages } from "@homarr/translation";
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./language-combobox.module.css";
|
||||
@@ -15,7 +15,7 @@ export const LanguageCombobox = () => {
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
const currentLocale = useCurrentLocale();
|
||||
const changeLocale = useChangeLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(value: string) => {
|
||||
@@ -39,6 +39,7 @@ export const LanguageCombobox = () => {
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
leftSection={isPending ? <Loader size={16} /> : null}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
@@ -72,11 +73,11 @@ const OptionItem = ({
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<span className={`fi fi-${localeAttributes[localeKey].flagIcon} ${classes.flagIcon}`}></span>
|
||||
<span className={`fi fi-${localeConfigurations[localeKey].flagIcon} ${classes.flagIcon}`}></span>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{localeAttributes[localeKey].name}</Text>
|
||||
<Text>{localeConfigurations[localeKey].name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({localeAttributes[localeKey].translatedName})
|
||||
({localeConfigurations[localeKey].translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const getPackageAttributesAsync = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
type PackageJsonDependencies = { [key in string]: string };
|
||||
type PackageJsonDependencies = Record<string, string>;
|
||||
interface PackageJson {
|
||||
dependencies: PackageJsonDependencies | undefined;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^20.17.1",
|
||||
"@types/node": "^22.8.6",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.13.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.19.1",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"tsx": "4.19.1",
|
||||
"tsx": "4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
10
package.json
10
package.json
@@ -29,8 +29,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@turbo/gen": "^2.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^2.1.3",
|
||||
"@vitest/ui": "^2.1.3",
|
||||
"@vitest/coverage-v8": "^2.1.4",
|
||||
"@vitest/ui": "^2.1.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
@@ -38,11 +38,11 @@
|
||||
"turbo": "^2.2.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.1.3"
|
||||
"vitest": "^2.1.4"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"packageManager": "pnpm@9.12.3",
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
"node": ">=22.11.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
import { mediaRouter } from "./router/medias/media-router";
|
||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { userRouter } from "./router/user";
|
||||
@@ -33,6 +34,7 @@ export const appRouter = createTRPCRouter({
|
||||
serverSettings: serverSettingsRouter,
|
||||
cronJobs: cronJobsRouter,
|
||||
apiKeys: apiKeysRouter,
|
||||
media: mediaRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
88
packages/api/src/router/medias/media-router.ts
Normal file
88
packages/api/src/router/medias/media-router.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, createId, desc, eq, like } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const mediaRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
.input(
|
||||
validation.common.paginated.and(
|
||||
z.object({ includeFromAllUsers: z.boolean().default(false), search: z.string().trim().default("") }),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeFromAllUsers = ctx.session.user.permissions.includes("admin") && input.includeFromAllUsers;
|
||||
|
||||
const where = and(
|
||||
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
|
||||
includeFromAllUsers ? undefined : eq(medias.creatorId, ctx.session.user.id),
|
||||
);
|
||||
const dbMedias = await ctx.db.query.medias.findMany({
|
||||
where,
|
||||
orderBy: desc(medias.createdAt),
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
columns: {
|
||||
content: false,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalCount = await ctx.db.$count(medias, where);
|
||||
|
||||
return {
|
||||
items: dbMedias,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => {
|
||||
const content = Buffer.from(await input.file.arrayBuffer());
|
||||
const id = createId();
|
||||
await ctx.db.insert(medias).values({
|
||||
id,
|
||||
creatorId: ctx.session.user.id,
|
||||
content,
|
||||
size: input.file.size,
|
||||
contentType: input.file.type,
|
||||
name: input.file.name,
|
||||
});
|
||||
|
||||
return id;
|
||||
}),
|
||||
deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
||||
const dbMedia = await ctx.db.query.medias.findFirst({
|
||||
where: eq(medias.id, input.id),
|
||||
columns: {
|
||||
creatorId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbMedia) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Media not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow admins and the creator of the media to delete it
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this media",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(medias).where(eq(medias.id, input.id));
|
||||
}),
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.1",
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.16",
|
||||
"react": "^18.3.1",
|
||||
"tldts": "^6.1.55"
|
||||
"tldts": "^6.1.57"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -5,3 +5,7 @@ export type AtLeastOneOf<T> = [T, ...T[]];
|
||||
export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
|
||||
[P in keyof (Omit<T, keyof R> & R)]: (Omit<T, keyof R> & R)[P];
|
||||
};
|
||||
|
||||
export type RemoveReadonly<T> = {
|
||||
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
|
||||
};
|
||||
|
||||
12
packages/db/migrations/mysql/0014_bizarre_red_shift.sql
Normal file
12
packages/db/migrations/mysql/0014_bizarre_red_shift.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `media` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`name` varchar(512) NOT NULL,
|
||||
`content` BLOB NOT NULL,
|
||||
`content_type` text NOT NULL,
|
||||
`size` int NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`creator_id` varchar(64),
|
||||
CONSTRAINT `media_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `media` ADD CONSTRAINT `media_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
1602
packages/db/migrations/mysql/meta/0014_snapshot.json
Normal file
1602
packages/db/migrations/mysql/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1729369383739,
|
||||
"tag": "0013_youthful_vulture",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "5",
|
||||
"when": 1729524382483,
|
||||
"tag": "0014_bizarre_red_shift",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
packages/db/migrations/sqlite/0014_colorful_cargill.sql
Normal file
10
packages/db/migrations/sqlite/0014_colorful_cargill.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `media` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`content` blob NOT NULL,
|
||||
`content_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`creator_id` text,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
1531
packages/db/migrations/sqlite/meta/0014_snapshot.json
Normal file
1531
packages/db/migrations/sqlite/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1729369389386,
|
||||
"tag": "0013_faithful_hex",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1729524387583,
|
||||
"tag": "0014_colorful_cargill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"@testcontainers/mysql": "^10.13.2",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.26.2",
|
||||
"drizzle-orm": "^0.35.3",
|
||||
"drizzle-kit": "^0.27.1",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"mysql2": "3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56,7 +56,7 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.13.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.19.1",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { relations } from "drizzle-orm";
|
||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, tinyint, varchar } from "drizzle-orm/mysql-core";
|
||||
import {
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
int,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
tinyint,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
import type {
|
||||
BackgroundImageAttachment,
|
||||
@@ -20,6 +31,12 @@ import type {
|
||||
} from "@homarr/definitions";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
|
||||
const customBlob = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return "BLOB";
|
||||
},
|
||||
});
|
||||
|
||||
export const apiKeys = mysqlTable("apiKey", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
@@ -142,6 +159,16 @@ export const invites = mysqlTable("invite", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = mysqlTable("media", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 512 }).notNull(),
|
||||
content: customBlob("content").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),
|
||||
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = mysqlTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -387,6 +414,13 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
invites: many(invites),
|
||||
}));
|
||||
|
||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [medias.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRelations = relations(icons, ({ one }) => ({
|
||||
repository: one(iconRepositories, {
|
||||
fields: [icons.iconRepositoryId],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type {
|
||||
@@ -145,6 +145,18 @@ export const invites = sqliteTable("invite", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = sqliteTable("media", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
content: blob("content", { mode: "buffer" }).$type<Buffer>().notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
creatorId: text("creator_id").references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -387,6 +399,14 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
groups: many(groupMembers),
|
||||
ownedGroups: many(groups),
|
||||
invites: many(invites),
|
||||
medias: many(medias),
|
||||
}));
|
||||
|
||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [medias.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRelations = relations(icons, ({ one }) => ({
|
||||
|
||||
@@ -9,11 +9,35 @@ import { objectEntries } from "@homarr/common";
|
||||
import * as mysqlSchema from "../schema/mysql";
|
||||
import * as sqliteSchema from "../schema/sqlite";
|
||||
|
||||
// We need the following two types as there is currently no support for Buffer in mysql and
|
||||
// so we use a custom type which results in the config beeing different
|
||||
type FixedMysqlConfig = {
|
||||
[key in keyof MysqlConfig]: {
|
||||
[column in keyof MysqlConfig[key]]: {
|
||||
[property in Exclude<keyof MysqlConfig[key][column], "dataType" | "data">]: MysqlConfig[key][column][property];
|
||||
} & {
|
||||
dataType: MysqlConfig[key][column]["data"] extends Buffer ? "buffer" : MysqlConfig[key][column]["dataType"];
|
||||
data: MysqlConfig[key][column]["data"] extends Buffer ? Buffer : MysqlConfig[key][column]["data"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type FixedSqliteConfig = {
|
||||
[key in keyof SqliteConfig]: {
|
||||
[column in keyof SqliteConfig[key]]: {
|
||||
[property in Exclude<keyof SqliteConfig[key][column], "dataType" | "data">]: SqliteConfig[key][column][property];
|
||||
} & {
|
||||
dataType: SqliteConfig[key][column]["dataType"] extends Buffer ? "buffer" : SqliteConfig[key][column]["dataType"];
|
||||
data: SqliteConfig[key][column]["data"] extends Buffer ? Buffer : SqliteConfig[key][column]["data"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test("schemas should match", () => {
|
||||
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
||||
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
||||
expectTypeOf<SqliteConfig>().toEqualTypeOf<MysqlConfig>();
|
||||
expectTypeOf<MysqlConfig>().toEqualTypeOf<SqliteConfig>();
|
||||
expectTypeOf<FixedSqliteConfig>().toEqualTypeOf<FixedMysqlConfig>();
|
||||
expectTypeOf<FixedMysqlConfig>().toEqualTypeOf<FixedSqliteConfig>();
|
||||
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@tabler/icons-react": "^3.20.0",
|
||||
"@tabler/icons-react": "^3.21.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.16",
|
||||
"react": "^18.3.1"
|
||||
|
||||
@@ -12,8 +12,11 @@ export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text>{t("action.copy.description")}</Text>
|
||||
{/* TODO: When next-international v2 is released the descriptions bold element can be implemented, see https://github.com/QuiiBz/next-international/pull/361 for progress */}
|
||||
<Text>
|
||||
{t.rich("action.copy.description", {
|
||||
b: (children) => <b>{children}</b>,
|
||||
})}
|
||||
</Text>
|
||||
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
|
||||
<Stack gap="xs">
|
||||
<Text fw="bold">{t("field.id.label")}:</Text>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^7.13.4",
|
||||
"@tabler/icons-react": "^3.20.0"
|
||||
"@tabler/icons-react": "^3.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
|
||||
|
||||
export type ServerSettingsRecord = {
|
||||
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
|
||||
};
|
||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||
|
||||
export const defaultServerSettings = {
|
||||
analytics: {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@mantine/hooks": "^7.13.4",
|
||||
"@mantine/spotlight": "^7.13.4",
|
||||
"@tabler/icons-react": "^3.20.0",
|
||||
"@tabler/icons-react": "^3.21.0",
|
||||
"jotai": "^2.10.1",
|
||||
"next": "^14.2.16",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { localeAttributes, supportedLanguages } from "@homarr/translation";
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
@@ -11,34 +11,34 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const currentLocale = useCurrentLocale();
|
||||
return supportedLanguages
|
||||
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
|
||||
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||
.filter(
|
||||
({ attributes }) =>
|
||||
attributes.name.toLowerCase().includes(normalizedQuery) ||
|
||||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
({ configuration }) =>
|
||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.sort(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageA.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
) -
|
||||
Math.min(
|
||||
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
languageB.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, attributes }) => ({
|
||||
.map(({ localeKey, configuration }) => ({
|
||||
key: localeKey,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<span className={`fi fi-${attributes.flagIcon}`} style={{ borderRadius: 4 }}></span>
|
||||
<span className={`fi fi-${configuration.flagIcon}`} style={{ borderRadius: 4 }}></span>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{attributes.name}</Text>
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({attributes.translatedName})
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -47,7 +47,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
const changeLocale = useChangeLocale();
|
||||
const { changeLocale } = useChangeLocale();
|
||||
|
||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconReport,
|
||||
IconSearch,
|
||||
@@ -89,6 +90,12 @@ export const pagesSearchGroup = createGroup<{
|
||||
name: t("manageSearchEngine.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
path: "/manage/medias",
|
||||
name: t("manageMedia.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUsers,
|
||||
path: "/manage/users",
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./middleware": "./src/middleware.ts"
|
||||
"./middleware": "./src/middleware.ts",
|
||||
"./request": "./src/request.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -25,9 +26,12 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next-international": "^1.2.4"
|
||||
"next": "^14.2.16",
|
||||
"next-intl": "3.24.0",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createI18nClient } from "next-international/client";
|
||||
|
||||
import { languageMapping } from "./lang";
|
||||
import enTranslation from "./lang/en";
|
||||
|
||||
export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
|
||||
languageMapping(),
|
||||
{
|
||||
fallbackLocale: enTranslation,
|
||||
},
|
||||
);
|
||||
19
packages/translation/src/client/index.ts
Normal file
19
packages/translation/src/client/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useMessages, useTranslations } from "next-intl";
|
||||
|
||||
import type { TranslationObject } from "../type";
|
||||
|
||||
export { useChangeLocale } from "./use-change-locale";
|
||||
export { useCurrentLocale } from "./use-current-locale";
|
||||
|
||||
export const { useI18n, useScopedI18n } = {
|
||||
useI18n: useTranslations,
|
||||
useScopedI18n: useTranslations,
|
||||
};
|
||||
|
||||
export const { useI18nMessages } = {
|
||||
useI18nMessages: () => useMessages() as TranslationObject,
|
||||
};
|
||||
|
||||
export { useTranslations };
|
||||
25
packages/translation/src/client/use-change-locale.ts
Normal file
25
packages/translation/src/client/use-change-locale.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useTransition } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import type { SupportedLanguage } from "../config";
|
||||
import { useCurrentLocale } from "./use-current-locale";
|
||||
|
||||
export const useChangeLocale = () => {
|
||||
const currentLocale = useCurrentLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return {
|
||||
changeLocale: (newLocale: SupportedLanguage) => {
|
||||
if (newLocale === currentLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(`/${newLocale}/${pathname}`);
|
||||
});
|
||||
},
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
5
packages/translation/src/client/use-current-locale.ts
Normal file
5
packages/translation/src/client/use-current-locale.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
import type { SupportedLanguage } from "../config";
|
||||
|
||||
export const useCurrentLocale = () => useLocale() as SupportedLanguage;
|
||||
26
packages/translation/src/config.ts
Normal file
26
packages/translation/src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
export const localeConfigurations = {
|
||||
de: {
|
||||
name: "Deutsch",
|
||||
translatedName: "German",
|
||||
flagIcon: "de",
|
||||
},
|
||||
en: {
|
||||
name: "English",
|
||||
translatedName: "English",
|
||||
flagIcon: "us",
|
||||
},
|
||||
} satisfies Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
translatedName: string;
|
||||
flagIcon: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const supportedLanguages = objectKeys(localeConfigurations);
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const defaultLocale = "en" satisfies SupportedLanguage;
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { supportedLanguages } from "./config";
|
||||
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||
|
||||
export * from "./type";
|
||||
export * from "./locale-attributes";
|
||||
|
||||
export const supportedLanguages = ["en", "de"] as const;
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const defaultLocale = "en";
|
||||
export { languageMapping } from "./lang";
|
||||
export type { TranslationKeys } from "./lang";
|
||||
export * from "./config";
|
||||
export { createLanguageMapping } from "./mapping";
|
||||
export type { TranslationKeys } from "./mapping";
|
||||
|
||||
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
|
||||
if (typeof value === "function") {
|
||||
@@ -16,3 +13,7 @@ export const translateIfNecessary = (t: TranslationFunction, value: stringOrTran
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const isLocaleSupported = (locale: string): locale is SupportedLanguage => {
|
||||
return supportedLanguages.includes(locale as SupportedLanguage);
|
||||
};
|
||||
|
||||
@@ -552,6 +552,44 @@ export default {
|
||||
full: "Full integration access",
|
||||
},
|
||||
},
|
||||
media: {
|
||||
plural: "Medias",
|
||||
search: "Find a media",
|
||||
field: {
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
creator: "Creator",
|
||||
},
|
||||
action: {
|
||||
upload: {
|
||||
label: "Upload media",
|
||||
file: "Select file",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The media was successfully uploaded",
|
||||
},
|
||||
error: {
|
||||
message: "The media could not be uploaded",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete media",
|
||||
description: "Are you sure you want to delete the media <bName></bName>?",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The media was successfully deleted",
|
||||
},
|
||||
error: {
|
||||
message: "The media could not be deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
copy: {
|
||||
label: "Copy URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
// Either "ltr" or "rtl"
|
||||
direction: "ltr",
|
||||
@@ -668,7 +706,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
mantineReactTable: MRT_Localization_EN,
|
||||
mantineReactTable: MRT_Localization_EN as Readonly<Record<keyof typeof MRT_Localization_EN, string>>,
|
||||
},
|
||||
section: {
|
||||
dynamic: {
|
||||
@@ -1167,11 +1205,11 @@ export default {
|
||||
},
|
||||
integration: {
|
||||
noData: "No integration found",
|
||||
description: "Click {here} to create a new integration",
|
||||
description: "Click <here></here> to create a new integration",
|
||||
},
|
||||
app: {
|
||||
noData: "No app found",
|
||||
description: "Click {here} to create a new app",
|
||||
description: "Click <here></here> to create a new app",
|
||||
},
|
||||
error: {
|
||||
action: {
|
||||
@@ -1644,6 +1682,7 @@ export default {
|
||||
apps: "Apps",
|
||||
integrations: "Integrations",
|
||||
searchEngies: "Search engines",
|
||||
medias: "Medias",
|
||||
users: {
|
||||
label: "Users",
|
||||
items: {
|
||||
@@ -1732,6 +1771,9 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
media: {
|
||||
includeFromAllUsers: "Include media from all users",
|
||||
},
|
||||
user: {
|
||||
back: "Back to users",
|
||||
fieldsDisabledExternalProvider:
|
||||
@@ -1800,7 +1842,7 @@ export default {
|
||||
copy: {
|
||||
title: "Copy invite",
|
||||
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.",
|
||||
"Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore.</b> If you do no longer wish to invite said person, you can delete this invitation any time.",
|
||||
link: "Invitation link",
|
||||
button: "Copy & close",
|
||||
},
|
||||
@@ -2138,6 +2180,9 @@ export default {
|
||||
label: "Edit",
|
||||
},
|
||||
},
|
||||
medias: {
|
||||
label: "Medias",
|
||||
},
|
||||
apps: {
|
||||
label: "Apps",
|
||||
new: {
|
||||
@@ -2359,6 +2404,9 @@ export default {
|
||||
manageSearchEngine: {
|
||||
label: "Manage search engines",
|
||||
},
|
||||
manageMedia: {
|
||||
label: "Manage medias",
|
||||
},
|
||||
manageUser: {
|
||||
label: "Manage users",
|
||||
},
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { SupportedLanguage } from ".";
|
||||
|
||||
export const localeAttributes: Record<
|
||||
SupportedLanguage,
|
||||
{
|
||||
name: string;
|
||||
translatedName: string;
|
||||
flagIcon: string;
|
||||
}
|
||||
> = {
|
||||
de: {
|
||||
name: "Deutsch",
|
||||
translatedName: "German",
|
||||
flagIcon: "de",
|
||||
},
|
||||
en: {
|
||||
name: "English",
|
||||
translatedName: "English",
|
||||
flagIcon: "us",
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { supportedLanguages } from ".";
|
||||
import { supportedLanguages } from "./config";
|
||||
|
||||
const _enTranslations = () => import("./lang/en");
|
||||
type EnTranslation = typeof _enTranslations;
|
||||
|
||||
export const languageMapping = () => {
|
||||
export const createLanguageMapping = () => {
|
||||
const mapping: Record<string, unknown> = {};
|
||||
|
||||
for (const language of supportedLanguages) {
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createI18nMiddleware } from "next-international/middleware";
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
|
||||
import { defaultLocale, supportedLanguages } from ".";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const I18nMiddleware = createI18nMiddleware({
|
||||
locales: supportedLanguages,
|
||||
defaultLocale,
|
||||
urlMappingStrategy: "rewrite",
|
||||
});
|
||||
export const I18nMiddleware = createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ["/", "/(de|en)/:path*"],
|
||||
};
|
||||
|
||||
34
packages/translation/src/request.ts
Normal file
34
packages/translation/src/request.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import deepmerge from "deepmerge";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
import { isLocaleSupported } from ".";
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { createLanguageMapping } from "./mapping";
|
||||
import { routing } from "./routing";
|
||||
|
||||
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let currentLocale = await requestLocale;
|
||||
|
||||
if (!currentLocale || !isLocaleSupported(currentLocale)) {
|
||||
currentLocale = routing.defaultLocale;
|
||||
}
|
||||
const typedLocale = currentLocale as SupportedLanguage;
|
||||
|
||||
const languageMap = createLanguageMapping();
|
||||
const currentMessages = (await languageMap[typedLocale]()).default;
|
||||
|
||||
// Fallback to default locale if the current locales messages if not all messages are present
|
||||
if (currentLocale !== routing.defaultLocale) {
|
||||
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
|
||||
return {
|
||||
locale: currentLocale,
|
||||
messages: deepmerge(fallbackMessages, currentMessages),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
locale: currentLocale,
|
||||
messages: currentMessages,
|
||||
};
|
||||
});
|
||||
11
packages/translation/src/routing.ts
Normal file
11
packages/translation/src/routing.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
import { defaultLocale, supportedLanguages } from "./config";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: supportedLanguages,
|
||||
defaultLocale,
|
||||
localePrefix: {
|
||||
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createI18nServer } from "next-international/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { languageMapping } from "./lang";
|
||||
import enTranslation from "./lang/en";
|
||||
export const { getI18n, getScopedI18n } = {
|
||||
getI18n: getTranslations,
|
||||
getScopedI18n: getTranslations,
|
||||
};
|
||||
|
||||
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(languageMapping(), {
|
||||
fallbackLocale: enTranslation,
|
||||
});
|
||||
export { getMessages as getI18nMessages } from "next-intl/server";
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { NamespaceKeys, NestedKeyOf } from "next-intl";
|
||||
|
||||
import type { RemoveReadonly } from "@homarr/common/types";
|
||||
|
||||
import type { useI18n, useScopedI18n } from "./client";
|
||||
import type enTranslation from "./lang/en";
|
||||
|
||||
export type TranslationFunction = ReturnType<typeof useI18n>;
|
||||
export type ScopedTranslationFunction<T extends Parameters<typeof useScopedI18n>[0]> = ReturnType<
|
||||
typeof useScopedI18n<T>
|
||||
>;
|
||||
export type TranslationFunction = ReturnType<typeof useI18n<never>>;
|
||||
export type ScopedTranslationFunction<
|
||||
NestedKey extends NamespaceKeys<IntlMessages, NestedKeyOf<IntlMessages>> = never,
|
||||
> = ReturnType<typeof useScopedI18n<NestedKey>>;
|
||||
export type TranslationObject = typeof enTranslation;
|
||||
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
|
||||
|
||||
declare global {
|
||||
// Use type safe message keys with `next-intl`
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface IntlMessages extends RemoveReadonly<TranslationObject> {}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@mantine/dates": "^7.13.4",
|
||||
"@mantine/hooks": "^7.13.4",
|
||||
"@tabler/icons-react": "^3.20.0",
|
||||
"@tabler/icons-react": "^3.21.0",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.16",
|
||||
"react": "^18.3.1"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import type { BadgeProps } from "@mantine/core";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useTranslations } from "@homarr/translation/client";
|
||||
|
||||
interface BetaBadgeProps {
|
||||
size: BadgeProps["size"];
|
||||
}
|
||||
|
||||
export const BetaBadge = ({ size }: BetaBadgeProps) => {
|
||||
const t = useI18n();
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Badge size={size} color="green" variant="outline">
|
||||
{t("common.beta")}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
|
||||
import { useMantineReactTable } from "mantine-react-table";
|
||||
import { MRT_Localization_EN } from "mantine-react-table/locales/en/index.cjs";
|
||||
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18nMessages } from "@homarr/translation/client";
|
||||
|
||||
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
|
||||
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
|
||||
) => {
|
||||
const t = useScopedI18n("common.mantineReactTable");
|
||||
const messages = useI18nMessages();
|
||||
return useMantineReactTable<TData>({
|
||||
...tableOptions,
|
||||
localization: objectKeys(MRT_Localization_EN).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = t(key);
|
||||
return acc;
|
||||
},
|
||||
{} as typeof MRT_Localization_EN,
|
||||
),
|
||||
localization: messages.common.mantineReactTable,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import type { ParamsObject } from "international-types";
|
||||
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
|
||||
import { ZodIssueCode } from "zod";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||
|
||||
export const zodErrorMap = <
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
TFunction extends (key: string, ...params: any[]) => string,
|
||||
>(
|
||||
t: TFunction,
|
||||
) => {
|
||||
export const zodErrorMap = <TFunction extends TranslationFunction>(t: TFunction) => {
|
||||
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
const error = handleZodError(issue, ctx);
|
||||
if ("message" in error && error.message) {
|
||||
@@ -139,7 +133,7 @@ type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom
|
||||
export interface CustomErrorParams<TKey extends CustomErrorKey> {
|
||||
i18n: {
|
||||
key: TKey;
|
||||
params: ParamsObject<TranslationObject["common"]["zod"]["errors"]["custom"][TKey]>;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { groupSchemas } from "./group";
|
||||
import { iconsSchemas } from "./icons";
|
||||
import { integrationSchemas } from "./integration";
|
||||
import { locationSchemas } from "./location";
|
||||
import { mediaSchemas } from "./media";
|
||||
import { searchEngineSchemas } from "./search-engine";
|
||||
import { userSchemas } from "./user";
|
||||
import { widgetSchemas } from "./widgets";
|
||||
@@ -19,6 +20,7 @@ export const validation = {
|
||||
location: locationSchemas,
|
||||
icons: iconsSchemas,
|
||||
searchEngine: searchEngineSchemas,
|
||||
media: mediaSchemas,
|
||||
common: commonSchemas,
|
||||
};
|
||||
|
||||
@@ -32,3 +34,4 @@ export {
|
||||
type BoardItemIntegration,
|
||||
} from "./shared";
|
||||
export { passwordRequirements } from "./user";
|
||||
export { supportedMediaUploadFormats } from "./media";
|
||||
|
||||
44
packages/validation/src/media.ts
Normal file
44
packages/validation/src/media.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
export const supportedMediaUploadFormats = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"];
|
||||
|
||||
export const uploadMediaSchema = zfd.formData({
|
||||
file: zfd.file().superRefine((value: File | null, context: z.RefinementCtx) => {
|
||||
if (!value) {
|
||||
return context.addIssue({
|
||||
code: "invalid_type",
|
||||
expected: "object",
|
||||
received: "null",
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportedMediaUploadFormats.includes(value.type)) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (value.size > 1024 * 1024 * 32) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "32 MB" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
|
||||
export const mediaSchemas = {
|
||||
uploadMedia: uploadMediaSchema,
|
||||
};
|
||||
@@ -30,7 +30,10 @@ const passwordSchema = z
|
||||
return passwordRequirements.every((requirement) => requirement.check(value));
|
||||
},
|
||||
{
|
||||
params: createCustomErrorParams("passwordRequirements"),
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordRequirements",
|
||||
params: {},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -38,7 +41,10 @@ const confirmPasswordRefine = [
|
||||
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,
|
||||
{
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordsDoNotMatch",
|
||||
params: {},
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] satisfies [(args: any) => boolean, unknown];
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@mantine/hooks": "^7.13.4",
|
||||
"@tabler/icons-react": "^3.20.0",
|
||||
"@tabler/icons-react": "^3.21.0",
|
||||
"@tiptap/extension-color": "2.9.1",
|
||||
"@tiptap/extension-highlight": "2.9.1",
|
||||
"@tiptap/extension-image": "2.9.1",
|
||||
|
||||
@@ -43,8 +43,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs">
|
||||
{t("widget.common.app.description", {
|
||||
here: (
|
||||
{t.rich("widget.common.app.description", {
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export type WidgetImportRecord = {
|
||||
[K in WidgetKind]: unknown;
|
||||
};
|
||||
export type WidgetImportRecord = Record<WidgetKind, unknown>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { languageMapping } from "@homarr/translation";
|
||||
import { createLanguageMapping } from "@homarr/translation";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
|
||||
describe("Widget properties with description should have matching translations", async () => {
|
||||
const enTranslation = await languageMapping().en();
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||
Object.entries(value.definition.options).forEach(
|
||||
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
|
||||
@@ -25,7 +25,7 @@ describe("Widget properties with description should have matching translations",
|
||||
});
|
||||
|
||||
describe("Widget properties should have matching name translations", async () => {
|
||||
const enTranslation = await languageMapping().en();
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||
Object.keys(value.definition.options).forEach((optionKey) => {
|
||||
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
|
||||
|
||||
@@ -92,8 +92,8 @@ export const WidgetIntegrationSelect = ({
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs">
|
||||
{t("widget.common.integration.description", {
|
||||
here: (
|
||||
{t.rich("widget.common.integration.description", {
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
|
||||
775
pnpm-lock.yaml
generated
775
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,10 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^2.2.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"typescript-eslint": "^8.11.0"
|
||||
"typescript-eslint": "^8.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
|
||||
Reference in New Issue
Block a user