chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-11-01 19:13:45 +00:00
committed by GitHub
75 changed files with 4472 additions and 530 deletions

2
.nvmrc
View File

@@ -1 +1 @@
20.18.0
22.11.0

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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",

View File

@@ -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>
);
};

View File

@@ -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={{

View File

@@ -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"),

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -60,7 +60,10 @@ export const UserCreateStepperComponent = () => {
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
params: createCustomErrorParams("passwordsDoNotMatch"),
params: createCustomErrorParams({
key: "passwordsDoNotMatch",
params: {},
}),
}),
{
initialValues: {

View File

@@ -58,7 +58,7 @@ export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
},
);
},
[group.id, mutate, t],
[group.id, mutate, t, disabled],
);
return (

View File

@@ -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}

View File

@@ -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();

View 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,
});
}

View File

@@ -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>

View File

@@ -29,7 +29,7 @@ export const getPackageAttributesAsync = async () => {
};
};
type PackageJsonDependencies = { [key in string]: string };
type PackageJsonDependencies = Record<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
}

View File

@@ -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"
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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

View 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));
}),
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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];
};

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View 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
);

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1729369389386,
"tag": "0013_faithful_hex",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1729524387583,
"tag": "0014_colorful_cargill",
"breakpoints": true
}
]
}

View File

@@ -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"
}
}

View File

@@ -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],

View File

@@ -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 }) => ({

View File

@@ -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]) => {

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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>

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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) };
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
},
);

View 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 };

View 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,
};
};

View File

@@ -0,0 +1,5 @@
import { useLocale } from "next-intl";
import type { SupportedLanguage } from "../config";
export const useCurrentLocale = () => useLocale() as SupportedLanguage;

View 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;

View File

@@ -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);
};

View File

@@ -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",
},

View File

@@ -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",
},
};

View File

@@ -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) {

View File

@@ -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*"],
};

View 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,
};
});

View 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
},
});

View File

@@ -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";

View File

@@ -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> {}
}

View File

@@ -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"

View File

@@ -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")}

View File

@@ -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,
});
};

View File

@@ -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>;
};
}

View File

@@ -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";

View 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,
};

View File

@@ -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];

View File

@@ -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",

View File

@@ -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>

View File

@@ -1,5 +1,3 @@
import type { WidgetKind } from "@homarr/definitions";
export type WidgetImportRecord = {
[K in WidgetKind]: unknown;
};
export type WidgetImportRecord = Record<WidgetKind, unknown>;

View File

@@ -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`, () => {

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",