feat: improve mobile board experience

This commit is contained in:
ajnart committed 2026-07-28 19:39:12 +02:00
1 parent bc57f9579d
commit 7c0e98c55b
46 files changed
+1455 -130

No files matched your search

+6
View File
@@ -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) => ({
+48 -4
View File
@@ -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()}
+6
View File
@@ -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({
+5
View File
@@ -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
View File
@@ -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%">
+6
View File
@@ -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(
+2
View File
@@ -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(),