Revert "chore(merge): resolve v2 conflicts"
This reverts commit377f2f6a20, reversing changes made to7e4cb0201b.
This commit is contained in:
58 files changed
+11115
-23863
No files matched your search
@@ -11,7 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -46,8 +45,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
@@ -297,11 +296,8 @@ interface CopyAiPromptButtonProps {
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
@@ -321,8 +317,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={!mounted || isLoading}
|
||||
disabled={!mounted || isLoading || !schema}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -30,11 +30,9 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetImport,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
import type { displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
|
||||
@@ -469,90 +467,6 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
customJsx: { template: "template" },
|
||||
};
|
||||
|
||||
const importToFormValues: Record<string, (config: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
singleValue: (c) => ({
|
||||
jsonPath: c.jsonPath,
|
||||
label: c.label ?? "",
|
||||
unit: c.unit ?? "",
|
||||
valueSize: c.valueSize ?? "lg",
|
||||
labelPosition: c.labelPosition ?? "below",
|
||||
}),
|
||||
keyValue: (c) => ({
|
||||
mappings:
|
||||
(c.mappings as Array<Record<string, string>>)?.map((m) => ({
|
||||
label: m.label,
|
||||
jsonPath: m.jsonPath,
|
||||
unit: m.unit ?? "",
|
||||
})) ?? [],
|
||||
kvLayout: c.layout ?? "list",
|
||||
kvColumns: c.columns ?? 2,
|
||||
}),
|
||||
table: (c) => ({
|
||||
tablePath: c.tablePath,
|
||||
columns: c.columns ?? [],
|
||||
striped: c.striped ?? true,
|
||||
compact: c.compact ?? false,
|
||||
}),
|
||||
statGrid: (c) => ({
|
||||
statGridItems: c.items ?? [],
|
||||
statGridColumns: c.columns ?? 2,
|
||||
cardStyle: c.cardStyle ?? "filled",
|
||||
}),
|
||||
progressBars: (c) => ({
|
||||
progressBars:
|
||||
(c.bars as Array<Record<string, unknown>>)?.map((b) => ({
|
||||
label: b.label ?? "",
|
||||
valuePath: b.valuePath ?? "",
|
||||
maxPath: b.maxPath ?? "",
|
||||
unit: b.unit ?? "",
|
||||
color: b.color ?? "blue",
|
||||
})) ?? [],
|
||||
showPercentage: c.showPercentage ?? true,
|
||||
barSize: c.barSize ?? "md",
|
||||
}),
|
||||
statusIndicator: (c) => ({
|
||||
statusItems:
|
||||
(c.items as Array<Record<string, unknown>>)?.map((i) => ({
|
||||
label: i.label ?? "",
|
||||
jsonPath: i.jsonPath ?? "",
|
||||
goodValues: Array.isArray(i.goodValues) ? (i.goodValues as string[]).join(", ") : String(i.goodValues ?? ""),
|
||||
})) ?? [],
|
||||
statusLayout: c.layout ?? "list",
|
||||
dotSize: c.dotSize ?? "md",
|
||||
}),
|
||||
countGrid: (c) => ({
|
||||
countGridItems: c.items ?? [],
|
||||
countGridColumns: c.columns ?? 2,
|
||||
countValueSize: c.valueSize ?? "md",
|
||||
}),
|
||||
raw: (c) => ({ rawJsonPath: c.jsonPath ?? "$", rawMaxHeight: c.maxHeight ?? 300 }),
|
||||
actionButton: (c) => ({
|
||||
buttonLabel: c.buttonLabel ?? "",
|
||||
buttonColor: c.buttonColor ?? "blue",
|
||||
confirmText: c.confirmText ?? "",
|
||||
successMessage: c.successMessage ?? "",
|
||||
}),
|
||||
customJsx: (c) => ({ template: c.template ?? "" }),
|
||||
};
|
||||
|
||||
function importJsonToFormValues(imported: CustomWidgetImport): Partial<z.infer<typeof formSchema>> {
|
||||
const displayFields =
|
||||
importToFormValues[imported.displayType]?.(imported.displayConfig as unknown as Record<string, unknown>) ?? {};
|
||||
return {
|
||||
name: imported.name,
|
||||
description: imported.description ?? "",
|
||||
iconUrl: imported.iconUrl ?? "",
|
||||
url: imported.url,
|
||||
authType: imported.authType,
|
||||
headerName: imported.headerName ?? "",
|
||||
method: imported.method,
|
||||
requestBody: imported.requestBody ?? "",
|
||||
displayType: imported.displayType,
|
||||
...displayFields,
|
||||
secrets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcErr = err as {
|
||||
@@ -767,32 +681,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
}
|
||||
}, [getPreviewInput, previewMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "create") return;
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const raw = e.clipboardData?.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) return;
|
||||
e.preventDefault();
|
||||
const formValues = importJsonToFormValues(result.data);
|
||||
form.setValues({ ...defaultCreateValues, ...formValues });
|
||||
form.clearErrors();
|
||||
showSuccessNotification({
|
||||
title: t("action.paste"),
|
||||
message: t("notification.imported", { name: result.data.name }),
|
||||
});
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
}, [form, t, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewRefreshSignal > 0 && hasTestedRef.current) {
|
||||
void handlePreviewTest();
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { IconApi, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
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 { Link } from "@homarr/ui";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
@@ -36,36 +31,6 @@ interface CustomWidgetListProps {
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("action.paste"), message: "Custom widget imported" });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification({ title: t("action.paste"), message: err.message || "Failed to import" });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const raw = e.clipboardData?.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) return;
|
||||
e.preventDefault();
|
||||
importMutation.mutate(result.data);
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
}, [importMutation.mutate]);
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return (
|
||||
@@ -79,40 +44,36 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder layout="fixed">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th w="30%" visibleFrom="sm">
|
||||
{t("table.url")}
|
||||
</Table.Th>
|
||||
<Table.Th w={120} visibleFrom="xs">
|
||||
{t("table.display")}
|
||||
</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td style={{ overflow: "hidden" }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} style={{ flexShrink: 0 }} />
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td visibleFrom="xs">
|
||||
<Table.Td>
|
||||
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
|
||||
{t(`displayType.${def.displayType}` as never)}
|
||||
</Badge>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
@@ -29,7 +28,6 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<InstallWidgetFromWorkshopButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
@@ -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