feat: improve mobile board experience
This commit is contained in:
1 parent
bc57f9579d
commit
7c0e98c55b
46 files changed
+1455
-130
No files matched your search
@@ -15,6 +15,12 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions(settings) {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
@@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
@@ -31,7 +32,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -43,7 +44,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
@@ -52,13 +62,15 @@ interface CalendarBaseProps {
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("widget.calendar");
|
||||
const mantineTheme = useMantineTheme();
|
||||
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||
const { ref, width, height } = useElementSize();
|
||||
@@ -66,6 +78,38 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const nextEvent = normalizedEvents
|
||||
.filter((event) => event.startDate.getTime() >= Date.now())
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0];
|
||||
const link = nextEvent?.links[0];
|
||||
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap={4} maw="100%">
|
||||
<Text
|
||||
component={link ? "a" : "span"}
|
||||
href={link?.href}
|
||||
target={link ? "_blank" : undefined}
|
||||
rel={link ? "noopener noreferrer" : undefined}
|
||||
fw={700}
|
||||
size="lg"
|
||||
lineClamp={1}
|
||||
maw="100%"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
{nextEvent?.title ?? normalizedEvents.length}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" lineClamp={1}>
|
||||
{nextEvent
|
||||
? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate)
|
||||
: t("name")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
|
||||
@@ -9,6 +9,12 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
@@ -6,6 +6,11 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Center, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
interface WidgetMobileSummaryProps {
|
||||
value: number | string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
|
||||
<Text fz="xl" fw={700} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
{description !== undefined && (
|
||||
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,16 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
|
||||
export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail";
|
||||
|
||||
export interface WidgetMobilePresentation {
|
||||
width: 1 | 2;
|
||||
height: 1 | 2 | 3;
|
||||
supportsCompactSummary?: boolean;
|
||||
supportsDetailView?: boolean;
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetContextMenuAction {
|
||||
key: string;
|
||||
label: stringOrTranslation;
|
||||
@@ -69,6 +79,7 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
mobile?: WidgetMobilePresentation;
|
||||
queryKey?: QueryKey;
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
@@ -98,6 +109,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
||||
|
||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
displayMode?: WidgetDisplayMode;
|
||||
isEditMode: boolean;
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function DownloadClientsWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
setOptions,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"downloads">) {
|
||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
|
||||
integrationIds.includes(id) ? [id] : [],
|
||||
@@ -605,6 +606,23 @@ export default function DownloadClientsWidget({
|
||||
{ up: 0, down: 0 },
|
||||
);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const totalDownloadSpeed = clients.reduce((total, { status }) => total + (status?.rates.down ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
<Stack align="center" gap={4} maw="100%">
|
||||
<Text fw={700} size="xl">
|
||||
{data.length}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" lineClamp={1}>
|
||||
{`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.columns.length === 0)
|
||||
return (
|
||||
<Center h="100%">
|
||||
|
||||
@@ -33,6 +33,12 @@ const columnsSort = columnsList.filter((column) =>
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -67,6 +67,8 @@ import * as customApi from "./custom-api";
|
||||
|
||||
export type {
|
||||
WidgetDefinition,
|
||||
WidgetDisplayMode,
|
||||
WidgetMobilePresentation,
|
||||
WidgetContextMenuAction,
|
||||
WidgetContextActionProps,
|
||||
WidgetOptionsSettings,
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -9,11 +23,18 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
|
||||
export default function MediaMissingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const pageSize = Number(options.pageSize);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||
@@ -32,25 +53,32 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
|
||||
</Center>
|
||||
);
|
||||
|
||||
const missing = data.flatMap((entry) =>
|
||||
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const queued = data.flatMap((entry) =>
|
||||
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const primary = options.showMissing
|
||||
? { value: missingCount, label: t("tab.missing") }
|
||||
: { value: queuedCount, label: t("tab.queued") };
|
||||
const description = options.showMissing && options.showQueued ? `${queuedCount} ${t("tab.queued")}` : undefined;
|
||||
|
||||
return <WidgetMobileSummary value={primary.value} label={primary.label} description={description} />;
|
||||
}
|
||||
|
||||
const isThin = width > 0 && width < 160;
|
||||
const isShort = height > 0 && height < 180;
|
||||
const targetCardWidth = isShort ? 130 : 200;
|
||||
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
|
||||
|
||||
const tabLabel = (label: string, shown: number, total: number) =>
|
||||
isThin ? total : `${label} (${shown}/${total})`;
|
||||
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
|
||||
|
||||
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
|
||||
const renderPanel = (
|
||||
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
|
||||
emptyLabel: string,
|
||||
) => (
|
||||
<ScrollArea h="100%" scrollbarSize={4}>
|
||||
<Box p="xs">
|
||||
{entries.length === 0 ? (
|
||||
@@ -116,7 +144,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
|
||||
<ThemeIcon
|
||||
className={classes.poster}
|
||||
h={size}
|
||||
w={w}
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={type === "movie" ? "yellow" : "blue"}
|
||||
>
|
||||
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
@@ -183,8 +218,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
|
||||
);
|
||||
};
|
||||
|
||||
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
|
||||
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
|
||||
const CardShell = ({
|
||||
item,
|
||||
density,
|
||||
children,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component="a"
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
{item.imageUrl && (
|
||||
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,12 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
|
||||
icon: IconMovie,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissing: factory.switch({ defaultValue: true }),
|
||||
|
||||
@@ -16,11 +16,21 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
|
||||
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
||||
export default function MediaReleasesWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const t = useI18n("widget.mediaReleases");
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
return <WidgetMobileSummary value={releases.length} label={t("name")} description={releases[0]?.title} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="sm">
|
||||
|
||||
@@ -5,6 +5,12 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
|
||||
icon: IconTicket,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
queryKey: [["widget", "mediaRelease"]],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { WidgetMobileSummary } from "../../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
@@ -35,7 +36,9 @@ export default function MediaServerWidget({
|
||||
isEditMode,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
@@ -48,6 +51,10 @@ export default function MediaServerWidget({
|
||||
if (!mediaRequests) return <WidgetEmptyState />;
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
return <WidgetMobileSummary value={mediaRequests.length} label={t("name")} description={mediaRequests[0]?.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
|
||||
@@ -11,6 +11,12 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
icon: IconZoomQuestion,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { views } from ".";
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { WidgetMobileSummary } from "../common/mobile-summary";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
@@ -30,6 +31,7 @@ export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
@@ -45,6 +47,27 @@ export default function MediaTranscodingWidget({
|
||||
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const summary =
|
||||
options.defaultView === "workers"
|
||||
? { value: transcodingData.data.workers.length, label: t("tab.workers") }
|
||||
: options.defaultView === "queue"
|
||||
? { value: transcodingData.data.queue.totalCount, label: t("tab.queue") }
|
||||
: {
|
||||
value: transcodingData.data.statistics.totalTranscodeCount,
|
||||
label: t("panel.statistics.transcodesCount"),
|
||||
};
|
||||
const failedHealthChecks = transcodingData.data.statistics.failedHealthCheckCount;
|
||||
|
||||
return (
|
||||
<WidgetMobileSummary
|
||||
value={summary.value}
|
||||
label={summary.label}
|
||||
description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
const isTiny = width < 256;
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ export const views = ["workers", "queue", "statistics"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { Avatar, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -11,7 +11,11 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
@@ -29,6 +33,35 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
|
||||
[notificationIntegrations],
|
||||
);
|
||||
|
||||
if (displayMode === "mobileSummary") {
|
||||
const latestNotification = sortedNotifications[0];
|
||||
|
||||
return (
|
||||
<Center h="100%" p="md">
|
||||
{latestNotification ? (
|
||||
latestNotification.href ? (
|
||||
<Box
|
||||
component="a"
|
||||
href={latestNotification.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
maw="100%"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<NotificationSummary notification={latestNotification} />
|
||||
</Box>
|
||||
) : (
|
||||
<NotificationSummary notification={latestNotification} />
|
||||
)
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noItems")}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
@@ -80,6 +113,23 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationSummary = ({
|
||||
notification,
|
||||
}: {
|
||||
notification: {
|
||||
title: string | null;
|
||||
body: string;
|
||||
time: Date;
|
||||
};
|
||||
}) => (
|
||||
<Stack align="center" gap={4} maw="100%">
|
||||
<Text fw={700} size="lg" lineClamp={1} maw="100%">
|
||||
{notification.title ?? notification.body}
|
||||
</Text>
|
||||
<InfoDisplay date={notification.time} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const InfoDisplay = ({ date }: { date: Date }) => {
|
||||
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
icon: IconMessage,
|
||||
mobile: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
supportsDetailView: true,
|
||||
},
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -7,6 +7,12 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
|
||||
@@ -7,6 +7,12 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
|
||||
icon: IconBinaryTree,
|
||||
mobile: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
supportsCompactSummary: true,
|
||||
eager: true,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
|
||||
Reference in new issue
Block a user