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"
|
"Umami"
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.enabledFrameworks": ["next-intl"],
|
||||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||||
"i18n-ally.enabledParsers": ["ts"],
|
"i18n-ally.enabledParsers": ["ts"],
|
||||||
"i18n-ally.extract.keyMaxLength": 0,
|
"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
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
import "@homarr/auth/env.mjs";
|
import "@homarr/auth/env.mjs";
|
||||||
|
|
||||||
import MillionLint from "@million/lint";
|
import MillionLint from "@million/lint";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
import "./src/env.mjs";
|
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} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** 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
|
// 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 });
|
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",
|
"@mantine/tiptap": "^7.13.4",
|
||||||
"@million/lint": "1.0.11",
|
"@million/lint": "1.0.11",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@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": "^5.59.16",
|
||||||
"@tanstack/react-query-devtools": "^5.59.16",
|
"@tanstack/react-query-devtools": "^5.59.16",
|
||||||
"@tanstack/react-query-next-experimental": "5.59.16",
|
"@tanstack/react-query-next-experimental": "5.59.16",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.80.4",
|
"sass": "^1.80.5",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"swagger-ui-react": "^5.17.14",
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.17.1",
|
"@types/node": "^22.8.6",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@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 "~/styles/scroll-area.scss";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
|
||||||
import { env } from "@homarr/auth/env.mjs";
|
import { env } from "@homarr/auth/env.mjs";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
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 { Analytics } from "~/components/layout/analytics";
|
||||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||||
import { composeWrappers } from "./compose";
|
import { composeWrappers } from "./compose";
|
||||||
@@ -59,10 +61,15 @@ export const viewport: Viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
|
if (!isLocaleSupported(props.params.locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const colorScheme = getColorScheme();
|
const colorScheme = getColorScheme();
|
||||||
const tCommon = await getScopedI18n("common");
|
const tCommon = await getScopedI18n("common");
|
||||||
const direction = tCommon("direction");
|
const direction = tCommon("direction");
|
||||||
|
const i18nMessages = await getI18nMessages();
|
||||||
|
|
||||||
const StackedProvider = composeWrappers([
|
const StackedProvider = composeWrappers([
|
||||||
(innerProps) => {
|
(innerProps) => {
|
||||||
@@ -70,7 +77,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
},
|
},
|
||||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||||
(innerProps) => <ModalProvider {...innerProps} />,
|
(innerProps) => <ModalProvider {...innerProps} />,
|
||||||
]);
|
]);
|
||||||
@@ -78,7 +85,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang={props.params.locale}
|
||||||
dir={direction}
|
dir={direction}
|
||||||
data-mantine-color-scheme={colorScheme}
|
data-mantine-color-scheme={colorScheme}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconLogs,
|
IconLogs,
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
|
IconPhoto,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconReport,
|
IconReport,
|
||||||
@@ -62,6 +63,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/search-engines",
|
href: "/manage/search-engines",
|
||||||
label: t("items.searchEngies"),
|
label: t("items.searchEngies"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: IconPhoto,
|
||||||
|
href: "/manage/medias",
|
||||||
|
label: t("items.medias"),
|
||||||
|
hidden: !session,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: IconUser,
|
icon: IconUser,
|
||||||
label: t("items.users.label"),
|
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, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
params: createCustomErrorParams({
|
||||||
|
key: "passwordsDoNotMatch",
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const RenameGroupForm = ({ group, disabled }: RenameGroupFormProps) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[group.id, mutate, t],
|
[group.id, mutate, t, disabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const ReservedGroupAlert = async () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||||
{t("group.reservedNotice.message", {
|
{t.rich("group.reservedNotice.message", {
|
||||||
checkoutDocs: (
|
checkoutDocs: () => (
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildr
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = {
|
type FormType = Record<GroupPermissionKey, boolean>;
|
||||||
[key in GroupPermissionKey]: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
|
||||||
const form = useFormContext();
|
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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
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 { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { SupportedLanguage } from "@homarr/translation";
|
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 { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||||
|
|
||||||
import classes from "./language-combobox.module.css";
|
import classes from "./language-combobox.module.css";
|
||||||
@@ -15,7 +15,7 @@ export const LanguageCombobox = () => {
|
|||||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
});
|
});
|
||||||
const currentLocale = useCurrentLocale();
|
const currentLocale = useCurrentLocale();
|
||||||
const changeLocale = useChangeLocale();
|
const { changeLocale, isPending } = useChangeLocale();
|
||||||
|
|
||||||
const handleOnOptionSubmit = React.useCallback(
|
const handleOnOptionSubmit = React.useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -39,6 +39,7 @@ export const LanguageCombobox = () => {
|
|||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
pointer
|
pointer
|
||||||
|
leftSection={isPending ? <Loader size={16} /> : null}
|
||||||
rightSection={<Combobox.Chevron />}
|
rightSection={<Combobox.Chevron />}
|
||||||
rightSectionPointerEvents="none"
|
rightSectionPointerEvents="none"
|
||||||
onClick={handleOnClick}
|
onClick={handleOnClick}
|
||||||
@@ -72,11 +73,11 @@ const OptionItem = ({
|
|||||||
return (
|
return (
|
||||||
<Group wrap="nowrap" justify="space-between">
|
<Group wrap="nowrap" justify="space-between">
|
||||||
<Group wrap="nowrap">
|
<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">
|
<Group wrap="nowrap" gap="xs">
|
||||||
<Text>{localeAttributes[localeKey].name}</Text>
|
<Text>{localeConfigurations[localeKey].name}</Text>
|
||||||
<Text size="xs" c="dimmed" inherit>
|
<Text size="xs" c="dimmed" inherit>
|
||||||
({localeAttributes[localeKey].translatedName})
|
({localeConfigurations[localeKey].translatedName})
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const getPackageAttributesAsync = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PackageJsonDependencies = { [key in string]: string };
|
type PackageJsonDependencies = Record<string, string>;
|
||||||
interface PackageJson {
|
interface PackageJson {
|
||||||
dependencies: PackageJsonDependencies | undefined;
|
dependencies: PackageJsonDependencies | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "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",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.13.0",
|
"eslint": "^9.13.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tsx": "4.19.1",
|
"tsx": "4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"tsx": "4.19.1",
|
"tsx": "4.19.2",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -29,8 +29,8 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@turbo/gen": "^2.2.3",
|
"@turbo/gen": "^2.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"@vitest/coverage-v8": "^2.1.3",
|
"@vitest/coverage-v8": "^2.1.4",
|
||||||
"@vitest/ui": "^2.1.3",
|
"@vitest/ui": "^2.1.4",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
@@ -38,11 +38,11 @@
|
|||||||
"turbo": "^2.2.3",
|
"turbo": "^2.2.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite-tsconfig-paths": "^5.0.1",
|
"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": {
|
"engines": {
|
||||||
"node": ">=20.18.0"
|
"node": ">=22.11.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { integrationRouter } from "./router/integration/integration-router";
|
|||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
import { logRouter } from "./router/log";
|
import { logRouter } from "./router/log";
|
||||||
|
import { mediaRouter } from "./router/medias/media-router";
|
||||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||||
import { serverSettingsRouter } from "./router/serverSettings";
|
import { serverSettingsRouter } from "./router/serverSettings";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
@@ -33,6 +34,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
serverSettings: serverSettingsRouter,
|
serverSettings: serverSettingsRouter,
|
||||||
cronJobs: cronJobsRouter,
|
cronJobs: cronJobsRouter,
|
||||||
apiKeys: apiKeysRouter,
|
apiKeys: apiKeysRouter,
|
||||||
|
media: mediaRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@drizzle-team/brocli": "^0.10.1",
|
"@drizzle-team/brocli": "^0.10.2",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.16",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"tldts": "^6.1.55"
|
"tldts": "^6.1.57"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@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>>> = {
|
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];
|
[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,
|
"when": 1729369383739,
|
||||||
"tag": "0013_youthful_vulture",
|
"tag": "0013_youthful_vulture",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1729369389386,
|
||||||
"tag": "0013_faithful_hex",
|
"tag": "0013_faithful_hex",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1729524387583,
|
||||||
|
"tag": "0014_colorful_cargill",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
"@testcontainers/mysql": "^10.13.2",
|
"@testcontainers/mysql": "^10.13.2",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-kit": "^0.26.2",
|
"drizzle-kit": "^0.27.1",
|
||||||
"drizzle-orm": "^0.35.3",
|
"drizzle-orm": "^0.36.0",
|
||||||
"mysql2": "3.11.3"
|
"mysql2": "3.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.13.0",
|
"eslint": "^9.13.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tsx": "4.19.1",
|
"tsx": "4.19.2",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,18 @@ import type { AdapterAccount } from "@auth/core/adapters";
|
|||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
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 {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
@@ -20,6 +31,12 @@ import type {
|
|||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||||
|
|
||||||
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
|
dataType() {
|
||||||
|
return "BLOB";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const apiKeys = mysqlTable("apiKey", {
|
export const apiKeys = mysqlTable("apiKey", {
|
||||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||||
apiKey: text("apiKey").notNull(),
|
apiKey: text("apiKey").notNull(),
|
||||||
@@ -142,6 +159,16 @@ export const invites = mysqlTable("invite", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.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(
|
export const integrations = mysqlTable(
|
||||||
"integration",
|
"integration",
|
||||||
{
|
{
|
||||||
@@ -387,6 +414,13 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
invites: many(invites),
|
invites: many(invites),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const mediaRelations = relations(medias, ({ one }) => ({
|
||||||
|
creator: one(users, {
|
||||||
|
fields: [medias.creatorId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const iconRelations = relations(icons, ({ one }) => ({
|
export const iconRelations = relations(icons, ({ one }) => ({
|
||||||
repository: one(iconRepositories, {
|
repository: one(iconRepositories, {
|
||||||
fields: [icons.iconRepositoryId],
|
fields: [icons.iconRepositoryId],
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AdapterAccount } from "@auth/core/adapters";
|
import type { AdapterAccount } from "@auth/core/adapters";
|
||||||
import type { DayOfWeek } from "@mantine/dates";
|
import type { DayOfWeek } from "@mantine/dates";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
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 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 { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
@@ -145,6 +145,18 @@ export const invites = sqliteTable("invite", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.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(
|
export const integrations = sqliteTable(
|
||||||
"integration",
|
"integration",
|
||||||
{
|
{
|
||||||
@@ -387,6 +399,14 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
groups: many(groupMembers),
|
groups: many(groupMembers),
|
||||||
ownedGroups: many(groups),
|
ownedGroups: many(groups),
|
||||||
invites: many(invites),
|
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 }) => ({
|
export const iconRelations = relations(icons, ({ one }) => ({
|
||||||
|
|||||||
@@ -9,11 +9,35 @@ import { objectEntries } from "@homarr/common";
|
|||||||
import * as mysqlSchema from "../schema/mysql";
|
import * as mysqlSchema from "../schema/mysql";
|
||||||
import * as sqliteSchema from "../schema/sqlite";
|
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", () => {
|
test("schemas should match", () => {
|
||||||
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
||||||
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
||||||
expectTypeOf<SqliteConfig>().toEqualTypeOf<MysqlConfig>();
|
expectTypeOf<FixedSqliteConfig>().toEqualTypeOf<FixedMysqlConfig>();
|
||||||
expectTypeOf<MysqlConfig>().toEqualTypeOf<SqliteConfig>();
|
expectTypeOf<FixedMysqlConfig>().toEqualTypeOf<FixedSqliteConfig>();
|
||||||
|
|
||||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.4",
|
||||||
"@tabler/icons-react": "^3.20.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.16",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text>{t("action.copy.description")}</Text>
|
<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 */}
|
{t.rich("action.copy.description", {
|
||||||
|
b: (children) => <b>{children}</b>,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
|
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw="bold">{t("field.id.label")}:</Text>
|
<Text fw="bold">{t("field.id.label")}:</Text>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^7.13.4",
|
"@mantine/notifications": "^7.13.4",
|
||||||
"@tabler/icons-react": "^3.20.0"
|
"@tabler/icons-react": "^3.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
|
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
|
||||||
|
|
||||||
export type ServerSettingsRecord = {
|
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||||
[key in (typeof defaultServerSettingsKeys)[number]]: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultServerSettings = {
|
export const defaultServerSettings = {
|
||||||
analytics: {
|
analytics: {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.4",
|
||||||
"@mantine/hooks": "^7.13.4",
|
"@mantine/hooks": "^7.13.4",
|
||||||
"@mantine/spotlight": "^7.13.4",
|
"@mantine/spotlight": "^7.13.4",
|
||||||
"@tabler/icons-react": "^3.20.0",
|
"@tabler/icons-react": "^3.21.0",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.16",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Group, Stack, Text } from "@mantine/core";
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
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 { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { createChildrenOptions } from "../../../lib/children";
|
import { createChildrenOptions } from "../../../lib/children";
|
||||||
@@ -11,34 +11,34 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const currentLocale = useCurrentLocale();
|
const currentLocale = useCurrentLocale();
|
||||||
return supportedLanguages
|
return supportedLanguages
|
||||||
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
|
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||||
.filter(
|
.filter(
|
||||||
({ attributes }) =>
|
({ configuration }) =>
|
||||||
attributes.name.toLowerCase().includes(normalizedQuery) ||
|
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
|
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
(languageA, languageB) =>
|
(languageA, languageB) =>
|
||||||
Math.min(
|
Math.min(
|
||||||
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||||
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
languageA.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||||
) -
|
) -
|
||||||
Math.min(
|
Math.min(
|
||||||
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
|
languageB.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||||
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
|
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.map(({ localeKey, attributes }) => ({
|
.map(({ localeKey, configuration }) => ({
|
||||||
key: localeKey,
|
key: localeKey,
|
||||||
Component() {
|
Component() {
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||||
<Group wrap="nowrap">
|
<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">
|
<Group wrap="nowrap" gap="xs">
|
||||||
<Text>{attributes.name}</Text>
|
<Text>{configuration.name}</Text>
|
||||||
<Text size="xs" c="dimmed" inherit>
|
<Text size="xs" c="dimmed" inherit>
|
||||||
({attributes.translatedName})
|
({configuration.translatedName})
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -47,7 +47,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
useInteraction() {
|
useInteraction() {
|
||||||
const changeLocale = useChangeLocale();
|
const { changeLocale } = useChangeLocale();
|
||||||
|
|
||||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconLogs,
|
IconLogs,
|
||||||
IconMailForward,
|
IconMailForward,
|
||||||
|
IconPhoto,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconReport,
|
IconReport,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -89,6 +90,12 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
name: t("manageSearchEngine.label"),
|
name: t("manageSearchEngine.label"),
|
||||||
hidden: !session,
|
hidden: !session,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: IconPhoto,
|
||||||
|
path: "/manage/medias",
|
||||||
|
name: t("manageMedia.label"),
|
||||||
|
hidden: !session,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: IconUsers,
|
icon: IconUsers,
|
||||||
path: "/manage/users",
|
path: "/manage/users",
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client/index.ts",
|
||||||
"./server": "./src/server.ts",
|
"./server": "./src/server.ts",
|
||||||
"./middleware": "./src/middleware.ts"
|
"./middleware": "./src/middleware.ts",
|
||||||
|
"./request": "./src/request.ts"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -25,9 +26,12 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.7",
|
"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": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@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";
|
import type { stringOrTranslation, TranslationFunction } from "./type";
|
||||||
|
|
||||||
export * from "./type";
|
export * from "./type";
|
||||||
export * from "./locale-attributes";
|
export * from "./config";
|
||||||
|
export { createLanguageMapping } from "./mapping";
|
||||||
export const supportedLanguages = ["en", "de"] as const;
|
export type { TranslationKeys } from "./mapping";
|
||||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
|
||||||
|
|
||||||
export const defaultLocale = "en";
|
|
||||||
export { languageMapping } from "./lang";
|
|
||||||
export type { TranslationKeys } from "./lang";
|
|
||||||
|
|
||||||
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
|
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
|
||||||
if (typeof value === "function") {
|
if (typeof value === "function") {
|
||||||
@@ -16,3 +13,7 @@ export const translateIfNecessary = (t: TranslationFunction, value: stringOrTran
|
|||||||
}
|
}
|
||||||
return value;
|
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",
|
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: {
|
common: {
|
||||||
// Either "ltr" or "rtl"
|
// Either "ltr" or "rtl"
|
||||||
direction: "ltr",
|
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: {
|
section: {
|
||||||
dynamic: {
|
dynamic: {
|
||||||
@@ -1167,11 +1205,11 @@ export default {
|
|||||||
},
|
},
|
||||||
integration: {
|
integration: {
|
||||||
noData: "No integration found",
|
noData: "No integration found",
|
||||||
description: "Click {here} to create a new integration",
|
description: "Click <here></here> to create a new integration",
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
noData: "No app found",
|
noData: "No app found",
|
||||||
description: "Click {here} to create a new app",
|
description: "Click <here></here> to create a new app",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
action: {
|
action: {
|
||||||
@@ -1644,6 +1682,7 @@ export default {
|
|||||||
apps: "Apps",
|
apps: "Apps",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
searchEngies: "Search engines",
|
searchEngies: "Search engines",
|
||||||
|
medias: "Medias",
|
||||||
users: {
|
users: {
|
||||||
label: "Users",
|
label: "Users",
|
||||||
items: {
|
items: {
|
||||||
@@ -1732,6 +1771,9 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
includeFromAllUsers: "Include media from all users",
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
back: "Back to users",
|
back: "Back to users",
|
||||||
fieldsDisabledExternalProvider:
|
fieldsDisabledExternalProvider:
|
||||||
@@ -1800,7 +1842,7 @@ export default {
|
|||||||
copy: {
|
copy: {
|
||||||
title: "Copy invite",
|
title: "Copy invite",
|
||||||
description:
|
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",
|
link: "Invitation link",
|
||||||
button: "Copy & close",
|
button: "Copy & close",
|
||||||
},
|
},
|
||||||
@@ -2138,6 +2180,9 @@ export default {
|
|||||||
label: "Edit",
|
label: "Edit",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
medias: {
|
||||||
|
label: "Medias",
|
||||||
|
},
|
||||||
apps: {
|
apps: {
|
||||||
label: "Apps",
|
label: "Apps",
|
||||||
new: {
|
new: {
|
||||||
@@ -2359,6 +2404,9 @@ export default {
|
|||||||
manageSearchEngine: {
|
manageSearchEngine: {
|
||||||
label: "Manage search engines",
|
label: "Manage search engines",
|
||||||
},
|
},
|
||||||
|
manageMedia: {
|
||||||
|
label: "Manage medias",
|
||||||
|
},
|
||||||
manageUser: {
|
manageUser: {
|
||||||
label: "Manage users",
|
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");
|
const _enTranslations = () => import("./lang/en");
|
||||||
type EnTranslation = typeof _enTranslations;
|
type EnTranslation = typeof _enTranslations;
|
||||||
|
|
||||||
export const languageMapping = () => {
|
export const createLanguageMapping = () => {
|
||||||
const mapping: Record<string, unknown> = {};
|
const mapping: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const language of supportedLanguages) {
|
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({
|
export const I18nMiddleware = createMiddleware(routing);
|
||||||
locales: supportedLanguages,
|
|
||||||
defaultLocale,
|
export const config = {
|
||||||
urlMappingStrategy: "rewrite",
|
// 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";
|
export const { getI18n, getScopedI18n } = {
|
||||||
import enTranslation from "./lang/en";
|
getI18n: getTranslations,
|
||||||
|
getScopedI18n: getTranslations,
|
||||||
|
};
|
||||||
|
|
||||||
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(languageMapping(), {
|
export { getMessages as getI18nMessages } from "next-intl/server";
|
||||||
fallbackLocale: enTranslation,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 { useI18n, useScopedI18n } from "./client";
|
||||||
import type enTranslation from "./lang/en";
|
import type enTranslation from "./lang/en";
|
||||||
|
|
||||||
export type TranslationFunction = ReturnType<typeof useI18n>;
|
export type TranslationFunction = ReturnType<typeof useI18n<never>>;
|
||||||
export type ScopedTranslationFunction<T extends Parameters<typeof useScopedI18n>[0]> = ReturnType<
|
export type ScopedTranslationFunction<
|
||||||
typeof useScopedI18n<T>
|
NestedKey extends NamespaceKeys<IntlMessages, NestedKeyOf<IntlMessages>> = never,
|
||||||
>;
|
> = ReturnType<typeof useScopedI18n<NestedKey>>;
|
||||||
export type TranslationObject = typeof enTranslation;
|
export type TranslationObject = typeof enTranslation;
|
||||||
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
|
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/core": "^7.13.4",
|
||||||
"@mantine/dates": "^7.13.4",
|
"@mantine/dates": "^7.13.4",
|
||||||
"@mantine/hooks": "^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",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.16",
|
"next": "^14.2.16",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { BadgeProps } from "@mantine/core";
|
import type { BadgeProps } from "@mantine/core";
|
||||||
import { Badge } from "@mantine/core";
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useTranslations } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface BetaBadgeProps {
|
interface BetaBadgeProps {
|
||||||
size: BadgeProps["size"];
|
size: BadgeProps["size"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BetaBadge = ({ size }: BetaBadgeProps) => {
|
export const BetaBadge = ({ size }: BetaBadgeProps) => {
|
||||||
const t = useI18n();
|
const t = useTranslations();
|
||||||
return (
|
return (
|
||||||
<Badge size={size} color="green" variant="outline">
|
<Badge size={size} color="green" variant="outline">
|
||||||
{t("common.beta")}
|
{t("common.beta")}
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
|
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
|
||||||
import { useMantineReactTable } 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 { useI18nMessages } from "@homarr/translation/client";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
|
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
|
||||||
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
|
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
|
||||||
) => {
|
) => {
|
||||||
const t = useScopedI18n("common.mantineReactTable");
|
const messages = useI18nMessages();
|
||||||
return useMantineReactTable<TData>({
|
return useMantineReactTable<TData>({
|
||||||
...tableOptions,
|
...tableOptions,
|
||||||
localization: objectKeys(MRT_Localization_EN).reduce(
|
localization: messages.common.mantineReactTable,
|
||||||
(acc, key) => {
|
|
||||||
acc[key] = t(key);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as typeof MRT_Localization_EN,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import type { ParamsObject } from "international-types";
|
|
||||||
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
|
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
|
||||||
import { ZodIssueCode } from "zod";
|
import { ZodIssueCode } from "zod";
|
||||||
|
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||||
|
|
||||||
export const zodErrorMap = <
|
export const zodErrorMap = <TFunction extends TranslationFunction>(t: TFunction) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
TFunction extends (key: string, ...params: any[]) => string,
|
|
||||||
>(
|
|
||||||
t: TFunction,
|
|
||||||
) => {
|
|
||||||
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||||
const error = handleZodError(issue, ctx);
|
const error = handleZodError(issue, ctx);
|
||||||
if ("message" in error && error.message) {
|
if ("message" in error && error.message) {
|
||||||
@@ -139,7 +133,7 @@ type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom
|
|||||||
export interface CustomErrorParams<TKey extends CustomErrorKey> {
|
export interface CustomErrorParams<TKey extends CustomErrorKey> {
|
||||||
i18n: {
|
i18n: {
|
||||||
key: TKey;
|
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 { iconsSchemas } from "./icons";
|
||||||
import { integrationSchemas } from "./integration";
|
import { integrationSchemas } from "./integration";
|
||||||
import { locationSchemas } from "./location";
|
import { locationSchemas } from "./location";
|
||||||
|
import { mediaSchemas } from "./media";
|
||||||
import { searchEngineSchemas } from "./search-engine";
|
import { searchEngineSchemas } from "./search-engine";
|
||||||
import { userSchemas } from "./user";
|
import { userSchemas } from "./user";
|
||||||
import { widgetSchemas } from "./widgets";
|
import { widgetSchemas } from "./widgets";
|
||||||
@@ -19,6 +20,7 @@ export const validation = {
|
|||||||
location: locationSchemas,
|
location: locationSchemas,
|
||||||
icons: iconsSchemas,
|
icons: iconsSchemas,
|
||||||
searchEngine: searchEngineSchemas,
|
searchEngine: searchEngineSchemas,
|
||||||
|
media: mediaSchemas,
|
||||||
common: commonSchemas,
|
common: commonSchemas,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,3 +34,4 @@ export {
|
|||||||
type BoardItemIntegration,
|
type BoardItemIntegration,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
export { passwordRequirements } from "./user";
|
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));
|
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,
|
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,
|
||||||
{
|
{
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
params: createCustomErrorParams({
|
||||||
|
key: "passwordsDoNotMatch",
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
] satisfies [(args: any) => boolean, unknown];
|
] satisfies [(args: any) => boolean, unknown];
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.4",
|
"@mantine/core": "^7.13.4",
|
||||||
"@mantine/hooks": "^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-color": "2.9.1",
|
||||||
"@tiptap/extension-highlight": "2.9.1",
|
"@tiptap/extension-highlight": "2.9.1",
|
||||||
"@tiptap/extension-image": "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"]}
|
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||||
description={
|
description={
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{t("widget.common.app.description", {
|
{t.rich("widget.common.app.description", {
|
||||||
here: (
|
here: () => (
|
||||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||||
{t("common.here")}
|
{t("common.here")}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
|
||||||
export type WidgetImportRecord = {
|
export type WidgetImportRecord = Record<WidgetKind, unknown>;
|
||||||
[K in WidgetKind]: unknown;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import { languageMapping } from "@homarr/translation";
|
import { createLanguageMapping } from "@homarr/translation";
|
||||||
|
|
||||||
import { widgetImports } from "..";
|
import { widgetImports } from "..";
|
||||||
|
|
||||||
describe("Widget properties with description should have matching translations", async () => {
|
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]) => {
|
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||||
Object.entries(value.definition.options).forEach(
|
Object.entries(value.definition.options).forEach(
|
||||||
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
|
([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 () => {
|
describe("Widget properties should have matching name translations", async () => {
|
||||||
const enTranslation = await languageMapping().en();
|
const enTranslation = await createLanguageMapping().en();
|
||||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||||
Object.keys(value.definition.options).forEach((optionKey) => {
|
Object.keys(value.definition.options).forEach((optionKey) => {
|
||||||
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
|
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export const WidgetIntegrationSelect = ({
|
|||||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||||
description={
|
description={
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
{t("widget.common.integration.description", {
|
{t.rich("widget.common.integration.description", {
|
||||||
here: (
|
here: () => (
|
||||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||||
{t("common.here")}
|
{t("common.here")}
|
||||||
</Anchor>
|
</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-prettier": "^9.1.0",
|
||||||
"eslint-config-turbo": "^2.2.3",
|
"eslint-config-turbo": "^2.2.3",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"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": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"typescript-eslint": "^8.11.0"
|
"typescript-eslint": "^8.12.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user