chore: reconcile performance layer after stack rebase
This commit is contained in:
1 parent
1088f058e3
commit
c92ca2af5f
235 files changed
+18492
-15756
No files matched your search
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardAddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: { id: "new", name: "" },
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{ title: (translate) => translate("section.category.create.title") },
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal()}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -20,6 +21,7 @@ export default function BoardEditActions() {
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
@@ -28,7 +30,7 @@ const AddMenu = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal();
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
|
||||
import { Alert, Input } from "@mantine/core";
|
||||
import { highlight, languages } from "prismjs";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
@@ -8,65 +8,44 @@ import "~/styles/prismjs.scss";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./customcss.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
}
|
||||
|
||||
export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
export const CustomCssSettingsContent = ({ form }: Props) => {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const t = useI18n();
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
customCss: board.customCss ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
savePartialSettings({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
<SectionCard title={t("board.setting.section.customCss.title")}>
|
||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
||||
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
{isAdmin ? <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} /> : <span />}
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
{isAdmin && <WorkshopCssImportButton onImport={(css) => form.setFieldValue("customCss", css)} />}
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomCssInputProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
||||
const customCssT = useScopedI18n("board.field.customCss");
|
||||
|
||||
return (
|
||||
@@ -76,6 +55,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
|
||||
htmlFor: "custom-css",
|
||||
}}
|
||||
description={customCssT("description")}
|
||||
error={error}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.codeEditorRoot}>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dynamicTile {
|
||||
.containerTile {
|
||||
border-style: dashed;
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
|
||||
|
||||
const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const };
|
||||
const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const };
|
||||
const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const };
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const custom = {
|
||||
id: "tablet",
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("layout representative widths", () => {
|
||||
test("uses a phone width, activation midpoint, and desktop minimum", () => {
|
||||
@@ -22,30 +46,30 @@ describe("layout representative widths", () => {
|
||||
describe("projectBoardLayout", () => {
|
||||
test("returns exact saved positions without recursively re-projecting the active layout", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, dynamicSection]).build();
|
||||
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
|
||||
|
||||
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
|
||||
expect.objectContaining({ id: dynamicSection.id, sectionId: rootSection.id, width: 3, height: 2 }),
|
||||
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses production ordering and sizing for items and dynamic sections", () => {
|
||||
test("uses production ordering and sizing for items and containers", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
|
||||
.build();
|
||||
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSections([rootSection, dynamicSection])
|
||||
.addSections([rootSection, container])
|
||||
.addItems([rootItem, nestedItem])
|
||||
.build();
|
||||
|
||||
@@ -59,7 +83,7 @@ describe("projectBoardLayout", () => {
|
||||
yOffset: 0,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({
|
||||
expect(projected.find((element) => element.id === container.id)).toMatchObject({
|
||||
type: "section",
|
||||
width: 3,
|
||||
xOffset: 0,
|
||||
@@ -69,7 +93,7 @@ describe("projectBoardLayout", () => {
|
||||
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
sectionId: dynamicSection.id,
|
||||
sectionId: container.id,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board } from "./_types";
|
||||
|
||||
export type BoardLayout = Board["layouts"][number];
|
||||
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
|
||||
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
|
||||
if (layout.role === "mobile") return 390;
|
||||
@@ -17,28 +20,69 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo
|
||||
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
|
||||
};
|
||||
|
||||
export const projectBoardLayout = (
|
||||
board: Board,
|
||||
sourceLayout: Pick<BoardLayout, "id" | "columnCount">,
|
||||
targetLayout: Pick<BoardLayout, "id" | "columnCount">,
|
||||
) => {
|
||||
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
|
||||
if (sourceLayout.id === targetLayout.id && sourceLayout.columnCount === targetLayout.columnCount) {
|
||||
if (
|
||||
sourceLayout.id === targetLayout.id &&
|
||||
sourceLayout.columnCount === targetLayout.columnCount &&
|
||||
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
|
||||
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
return board.sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.flatMap(
|
||||
(section) =>
|
||||
generateResponsiveGridFor({
|
||||
items: elements,
|
||||
previousWidth: sourceLayout.columnCount,
|
||||
width: targetLayout.columnCount,
|
||||
sectionId: section.id,
|
||||
}).items,
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
|
||||
}
|
||||
|
||||
const mainRoot = rootByLane.get("main");
|
||||
if (!mainRoot) return [];
|
||||
|
||||
const sourceRootById = new Map(
|
||||
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
|
||||
);
|
||||
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
|
||||
boardLanes.map((lane) => [
|
||||
lane,
|
||||
lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane,
|
||||
]),
|
||||
);
|
||||
const remappedElements = elements.map((element) => {
|
||||
const sourceLane = sourceRootById.get(element.sectionId);
|
||||
if (!sourceLane) return element;
|
||||
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
|
||||
return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id };
|
||||
});
|
||||
|
||||
return boardLanes.flatMap((lane) => {
|
||||
const root = rootByLane.get(lane);
|
||||
const width = getBoardLaneColumnCount(targetLayout, lane);
|
||||
if (!root || width === 0) return [];
|
||||
|
||||
const sourceLanes = boardLanes.filter(
|
||||
(sourceLane) =>
|
||||
rootByLane.has(sourceLane) &&
|
||||
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
|
||||
targetLaneBySourceLane.get(sourceLane) === lane,
|
||||
);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return generateResponsiveGridFor({
|
||||
items: remappedElements,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId: root.id,
|
||||
}).items;
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
|
||||
@@ -59,7 +103,7 @@ const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId
|
||||
? [
|
||||
|
||||
@@ -11,10 +11,18 @@ import type { Board } from "./_types";
|
||||
|
||||
interface Props {
|
||||
initialBoard: Board;
|
||||
initialLayoutId: string;
|
||||
initialViewportWidth: number;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren<Props>) => {
|
||||
export const BoardProviders = ({
|
||||
children,
|
||||
initialBoard,
|
||||
initialLayoutId,
|
||||
initialViewportWidth,
|
||||
canModify,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const isDesktop = useMediaQuery("(min-width: 64em)");
|
||||
const requestedLayoutId = searchParams.get("layout");
|
||||
@@ -23,7 +31,12 @@ export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithC
|
||||
const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard} layoutOverrideId={layoutOverrideId}>
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
layoutOverrideId={layoutOverrideId}
|
||||
>
|
||||
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,10 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env as authEnv } from "@homarr/auth/env";
|
||||
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { SettingsProvider } from "@homarr/settings";
|
||||
@@ -48,6 +49,8 @@ const fontSans = Inter({
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const logger = createLogger({ module: "rootLayout" });
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
@@ -83,25 +86,36 @@ export default async function Layout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: SupportedLanguage }>;
|
||||
}) {
|
||||
if (!isLocaleSupported((await props.params).locale)) {
|
||||
const { locale } = await props.params;
|
||||
if (!isLocaleSupported(locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
// Resolved on the server so the assistant runtime chunk is never referenced by a page unless the
|
||||
// assistant is actually usable. Reuses the availability check the management page invalidates.
|
||||
// The states stay distinct so a signed-out visitor is told to sign in rather than that the
|
||||
// instance is unconfigured, which the server cannot know for them.
|
||||
const assistantAvailability: AssistantAvailability = !session
|
||||
? "unauthenticated"
|
||||
: await api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? ("enabled" as const) : ("unconfigured" as const)))
|
||||
.catch(() => "error" as const);
|
||||
const colorScheme = await getCurrentColorSchemeAsync();
|
||||
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
|
||||
const sessionPromise = auth();
|
||||
const userPromise = sessionPromise.then((session) =>
|
||||
session
|
||||
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
|
||||
logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error }));
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
|
||||
!session
|
||||
? "unauthenticated"
|
||||
: api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error"),
|
||||
);
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
userPromise,
|
||||
getRscServerSettingsAsync(),
|
||||
getCurrentColorSchemeAsync(),
|
||||
assistantAvailabilityPromise,
|
||||
]);
|
||||
const direction = isLocaleRTL(locale) ? "rtl" : "ltr";
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL,
|
||||
@@ -146,8 +160,6 @@ export default async function Layout(props: {
|
||||
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
|
||||
]);
|
||||
|
||||
const { locale } = await props.params;
|
||||
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildAiPrompt } from "./_copy-ai-prompt-button";
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
required: ["name", "url", "displayType", "displayConfig"],
|
||||
};
|
||||
|
||||
describe("buildAiPrompt", () => {
|
||||
test("asks Homarr Assistant to create a definition and includes the user's request", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
'{"status":"online"}',
|
||||
{
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Status",
|
||||
url: "https://example.com/status",
|
||||
},
|
||||
"Show a compact green status when the service is online.",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("`customWidget_create`");
|
||||
expect(prompt).toContain("`secrets: []`");
|
||||
expect(prompt).toContain("do not ask for typed confirmation");
|
||||
expect(prompt).toContain('```json\n{"status":"online"}\n```');
|
||||
expect(prompt).toContain('"name": "Status"');
|
||||
expect(prompt).toContain("Show a compact green status when the service is online.");
|
||||
});
|
||||
|
||||
test("asks Homarr Assistant to update the selected definition without replacing its secrets", () => {
|
||||
const prompt = buildAiPrompt(
|
||||
schema,
|
||||
null,
|
||||
{
|
||||
name: "Existing widget",
|
||||
authType: "bearer",
|
||||
},
|
||||
"Use a progress bar.",
|
||||
"widget-definition-id",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("custom widget definition `widget-definition-id`");
|
||||
expect(prompt).toContain("`customWidget_update`");
|
||||
expect(prompt).toContain("Preserve configured secrets by omitting the `secrets` field");
|
||||
expect(prompt).not.toContain('"secrets"');
|
||||
});
|
||||
|
||||
test("keeps copy-to-clipboard prompts useful before a request is entered", () => {
|
||||
const prompt = buildAiPrompt(schema);
|
||||
|
||||
expect(prompt).toContain("PASTE_YOUR_API_RESPONSE_HERE");
|
||||
expect(prompt).toContain("Describe what you want the widget to show or change here.");
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
import { env } from "@homarr/docker/env";
|
||||
@@ -41,11 +43,29 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
import { ManageTourGate } from "~/components/onboarding/manage-tour-gate";
|
||||
import { env as nextEnv } from "~/env";
|
||||
|
||||
const logger = createLogger({ module: "manageLayout" });
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
const session = await auth();
|
||||
const sessionPromise = auth();
|
||||
const shouldRunManageTourPromise = sessionPromise.then(async (session) => {
|
||||
if (!session || nextEnv.DEMO_MODE) return false;
|
||||
|
||||
try {
|
||||
const user = await getRscUserSettingsAsync(session.user.id);
|
||||
return user !== undefined && !user.completedManageTour;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to load the management tour status", { cause: error }));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [t, session, shouldRunManageTour] = await Promise.all([
|
||||
getScopedI18n("management.navbar"),
|
||||
sessionPromise,
|
||||
shouldRunManageTourPromise,
|
||||
]);
|
||||
const navigationLinks: NavigationLink[] = [
|
||||
{
|
||||
label: t("items.home"),
|
||||
@@ -63,7 +83,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconBox,
|
||||
href: "/manage/apps",
|
||||
label: t("items.apps"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -73,7 +93,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("integration-create"),
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
@@ -92,7 +112,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
@@ -102,7 +122,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
hidden: !session?.user.permissions.includes("media-upload"),
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
@@ -239,7 +259,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
return (
|
||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||
{shell}
|
||||
</ManageTourGate>
|
||||
);
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
openModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
value: configuration.value,
|
||||
integrationData: configuration.integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("AdvancedFocusManualSurface", () => {
|
||||
expect(dialog?.id).toBe("advanced-focus-test");
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog?.closest("[data-portal]")).not.toBeNull();
|
||||
expect(dialog?.closest(".grid-stack-item")).toBeNull();
|
||||
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
|
||||
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
|
||||
"Test widget advanced view",
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset <= columnCount - size.width; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { ItemMoveModalProps } from "./item-move-modal";
|
||||
|
||||
let itemMoveModalPromise: Promise<typeof import("./item-move-modal")> | undefined;
|
||||
|
||||
const loadItemMoveModal = () => {
|
||||
if (itemMoveModalPromise) return itemMoveModalPromise;
|
||||
|
||||
const currentPromise = import("./item-move-modal");
|
||||
itemMoveModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (itemMoveModalPromise === currentPromise) itemMoveModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadItemMoveModal = () => void loadItemMoveModal().catch(() => undefined);
|
||||
|
||||
interface LazyItemMoveModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: ItemMoveModalProps;
|
||||
}
|
||||
|
||||
const LazyItemMoveModalContent = (props: LazyItemMoveModalContentProps) => {
|
||||
const { ItemMoveModal } = use(loadItemMoveModal());
|
||||
const Component = ItemMoveModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyItemMoveModal = createModal<ItemMoveModalProps>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
itemMoveModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyItemMoveModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.moveResize.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => {
|
||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||
};
|
||||
@@ -172,7 +172,7 @@ describe("matchesWidgetItemQuery", () => {
|
||||
integrationIds: ["beszel-1"],
|
||||
systemId: "system-1",
|
||||
timePeriod: "1h",
|
||||
includeDocker: false,
|
||||
includeDocker: true,
|
||||
},
|
||||
};
|
||||
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
@@ -31,6 +32,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
@@ -120,6 +122,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useIntegrations } from "@homarr/auth/client";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
@@ -33,7 +35,11 @@ const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(n
|
||||
*/
|
||||
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
|
||||
const board = useRequiredBoard();
|
||||
const integrations = useIntegrations();
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session) && isEditMode,
|
||||
});
|
||||
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
|
||||
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
|
||||
|
||||
@@ -12,8 +12,18 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
integration: {
|
||||
all: {
|
||||
useQuery: () => ({ data: undefined }),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useIntegrations: () => undefined,
|
||||
useSession: () => ({ data: { user: { id: "user" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
@@ -21,6 +31,10 @@ vi.mock("@homarr/boards/context", () => ({
|
||||
useRequiredBoard: () => mocks.board,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true],
|
||||
}));
|
||||
|
||||
describe("board grid portal host", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
.suppressTouchCallout {
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ItemSelectModal } from "../../items/item-select-modal";
|
||||
import { useBoardPermissions } from "../../permissions/client";
|
||||
import type { BoardPlacementHint } from "../../items/actions/create-item";
|
||||
import type { UseGridstackRefs } from "./use-gridstack";
|
||||
import classes from "./board-empty-space-context-menu.module.css";
|
||||
import { hasExceededLongPressMoveTolerance } from "./long-press";
|
||||
|
||||
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: BoardPlacementHint;
|
||||
}
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { openModal } = useModalAction(ItemSelectModal);
|
||||
const [isEditMode] = useEditMode();
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
|
||||
const touchGuardElement = useRef<HTMLDivElement | null>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
|
||||
const closeMenu = (afterFocusRestore?: (returnFocus: HTMLElement | null) => void) => {
|
||||
const returnFocus = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
setMenu(null);
|
||||
requestAnimationFrame(() => {
|
||||
returnFocus?.focus();
|
||||
afterFocusRestore?.(returnFocus);
|
||||
});
|
||||
};
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer.current !== null) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
touchGuardElement.current = null;
|
||||
longPressPointer.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
|
||||
const wrapper = refs.wrapperElement.current;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
|
||||
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
|
||||
|
||||
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
|
||||
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
|
||||
if (
|
||||
cell.x < 0 ||
|
||||
cell.y < 0 ||
|
||||
cell.x >= gridstack.getColumn() ||
|
||||
(rowLimit !== undefined && cell.y >= rowLimit) ||
|
||||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
returnFocusRef.current = event.currentTarget;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || event.pointerType !== "touch") return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
const returnFocus = event.currentTarget;
|
||||
clearLongPress();
|
||||
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
touchGuardElement.current = event.currentTarget;
|
||||
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
returnFocusRef.current = returnFocus;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
navigator.vibrate?.(20);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const pointer = longPressPointer.current;
|
||||
if (
|
||||
!pointer ||
|
||||
pointer.pointerId !== event.pointerId ||
|
||||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearLongPress();
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!enabled ||
|
||||
event.currentTarget !== event.target ||
|
||||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const wrapper = event.currentTarget;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!gridstack) return;
|
||||
|
||||
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
|
||||
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
|
||||
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
event.preventDefault();
|
||||
returnFocusRef.current = wrapper;
|
||||
setMenu({
|
||||
x: rect.left + 24,
|
||||
y: rect.top + 24,
|
||||
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
{...boxProps}
|
||||
ref={refs.wrapper}
|
||||
tabIndex={enabled ? 0 : undefined}
|
||||
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Menu
|
||||
opened={menu !== null}
|
||||
withInitialFocusPlaceholder={false}
|
||||
onChange={(opened) => {
|
||||
if (!opened) closeMenu();
|
||||
}}
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
returnFocus={false}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
data-autofocus
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => {
|
||||
const placement = menu?.placement;
|
||||
closeMenu((returnFocus) => {
|
||||
if (!placement) return;
|
||||
openModal(
|
||||
{ placement, board, persistImmediately: !isEditMode },
|
||||
{
|
||||
onClose: () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("item.create.addAtPosition")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { filterRecursiveInnerSections } from "./gridstack";
|
||||
|
||||
describe("filterRecursiveInnerSections", () => {
|
||||
test("removes sections that would recursively render an ancestor", () => {
|
||||
const innerSections = [{ id: "safe" }, { id: "parent" }, { id: "root" }];
|
||||
|
||||
expect(filterRecursiveInnerSections(innerSections, new Set(["root", "parent"]))).toEqual([{ id: "safe" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { hasExceededLongPressMoveTolerance } from "./long-press";
|
||||
|
||||
describe("hasExceededLongPressMoveTolerance", () => {
|
||||
test("allows small touch jitter", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
|
||||
});
|
||||
|
||||
test("allows movement exactly at the threshold", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
|
||||
});
|
||||
|
||||
test("cancels a deliberate scroll gesture", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export const longPressMoveTolerancePx = 10;
|
||||
|
||||
export const hasExceededLongPressMoveTolerance = (
|
||||
start: { x: number; y: number },
|
||||
current: { x: number; y: number },
|
||||
tolerance = longPressMoveTolerancePx,
|
||||
) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { wouldCreateSectionCycle } from "./section-actions";
|
||||
|
||||
describe("wouldCreateSectionCycle", () => {
|
||||
test("rejects moving a dynamic section into itself or one of its descendants", () => {
|
||||
test("rejects moving a container into itself or one of its descendants", () => {
|
||||
const layoutId = "layout";
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const parentSection = new DynamicSectionMockBuilder({ id: "parent" })
|
||||
const parentSection = new ContainerSectionMockBuilder({ id: "parent" })
|
||||
.addLayout({ layoutId, parentSectionId: rootSection.id })
|
||||
.build();
|
||||
const childSection = new DynamicSectionMockBuilder({ id: "child" })
|
||||
const childSection = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId, parentSectionId: parentSection.id })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
|
||||
|
||||
@@ -101,7 +101,7 @@ export const wouldCreateSectionCycle = (
|
||||
visitedSectionIds.add(currentSectionId);
|
||||
|
||||
const currentSection = board.sections.find((section) => section.id === currentSectionId);
|
||||
if (currentSection?.kind !== "dynamic") return false;
|
||||
if (currentSection?.kind !== "container") return false;
|
||||
|
||||
currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { use, useCallback, useMemo } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -6,9 +6,10 @@ import { getSafeAppHref } from "@homarr/common";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets/definition";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { filterByItemKind } from "./filter-by-item-kind";
|
||||
|
||||
interface SectionItemLayout {
|
||||
layoutId: string;
|
||||
@@ -80,10 +81,27 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => {
|
||||
const settings = useSettings();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const appDefinition = use(loadWidgetDefinition("app"));
|
||||
const appIds = useMemo(() => {
|
||||
const items: Item[] = getSectionItemsForLayout(board, sectionId, currentLayoutId);
|
||||
return Array.from(new Set(filterByItemKind(items, settings, "app").map((item) => item.options.appId)));
|
||||
}, [board, currentLayoutId, sectionId, settings]);
|
||||
return Array.from(
|
||||
new Set(
|
||||
items
|
||||
.filter((item) => item.kind === "app")
|
||||
.map(
|
||||
(item) =>
|
||||
(
|
||||
reduceWidgetOptionsWithDefinition(
|
||||
appDefinition,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<"app">["options"]
|
||||
).appId,
|
||||
)
|
||||
.filter((appId): appId is string => typeof appId === "string"),
|
||||
),
|
||||
);
|
||||
}, [appDefinition, board, currentLayoutId, sectionId, settings]);
|
||||
const { data: apps = [], isLoading } = clientApi.app.byIds.useQuery(appIds, {
|
||||
enabled: enabled && appIds.length > 0,
|
||||
staleTime: 30_000,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ e
|
||||
|
||||
// Native server-side import keeps the completed-user client graph lean while
|
||||
// giving first-time users one stable board tree instead of swapping a
|
||||
// Suspense fallback after GridStack has mounted.
|
||||
// Suspense fallback after the board grid has mounted.
|
||||
const { BoardTourProvider } = await import("./board-tour");
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
@@ -31,8 +31,6 @@ interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
|
||||
isDockerEnabled?: boolean;
|
||||
opened: boolean;
|
||||
onOpenChange: (opened: boolean) => void;
|
||||
}
|
||||
|
||||
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
@@ -41,13 +39,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
|
||||
.join(" + ");
|
||||
|
||||
export const UserAvatarMenu = ({
|
||||
children,
|
||||
availableUpdates,
|
||||
isDockerEnabled,
|
||||
opened,
|
||||
onOpenChange,
|
||||
}: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const session = useSession();
|
||||
|
||||
@@ -65,7 +57,7 @@ export const UserAvatarMenu = ({
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { WorkshopBrowserModal } from "./workshop-browser-modal";
|
||||
|
||||
function parseWidgetContent(content: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { ok: false as const, message: "Submission content is not valid JSON" };
|
||||
}
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { ok: false as const, message: result.error.issues[0]?.message ?? "Invalid widget schema" };
|
||||
}
|
||||
return { ok: true as const, data: result.data };
|
||||
}
|
||||
|
||||
export const InstallWidgetFromWorkshopButton = () => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
setPendingId(null);
|
||||
close();
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
|
||||
setPendingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelect = (submission: { id: string; content: string }) => {
|
||||
const parsed = parseWidgetContent(submission.content);
|
||||
if (!parsed.ok) {
|
||||
showErrorNotification({ title: t("title"), message: parsed.message });
|
||||
return;
|
||||
}
|
||||
setPendingId(submission.id);
|
||||
importMutation.mutate(parsed.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopBrowserModal
|
||||
type="widget"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.install")}
|
||||
pendingId={pendingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</Button>
|
||||
<WorkshopBrowserModal
|
||||
type="css"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.use")}
|
||||
onSelect={(submission) => {
|
||||
onSelect(submission.content);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
|
||||
|
||||
export interface WorkshopSubmission {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
type: "css" | "widget";
|
||||
title: string;
|
||||
description: string;
|
||||
schemaVersion: string;
|
||||
content: string;
|
||||
screenshots: string[];
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
version: number;
|
||||
author: string;
|
||||
authorName: string;
|
||||
commentCount: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PBListResponse {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: WorkshopSubmission[];
|
||||
}
|
||||
|
||||
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
|
||||
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
|
||||
|
||||
export const fetchWorkshopSubmissions = async (
|
||||
type: "css" | "widget",
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
): Promise<PBListResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(perPage),
|
||||
sort: "-upvotes",
|
||||
filter: `(type='${type}')`,
|
||||
});
|
||||
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
|
||||
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
|
||||
return response.json() as Promise<PBListResponse>;
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopSubmission } from "./workshop-api";
|
||||
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
|
||||
|
||||
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
|
||||
|
||||
interface Props {
|
||||
type: "css" | "widget";
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (submission: WorkshopSubmission) => void;
|
||||
actionLabel: string;
|
||||
pendingId?: string | null;
|
||||
}
|
||||
|
||||
const displayName = (submission: WorkshopSubmission) => submission.authorName;
|
||||
|
||||
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const handleClose = () => {
|
||||
setPage(1);
|
||||
onClose();
|
||||
};
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["workshop-submissions", type, page],
|
||||
queryFn: () => fetchWorkshopSubmissions(type, page),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const errorText = error instanceof Error ? error.message : "unknown error";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>Install from Workshop</Text>
|
||||
<Anchor
|
||||
href={WORKSHOP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
Open Workshop <IconExternalLink size={12} />
|
||||
</Anchor>
|
||||
</Group>
|
||||
}
|
||||
size={1200}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No submissions available yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{data.items.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
actionLabel={actionLabel}
|
||||
pending={pendingId === submission.id}
|
||||
onSelect={() => onSelect(submission)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Center>
|
||||
<Pagination total={data.totalPages} value={page} onChange={setPage} />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const SubmissionCard = ({
|
||||
submission,
|
||||
actionLabel,
|
||||
pending,
|
||||
onSelect,
|
||||
}: {
|
||||
submission: WorkshopSubmission;
|
||||
actionLabel: string;
|
||||
pending: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const screenshots = submission.screenshots ?? [];
|
||||
const [imgIdx, setImgIdx] = useState(0);
|
||||
const score = submission.upvotes - submission.downvotes;
|
||||
const scorePrefix = score > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<Card withBorder padding={0}>
|
||||
{screenshots.length > 0 && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
|
||||
alt={submission.title}
|
||||
h={180}
|
||||
fit="cover"
|
||||
/>
|
||||
{screenshots.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i - 1 + screenshots.length) % screenshots.length)}
|
||||
>
|
||||
<IconChevronLeft size={12} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
right={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i + 1) % screenshots.length)}
|
||||
>
|
||||
<IconChevronRight size={12} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack gap={4} p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
|
||||
{submission.title}
|
||||
</Text>
|
||||
<Badge variant="light" size="sm" style={{ flexShrink: 0 }}>
|
||||
{scorePrefix}
|
||||
{score}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{displayName(submission)} · v{submission.version}
|
||||
</Text>
|
||||
|
||||
{submission.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{submission.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" mt={4}>
|
||||
<Button size="xs" style={{ flex: 1 }} loading={pending} onClick={onSelect}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
component="a"
|
||||
href={`${WORKSHOP_URL}/${submission.id}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View in workshop"
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user