Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4782e24b79 | ||
|
|
25d964a197 | ||
|
|
3eb3908d4c | ||
|
|
d6f14de6d1 | ||
|
|
0ad7b79187 | ||
|
|
35bc50c12a |
No files matched your search
@@ -17,10 +17,13 @@ import {
|
||||
import superjson from "superjson";
|
||||
import type { SuperJSONResult } from "superjson";
|
||||
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
@@ -56,6 +59,20 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "Mutations are disabled in demo mode"
|
||||
) {
|
||||
showWarningNotification({
|
||||
title: "Demo mode",
|
||||
message: "This action is disabled in demo mode.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
@@ -37,6 +40,13 @@ export default async function Login(props: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
{appEnv.DEMO_MODE && (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
||||
<Text size="sm" fw={500}>
|
||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
|
||||
@@ -42,8 +42,9 @@ export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
@@ -51,11 +52,13 @@ export const BoardContentHeaderActions = () => {
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu />
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
{!demoMode && (
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
@@ -141,7 +144,7 @@ const AddMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = () => {
|
||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -164,10 +167,18 @@ const EditModeMenu = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const discardDemoChanges = useCallback(() => {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) return saveBoard(board);
|
||||
if (isEditMode) {
|
||||
if (demoMode) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, saveBoard, open]);
|
||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
|
||||
@@ -96,7 +96,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
|
||||
>
|
||||
{children}
|
||||
<MenuDropdown>
|
||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION || env.DEMO_MODE} />
|
||||
</MenuDropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -19,6 +19,9 @@
|
||||
|
||||
<!-- Links -->
|
||||
<p align="center">
|
||||
<a href="https://demo.homarr.dev">
|
||||
<strong>Live Demo 🎮</strong>
|
||||
</a> •
|
||||
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||
<strong>Install 💻</strong>
|
||||
</a> •
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
},
|
||||
runtimeEnv: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
},
|
||||
});
|
||||
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
import { isDemoMode, publicProcedure } from "../trpc";
|
||||
|
||||
export const dockerMiddleware = () => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_DOCKER) {
|
||||
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||
return await next();
|
||||
}
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
export const infoRouter = createTRPCRouter({
|
||||
getInfo: protectedProcedure
|
||||
@@ -13,4 +13,5 @@ export const infoRouter = createTRPCRouter({
|
||||
version: packageJson.version,
|
||||
};
|
||||
}),
|
||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||
});
|
||||
@@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
pingIconsEnabled: input.pingIconsEnabled,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
@@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({
|
||||
.set({
|
||||
firstDayOfWeek: input.firstDayOfWeek,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { env } from "./env";
|
||||
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
|
||||
|
||||
const logger = createLogger({ module: "trpc" });
|
||||
@@ -87,6 +88,20 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
export const isDemoMode = env.DEMO_MODE;
|
||||
|
||||
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
if (env.DEMO_MODE && type === "mutation") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Mutations are disabled in demo mode",
|
||||
});
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
*
|
||||
@@ -94,19 +109,14 @@ export const createTRPCRouter = t.router;
|
||||
* tRPC API. It does not guarantee that a user querying is authorized, but you
|
||||
* can still access user session data if they are logged in
|
||||
*/
|
||||
export const publicProcedure = t.procedure;
|
||||
export const publicProcedure = baseProcedure;
|
||||
|
||||
/**
|
||||
* Reusable middleware that enforces users are logged in before running the
|
||||
* procedure
|
||||
*/
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
// infers the `session` as non-nullable
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
},
|
||||
});
|
||||
@@ -121,7 +131,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
*
|
||||
* @see https://trpc.io/docs/procedures
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
|
||||
export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
|
||||
|
||||
/**
|
||||
* Procedure that requires a specific permission
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
} from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
@@ -18,12 +21,17 @@ import {
|
||||
insertServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "../queries/server-setting";
|
||||
|
||||
import {
|
||||
apps,
|
||||
boards,
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationItems,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
onboarding,
|
||||
searchEngines,
|
||||
@@ -39,7 +47,7 @@ export const seedDataAsync = async (db: Database) => {
|
||||
await seedDefaultSearchEnginesAsync(db);
|
||||
await seedDefaultIntegrationsAsync(db);
|
||||
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
};
|
||||
@@ -209,6 +217,134 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
||||
console.log(`Created ${createdCount} default integrations through seeding process`);
|
||||
};
|
||||
|
||||
interface DemoWidget {
|
||||
kind: WidgetKind;
|
||||
width: number;
|
||||
height: number;
|
||||
needsIntegration: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const demoApps = [
|
||||
{
|
||||
name: "Sonarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
|
||||
href: "https://sonarr.tv",
|
||||
},
|
||||
{
|
||||
name: "Radarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
|
||||
href: "https://radarr.video",
|
||||
},
|
||||
{
|
||||
name: "Plex",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
|
||||
href: "https://plex.tv",
|
||||
},
|
||||
{
|
||||
name: "Jellyfin",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
|
||||
href: "https://jellyfin.org",
|
||||
},
|
||||
{
|
||||
name: "Proxmox",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
|
||||
href: "https://proxmox.com",
|
||||
},
|
||||
{
|
||||
name: "Pi-hole",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
|
||||
href: "https://pi-hole.net",
|
||||
},
|
||||
{
|
||||
name: "Grafana",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
|
||||
href: "https://grafana.com",
|
||||
},
|
||||
{
|
||||
name: "Portainer",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
|
||||
href: "https://portainer.io",
|
||||
},
|
||||
{
|
||||
name: "Home Assistant",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
|
||||
href: "https://home-assistant.io",
|
||||
},
|
||||
{
|
||||
name: "Nextcloud",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
|
||||
href: "https://nextcloud.com",
|
||||
},
|
||||
{
|
||||
name: "qBittorrent",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
|
||||
href: "https://qbittorrent.org",
|
||||
},
|
||||
{
|
||||
name: "Overseerr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
|
||||
href: "https://overseerr.dev",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
|
||||
// Row 1: calendar + downloads + clock = 12
|
||||
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
|
||||
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
|
||||
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
|
||||
// Row 2: healthMonitoring + bookmarks = 12
|
||||
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
|
||||
{
|
||||
kind: "bookmarks",
|
||||
width: 6,
|
||||
height: 3,
|
||||
needsIntegration: false,
|
||||
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
|
||||
},
|
||||
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
|
||||
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
|
||||
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
|
||||
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
|
||||
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
|
||||
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
|
||||
// Row 5: requestList + requestStats + indexerManager = 12
|
||||
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
|
||||
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
|
||||
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
|
||||
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
|
||||
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
|
||||
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
|
||||
{
|
||||
kind: "rssFeed",
|
||||
width: 6,
|
||||
height: 2,
|
||||
needsIntegration: false,
|
||||
options: {
|
||||
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
|
||||
maximumAmountPosts: 20,
|
||||
textLinesClamp: 2,
|
||||
hideDescription: false,
|
||||
},
|
||||
},
|
||||
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
|
||||
...appIds.map(
|
||||
(appId): DemoWidget => ({
|
||||
kind: "app",
|
||||
width: 2,
|
||||
height: 1,
|
||||
needsIntegration: false,
|
||||
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
|
||||
}),
|
||||
),
|
||||
// Row 9: notebook + dockerContainers + weather = 12
|
||||
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
|
||||
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
|
||||
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
|
||||
];
|
||||
|
||||
const seedDemoUserAsync = async (db: Database) => {
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, "demo"),
|
||||
@@ -225,7 +361,7 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
id: userId,
|
||||
name: "demo",
|
||||
email: "demo@example.com",
|
||||
password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
|
||||
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
|
||||
});
|
||||
|
||||
const maxPosition = await getMaxGroupPositionAsync(db);
|
||||
@@ -250,6 +386,27 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
previousStep: "settings",
|
||||
});
|
||||
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Demo Integration",
|
||||
url: "https://demo.homarr.dev",
|
||||
kind: "mock",
|
||||
appId: null,
|
||||
});
|
||||
|
||||
const appIds: string[] = [];
|
||||
for (const app of demoApps) {
|
||||
const appId = createId();
|
||||
appIds.push(appId);
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl,
|
||||
href: app.href,
|
||||
});
|
||||
}
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
@@ -257,23 +414,64 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
isPublic: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: createId(),
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const layoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const demoWidgets = buildDemoWidgets(appIds);
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
let rowMaxHeight = 0;
|
||||
for (const widget of demoWidgets) {
|
||||
if (xOffset + widget.width > 12) {
|
||||
xOffset = 0;
|
||||
yOffset += rowMaxHeight;
|
||||
rowMaxHeight = 0;
|
||||
}
|
||||
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: widget.kind,
|
||||
...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
});
|
||||
if (widget.needsIntegration) {
|
||||
await db.insert(integrationItems).values({
|
||||
itemId,
|
||||
integrationId,
|
||||
});
|
||||
}
|
||||
xOffset += widget.width;
|
||||
}
|
||||
|
||||
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
|
||||
|
||||
console.log(
|
||||
"Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
|
||||
"Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,130 @@
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
|
||||
export class CalendarMockService implements ICalendarIntegration {
|
||||
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
|
||||
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
|
||||
return await Promise.resolve(result);
|
||||
}
|
||||
interface EventTemplate {
|
||||
title: string;
|
||||
subTitle: string;
|
||||
description: string;
|
||||
dayOffset: number;
|
||||
hours: number;
|
||||
image: CalendarEvent["image"];
|
||||
indicatorColor: string;
|
||||
metadata?: CalendarEvent["metadata"];
|
||||
location?: string;
|
||||
links: CalendarEvent["links"];
|
||||
}
|
||||
|
||||
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
||||
const startDate = randomDateBetween(start, end);
|
||||
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
|
||||
return {
|
||||
const eventTemplates: EventTemplate[] = [
|
||||
{
|
||||
title: "The Mandalorian",
|
||||
subTitle: "Season 4",
|
||||
description: "A lone bounty hunter in the outer reaches of the galaxy.",
|
||||
dayOffset: 0,
|
||||
hours: 20,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: { content: "S4/E3", color: "red" },
|
||||
},
|
||||
indicatorColor: "blue",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt8111088/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Silo",
|
||||
subTitle: "Season 2",
|
||||
description: "In a ruined and toxic future, thousands live in a giant underground silo.",
|
||||
dayOffset: 3,
|
||||
hours: 21,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: { content: "S2/E8", color: "teal" },
|
||||
},
|
||||
indicatorColor: "teal",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt14688458/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Dune: Part Two",
|
||||
subTitle: "Digital Release",
|
||||
description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
|
||||
dayOffset: 5,
|
||||
hours: 0,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
metadata: { type: "radarr", releaseType: "digitalRelease" },
|
||||
indicatorColor: "cyan",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt15239678/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Severance",
|
||||
subTitle: "Season 2",
|
||||
description: "Mark leads a team of office workers whose memories are surgically divided.",
|
||||
dayOffset: 7,
|
||||
hours: 22,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: { content: "S2/E5", color: "violet" },
|
||||
},
|
||||
indicatorColor: "violet",
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: "Oppenheimer",
|
||||
subTitle: "Physical Release",
|
||||
description: "The story of the development of the atomic bomb.",
|
||||
dayOffset: 10,
|
||||
hours: 0,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
metadata: { type: "radarr", releaseType: "physicalRelease" },
|
||||
indicatorColor: "orange",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt15398776/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Homarr Meetup",
|
||||
subTitle: "",
|
||||
description: "Yearly meetup of the Homarr community",
|
||||
startDate,
|
||||
endDate,
|
||||
image: null,
|
||||
dayOffset: 14,
|
||||
hours: 14,
|
||||
location: "Mountains",
|
||||
image: null,
|
||||
indicatorColor: "#fa5252",
|
||||
links: [
|
||||
{
|
||||
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{
|
||||
title: "The Bear",
|
||||
subTitle: "Season 3",
|
||||
description: "A young chef fights to transform a sandwich shop.",
|
||||
dayOffset: 18,
|
||||
hours: 3,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: { content: "S3/E1", color: "yellow" },
|
||||
},
|
||||
indicatorColor: "yellow",
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: "Foundation",
|
||||
subTitle: "Season 3",
|
||||
description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
|
||||
dayOffset: 21,
|
||||
hours: 20,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: { content: "S3/E1", color: "indigo" },
|
||||
},
|
||||
indicatorColor: "indigo",
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: "Blade Runner 2099",
|
||||
subTitle: "Premiere",
|
||||
description: "A new chapter in the Blade Runner universe.",
|
||||
dayOffset: 25,
|
||||
hours: 21,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
metadata: { type: "radarr", releaseType: "digitalRelease" },
|
||||
indicatorColor: "grape",
|
||||
links: [],
|
||||
},
|
||||
];
|
||||
|
||||
const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
|
||||
const rangeStart = new Date(start);
|
||||
rangeStart.setDate(rangeStart.getDate() - 3);
|
||||
|
||||
return eventTemplates
|
||||
.map((tpl) => {
|
||||
const startDate = new Date(rangeStart);
|
||||
startDate.setDate(startDate.getDate() + tpl.dayOffset);
|
||||
startDate.setHours(tpl.hours, 0, 0, 0);
|
||||
|
||||
return {
|
||||
title: tpl.title,
|
||||
subTitle: tpl.subTitle,
|
||||
description: tpl.description,
|
||||
startDate,
|
||||
endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
|
||||
image: tpl.image,
|
||||
location: tpl.location ?? null,
|
||||
indicatorColor: tpl.indicatorColor,
|
||||
links: tpl.links,
|
||||
...(tpl.metadata ? { metadata: tpl.metadata } : {}),
|
||||
};
|
||||
})
|
||||
.filter((event) => event.startDate >= start && event.startDate <= end);
|
||||
};
|
||||
|
||||
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
title: "Titanic",
|
||||
subTitle: "A classic movie",
|
||||
description: "A tragic love story set on the ill-fated RMS Titanic.",
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
type: "radarr",
|
||||
releaseType: "inCinemas",
|
||||
},
|
||||
indicatorColor: "cyan",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt0120338/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
title: "The Mandalorian",
|
||||
subTitle: "A Star Wars Series",
|
||||
description: "A lone bounty hunter in the outer reaches of the galaxy.",
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: {
|
||||
content: "S1/E1",
|
||||
color: "red",
|
||||
},
|
||||
},
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt8111088/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function randomDateBetween(start: Date, end: Date): Date {
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
export class CalendarMockService implements ICalendarIntegration {
|
||||
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
|
||||
return await Promise.resolve(buildEventsForRange(start, end));
|
||||
}
|
||||
}
|
||||
@@ -2,57 +2,163 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
|
||||
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
||||
|
||||
const mockItems: Omit<DownloadClientItem, "id" | "index">[] = [
|
||||
{
|
||||
name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
|
||||
type: "torrent",
|
||||
size: 4_700_000_000,
|
||||
progress: 0.47,
|
||||
state: "leeching",
|
||||
downSpeed: 12_400_000,
|
||||
upSpeed: 890_000,
|
||||
sent: 420_000_000,
|
||||
received: 2_209_000_000,
|
||||
time: 201_000,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
|
||||
type: "torrent",
|
||||
size: 5_800_000_000,
|
||||
progress: 0.82,
|
||||
state: "leeching",
|
||||
downSpeed: 34_500_000,
|
||||
upSpeed: 2_100_000,
|
||||
sent: 1_200_000_000,
|
||||
received: 4_756_000_000,
|
||||
time: 30_000,
|
||||
category: "iso",
|
||||
},
|
||||
{
|
||||
name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
|
||||
type: "torrent",
|
||||
size: 18_200_000_000,
|
||||
progress: 0.03,
|
||||
state: "leeching",
|
||||
downSpeed: 1_200_000,
|
||||
upSpeed: 50_000,
|
||||
sent: 30_000_000,
|
||||
received: 546_000_000,
|
||||
time: 14_880_000,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Arch.Linux.2024.12.01.x86_64.iso",
|
||||
type: "torrent",
|
||||
size: 1_100_000_000,
|
||||
progress: 1,
|
||||
state: "seeding",
|
||||
downSpeed: 0,
|
||||
upSpeed: 4_500_000,
|
||||
sent: 3_300_000_000,
|
||||
received: 1_100_000_000,
|
||||
time: -7_200_000,
|
||||
category: "iso",
|
||||
},
|
||||
{
|
||||
name: "Tears.of.Steel.2012.1080p.DTS.mkv",
|
||||
type: "torrent",
|
||||
size: 7_340_000_000,
|
||||
progress: 0,
|
||||
state: "paused",
|
||||
downSpeed: 0,
|
||||
upSpeed: 0,
|
||||
time: 0,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
|
||||
type: "torrent",
|
||||
size: 12_000_000_000,
|
||||
progress: 0.65,
|
||||
state: "stalled",
|
||||
downSpeed: 0,
|
||||
upSpeed: 0,
|
||||
sent: 200_000_000,
|
||||
received: 7_800_000_000,
|
||||
time: 0,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Spring.2019.4K.HDR.mkv",
|
||||
type: "usenet",
|
||||
size: 3_200_000_000,
|
||||
progress: 0.91,
|
||||
state: "downloading",
|
||||
downSpeed: 45_000_000,
|
||||
time: 6_500,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
|
||||
type: "usenet",
|
||||
size: 1_800_000_000,
|
||||
progress: 1,
|
||||
state: "completed",
|
||||
time: -3_600_000,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Caminandes.3.Llamigos.2016.1080p.mkv",
|
||||
type: "torrent",
|
||||
size: 890_000_000,
|
||||
progress: 1,
|
||||
state: "seeding",
|
||||
downSpeed: 0,
|
||||
upSpeed: 1_800_000,
|
||||
sent: 2_670_000_000,
|
||||
received: 890_000_000,
|
||||
time: -86_400_000,
|
||||
category: "movies",
|
||||
},
|
||||
{
|
||||
name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
|
||||
type: "usenet",
|
||||
size: 9_400_000_000,
|
||||
progress: 0.22,
|
||||
state: "downloading",
|
||||
downSpeed: 28_000_000,
|
||||
time: 244_000,
|
||||
category: "tv",
|
||||
},
|
||||
];
|
||||
|
||||
export class DownloadClientMockService implements IDownloadClientIntegration {
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
return await Promise.resolve({
|
||||
status: {
|
||||
paused: Math.random() < 0.5,
|
||||
paused: false,
|
||||
rates: {
|
||||
down: Math.floor(Math.random() * 5000),
|
||||
up: Math.floor(Math.random() * 5000),
|
||||
down: 121_100_000,
|
||||
up: 9_340_000,
|
||||
},
|
||||
types: ["torrent", "usenet"],
|
||||
types: ["torrent", "usenet"] as const,
|
||||
},
|
||||
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
|
||||
0,
|
||||
input.limit,
|
||||
),
|
||||
items: mockItems.slice(0, input.limit).map((item, index) => ({
|
||||
...item,
|
||||
id: `mock-${index}`,
|
||||
index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
private static createItem(index: number): DownloadClientItem {
|
||||
const progress = Math.random() < 0.5 ? Math.random() : 1;
|
||||
return {
|
||||
id: `item-${index}`,
|
||||
index,
|
||||
name: `Item ${index}`,
|
||||
type: Math.random() > 0.5 ? "torrent" : "usenet",
|
||||
progress,
|
||||
size: Math.floor(Math.random() * 10000) + 1,
|
||||
downSpeed: Math.floor(Math.random() * 5000),
|
||||
upSpeed: Math.floor(Math.random() * 5000),
|
||||
state: progress >= 1 ? "completed" : "downloading",
|
||||
time: 0,
|
||||
};
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
|
||||
title: "Inception",
|
||||
subtitle: "A mind-bending thriller",
|
||||
description:
|
||||
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
|
||||
"A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
|
||||
releaseDate: new Date("2010-07-16"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/inception_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
|
||||
},
|
||||
producer: "Warner Bros.",
|
||||
price: 14.99,
|
||||
rating: "8.8/10",
|
||||
tags: ["Sci-Fi", "Thriller"],
|
||||
href: "https://example.com/inception",
|
||||
href: "https://www.themoviedb.org/movie/27205-inception",
|
||||
length: 148,
|
||||
},
|
||||
{
|
||||
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
|
||||
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
|
||||
releaseDate: new Date("2013-09-15"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
|
||||
},
|
||||
producer: "AMC",
|
||||
rating: "9.5/10",
|
||||
tags: ["Crime", "Drama"],
|
||||
href: "https://example.com/breaking_bad",
|
||||
href: "https://www.themoviedb.org/tv/1396-breaking-bad",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
@@ -50,79 +50,86 @@ export const mockMediaReleases: MediaRelease[] = [
|
||||
description: "The fourth studio album by French electronic music duo Daft Punk.",
|
||||
releaseDate: new Date("2013-05-17"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/ram_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
|
||||
},
|
||||
producer: "Columbia Records",
|
||||
price: 9.99,
|
||||
rating: "8.5/10",
|
||||
tags: ["Electronic", "Dance", "Pop", "Funk"],
|
||||
href: "https://example.com/ram",
|
||||
tags: ["Electronic", "Funk"],
|
||||
href: "https://www.daftpunk.com",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "book",
|
||||
title: "The Great Gatsby",
|
||||
subtitle: "F. Scott Fitzgerald",
|
||||
description: "A novel about the American dream and the disillusionment that comes with it.",
|
||||
releaseDate: new Date("1925-04-10"),
|
||||
type: "movie",
|
||||
title: "The Dark Knight",
|
||||
subtitle: "Theatrical Release",
|
||||
description:
|
||||
"Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
|
||||
releaseDate: new Date("2008-07-18"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/gatsby_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
|
||||
},
|
||||
producer: "Scribner",
|
||||
price: 10.99,
|
||||
rating: "4.2/5",
|
||||
tags: ["Classic", "Fiction"],
|
||||
href: "https://example.com/gatsby",
|
||||
producer: "Warner Bros.",
|
||||
price: 12.99,
|
||||
rating: "9.0/10",
|
||||
tags: ["Action", "Crime", "Drama"],
|
||||
href: "https://www.themoviedb.org/movie/155-the-dark-knight",
|
||||
length: 152,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
type: "game",
|
||||
title: "The Legend of Zelda: Breath of the Wild",
|
||||
subtitle: "Nintendo Switch",
|
||||
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
|
||||
releaseDate: new Date("2017-03-03"),
|
||||
type: "movie",
|
||||
title: "Interstellar",
|
||||
subtitle: "Digital Release",
|
||||
description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
|
||||
releaseDate: new Date("2014-11-07"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/zelda_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
|
||||
},
|
||||
producer: "Nintendo",
|
||||
price: 59.99,
|
||||
rating: "10/10",
|
||||
tags: ["Action", "Adventure"],
|
||||
href: "https://example.com/zelda",
|
||||
producer: "Paramount",
|
||||
price: 14.99,
|
||||
rating: "8.7/10",
|
||||
tags: ["Sci-Fi", "Adventure"],
|
||||
href: "https://www.themoviedb.org/movie/157336-interstellar",
|
||||
length: 169,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
type: "article",
|
||||
title: "The Rise of AI in Healthcare",
|
||||
subtitle: "Tech Innovations",
|
||||
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
|
||||
releaseDate: new Date("2023-10-01"),
|
||||
type: "tv",
|
||||
title: "Severance",
|
||||
subtitle: "S2E1 - Premiere",
|
||||
description:
|
||||
"Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
|
||||
releaseDate: new Date("2025-01-17"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
|
||||
poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
|
||||
},
|
||||
producer: "Tech Innovations",
|
||||
rating: "4.8/5",
|
||||
tags: ["Technology", "Healthcare"],
|
||||
href: "https://example.com/ai_healthcare",
|
||||
producer: "Apple TV+",
|
||||
rating: "8.7/10",
|
||||
tags: ["Thriller", "Sci-Fi"],
|
||||
href: "https://www.themoviedb.org/tv/95396-severance",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
type: "video",
|
||||
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
|
||||
releaseDate: new Date("2024-05-18T17:00:00Z"),
|
||||
type: "movie",
|
||||
title: "Dune: Part Two",
|
||||
subtitle: "Theatrical Release",
|
||||
description:
|
||||
"Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
|
||||
releaseDate: new Date("2024-03-01"),
|
||||
imageUrls: {
|
||||
poster:
|
||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||
backdrop:
|
||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||
poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
|
||||
backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
},
|
||||
producer: "PietSmiet",
|
||||
rating: "1K",
|
||||
tags: [],
|
||||
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
|
||||
producer: "Warner Bros.",
|
||||
price: 19.99,
|
||||
rating: "8.5/10",
|
||||
tags: ["Sci-Fi", "Adventure"],
|
||||
href: "https://www.themoviedb.org/movie/693134-dune-part-two",
|
||||
length: 166,
|
||||
},
|
||||
];
|
||||
@@ -1,36 +1,95 @@
|
||||
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
|
||||
|
||||
export class MediaServerMockService implements IMediaServerIntegration {
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
return await Promise.resolve(
|
||||
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
|
||||
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
const sessions: StreamSession[] = [
|
||||
{
|
||||
sessionId: "s-0",
|
||||
sessionName: "Living Room TV",
|
||||
user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "movie",
|
||||
name: "Interstellar",
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "s-1",
|
||||
sessionName: "Bedroom Chromecast",
|
||||
user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "video",
|
||||
name: "Breaking Bad",
|
||||
seasonName: "Season 5",
|
||||
episodeName: "Ozymandias",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "s-2",
|
||||
sessionName: "Office Desktop",
|
||||
user: { userId: "u-2", username: "mike", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "movie",
|
||||
name: "The Dark Knight",
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "s-3",
|
||||
sessionName: "iPad",
|
||||
user: { userId: "u-3", username: "emily", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "video",
|
||||
name: "Severance",
|
||||
seasonName: "Season 2",
|
||||
episodeName: "The We We Are",
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "s-4",
|
||||
sessionName: "Mobile",
|
||||
user: { userId: "u-4", username: "alex", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "audio",
|
||||
name: "Daft Punk",
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: "Random Access Memories",
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "s-5",
|
||||
sessionName: "Guest Room",
|
||||
user: { userId: "u-5", username: "guest", profilePictureUrl: null },
|
||||
currentlyPlaying: {
|
||||
type: "movie",
|
||||
name: "Oppenheimer",
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private static createSession(index: number): StreamSession {
|
||||
return {
|
||||
sessionId: `session-${index}`,
|
||||
sessionName: `Session ${index}`,
|
||||
user: {
|
||||
userId: `user-${index}`,
|
||||
username: `User${index}`,
|
||||
profilePictureUrl: "/images/mock/avatar.jpg",
|
||||
},
|
||||
currentlyPlaying:
|
||||
Math.random() > 0.9 // 10% chance of being null (not currently playing)
|
||||
? {
|
||||
type: "movie",
|
||||
name: `Movie ${index}`,
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
export class MediaServerMockService implements IMediaServerIntegration {
|
||||
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
return await Promise.resolve(sessions);
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
|
||||
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
|
||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||
return await Promise.resolve({
|
||||
lan: {
|
||||
guests: 5,
|
||||
users: 10,
|
||||
status: "enabled",
|
||||
},
|
||||
vpn: {
|
||||
users: 3,
|
||||
status: "enabled",
|
||||
},
|
||||
wanStatus: "disabled",
|
||||
wifi: {
|
||||
status: "disabled",
|
||||
guests: 0,
|
||||
users: 0,
|
||||
},
|
||||
www: {
|
||||
latency: 22,
|
||||
status: "enabled",
|
||||
ping: 32,
|
||||
uptime: 3600,
|
||||
},
|
||||
lan: { guests: 5, users: 18, status: "enabled" },
|
||||
vpn: { users: 3, status: "enabled" },
|
||||
wanStatus: "enabled",
|
||||
wifi: { status: "enabled", guests: 2, users: 12 },
|
||||
www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class MockIntegration
|
||||
|
||||
// Health Monitoring Integrations
|
||||
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
|
||||
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
|
||||
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
|
||||
|
||||
// IndexerManagerIntegration
|
||||
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
||||
@@ -104,7 +104,7 @@ export class MockIntegration
|
||||
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
|
||||
|
||||
// MediaServerIntegration
|
||||
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
|
||||
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
|
||||
|
||||
// MediaTranscodingIntegration
|
||||
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NotificationData } from "@mantine/notifications";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
|
||||
|
||||
export const showSuccessNotification = (props: NotificationData) =>
|
||||
notifications.show({
|
||||
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
|
||||
color: "red",
|
||||
icon: <IconX size={20} />,
|
||||
});
|
||||
|
||||
export const showWarningNotification = (props: NotificationData) =>
|
||||
notifications.show({
|
||||
...props,
|
||||
color: "yellow",
|
||||
icon: <IconInfoCircle size={20} />,
|
||||
});
|
||||
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
|
||||
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
||||
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
||||
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
||||
withBorder: () => undefined,
|
||||
},
|
||||
calendar: {
|
||||
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||
|
||||
@@ -7,17 +7,174 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db, like, or } from "@homarr/db";
|
||||
import { icons } from "@homarr/db/schema";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import type { ContainerState, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "dockerRequestHandler" });
|
||||
|
||||
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
|
||||
|
||||
const port = (privatePort: number, publicPort: number, type: string): Port => ({
|
||||
IP: "0.0.0.0",
|
||||
PrivatePort: privatePort,
|
||||
PublicPort: publicPort,
|
||||
Type: type,
|
||||
});
|
||||
|
||||
const mockContainers: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
state: ContainerState;
|
||||
image: string;
|
||||
iconUrl: string;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
ports: Port[];
|
||||
}[] = [
|
||||
{
|
||||
id: "a1b2c3d4e5f6",
|
||||
name: "sonarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/sonarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
|
||||
cpuUsage: 2.3,
|
||||
memoryUsage: 256 * 1024 * 1024,
|
||||
ports: [port(8989, 8989, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "b2c3d4e5f6a7",
|
||||
name: "radarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/radarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
|
||||
cpuUsage: 1.8,
|
||||
memoryUsage: 220 * 1024 * 1024,
|
||||
ports: [port(7878, 7878, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "c3d4e5f6a7b8",
|
||||
name: "plex",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/plex:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
|
||||
cpuUsage: 12.5,
|
||||
memoryUsage: 1024 * 1024 * 1024,
|
||||
ports: [port(32400, 32400, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "d4e5f6a7b8c9",
|
||||
name: "qbittorrent",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/qbittorrent:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
|
||||
cpuUsage: 5.1,
|
||||
memoryUsage: 380 * 1024 * 1024,
|
||||
ports: [port(8080, 8080, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "e5f6a7b8c9d0",
|
||||
name: "prowlarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/prowlarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
|
||||
cpuUsage: 0.8,
|
||||
memoryUsage: 120 * 1024 * 1024,
|
||||
ports: [port(9696, 9696, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "f6a7b8c9d0e1",
|
||||
name: "overseerr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/overseerr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
|
||||
cpuUsage: 1.2,
|
||||
memoryUsage: 180 * 1024 * 1024,
|
||||
ports: [port(5055, 5055, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "a7b8c9d0e1f2",
|
||||
name: "homarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "ghcr.io/homarr-labs/homarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
cpuUsage: 3.4,
|
||||
memoryUsage: 290 * 1024 * 1024,
|
||||
ports: [port(7575, 7575, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "b8c9d0e1f2a3",
|
||||
name: "pihole",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "pihole/pihole:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
|
||||
cpuUsage: 0.5,
|
||||
memoryUsage: 95 * 1024 * 1024,
|
||||
ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
|
||||
},
|
||||
{
|
||||
id: "c9d0e1f2a3b4",
|
||||
name: "nginx-proxy",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "jc21/nginx-proxy-manager:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
|
||||
cpuUsage: 0.3,
|
||||
memoryUsage: 65 * 1024 * 1024,
|
||||
ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
|
||||
},
|
||||
{
|
||||
id: "d0e1f2a3b4c5",
|
||||
name: "watchtower",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "containrrr/watchtower:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
|
||||
cpuUsage: 0.1,
|
||||
memoryUsage: 30 * 1024 * 1024,
|
||||
ports: [],
|
||||
},
|
||||
{
|
||||
id: "e1f2a3b4c5d6",
|
||||
name: "tdarr",
|
||||
host: "local",
|
||||
state: "exited",
|
||||
image: "ghcr.io/haveagitgat/tdarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
ports: [],
|
||||
},
|
||||
{
|
||||
id: "f2a3b4c5d6e7",
|
||||
name: "bazarr",
|
||||
host: "local",
|
||||
state: "running",
|
||||
image: "lscr.io/linuxserver/bazarr:latest",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
|
||||
cpuUsage: 0.6,
|
||||
memoryUsage: 110 * 1024 * 1024,
|
||||
ports: [port(6767, 6767, "tcp")],
|
||||
},
|
||||
];
|
||||
|
||||
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "dockerContainersResult",
|
||||
widgetKind: "dockerContainers",
|
||||
async requestAsync() {
|
||||
if (isDemoMode) {
|
||||
return mockContainers;
|
||||
}
|
||||
return await getContainersWithStatsAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(20, "seconds"),
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./src/context";
|
||||
export * from "./src/preferences/definitions";
|
||||
@@ -0,0 +1,78 @@
|
||||
export const userPreferenceDefinitions = [
|
||||
{
|
||||
key: "colorScheme",
|
||||
kind: "select",
|
||||
guest: true,
|
||||
mutationGroup: "colorScheme",
|
||||
aliases: ["theme", "dark", "light", "auto"],
|
||||
},
|
||||
{ key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
|
||||
{
|
||||
key: "defaultSearchEngineId",
|
||||
kind: "searchEngine",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["search", "search engine"],
|
||||
},
|
||||
{
|
||||
key: "openSearchInNewTab",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["search", "new tab", "external"],
|
||||
},
|
||||
{
|
||||
key: "ddgBangs",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "searchPreferences",
|
||||
aliases: ["duckduckgo", "bangs", "search"],
|
||||
},
|
||||
{
|
||||
key: "firstDayOfWeek",
|
||||
kind: "select",
|
||||
guest: false,
|
||||
mutationGroup: "firstDayOfWeek",
|
||||
aliases: ["calendar", "week", "weekday"],
|
||||
},
|
||||
{
|
||||
key: "homeBoardId",
|
||||
kind: "board",
|
||||
guest: false,
|
||||
mutationGroup: "homeBoards",
|
||||
aliases: ["home", "board", "dashboard"],
|
||||
},
|
||||
{
|
||||
key: "mobileHomeBoardId",
|
||||
kind: "board",
|
||||
guest: false,
|
||||
mutationGroup: "homeBoards",
|
||||
aliases: ["mobile", "home", "board", "dashboard"],
|
||||
},
|
||||
{
|
||||
key: "pingIconsEnabled",
|
||||
kind: "boolean",
|
||||
guest: false,
|
||||
mutationGroup: "pingIconsEnabled",
|
||||
aliases: ["ping", "icon", "status"],
|
||||
},
|
||||
{
|
||||
key: "fullPreferencesPage",
|
||||
kind: "link",
|
||||
guest: false,
|
||||
mutationGroup: null,
|
||||
aliases: ["settings", "manage", "profile"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
|
||||
export type UserPreferenceKey = UserPreferenceDefinition["key"];
|
||||
export type UserPreferenceKind = UserPreferenceDefinition["kind"];
|
||||
export type UserPreferenceMutationGroup = Exclude<UserPreferenceDefinition["mutationGroup"], null>;
|
||||
|
||||
export const userPreferenceDefinitionByKey = Object.fromEntries(
|
||||
userPreferenceDefinitions.map((definition) => [definition.key, definition]),
|
||||
) as Record<UserPreferenceKey, UserPreferenceDefinition>;
|
||||
|
||||
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
|
||||
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
|
||||
@@ -29,6 +29,7 @@
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
|
||||
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
|
||||
<Spotlight.Action
|
||||
renderRoot={renderRoot}
|
||||
onClick={onClick}
|
||||
closeSpotlightOnTrigger={interaction.type !== "children"}
|
||||
closeSpotlightOnTrigger={
|
||||
interaction.type !== "children" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
<action.Component {...childrenOptions.option} />
|
||||
|
||||
@@ -61,7 +61,8 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
interaction.type !== "mode" &&
|
||||
interaction.type !== "children" &&
|
||||
interaction.type !== "none" &&
|
||||
interaction.type !== "setQuery"
|
||||
interaction.type !== "setQuery" &&
|
||||
(interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
|
||||
}
|
||||
className={classes.spotlightAction}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
|
||||
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
|
||||
import { IconSearch, IconX } from "@tabler/icons-react";
|
||||
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
@@ -15,7 +15,7 @@ import type { OpenMediaRequestSearchOptions } from "../index";
|
||||
import type { inferSearchInteractionOptions } from "../lib/interaction";
|
||||
import type { SearchMode } from "../lib/mode";
|
||||
import { searchModes } from "../modes";
|
||||
import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
|
||||
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
|
||||
import {
|
||||
mediaRequestSearchEvent,
|
||||
mediaRequestSearchScopeAtom,
|
||||
@@ -27,6 +27,7 @@ import { SpotlightChildrenActions } from "./actions/children-actions";
|
||||
import { SpotlightActionGroups } from "./actions/groups/action-group";
|
||||
|
||||
type SearchModeKey = keyof TranslationObject["search"]["mode"];
|
||||
type ChildrenOptions = inferSearchInteractionOptions<"children">;
|
||||
|
||||
const defaultMode = "home";
|
||||
export const Spotlight = () => {
|
||||
@@ -39,7 +40,6 @@ export const Spotlight = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We use the "key" below to prevent the 'Different amounts of hooks' error
|
||||
return (
|
||||
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
|
||||
);
|
||||
@@ -54,22 +54,41 @@ interface SpotlightWithActiveModeProps {
|
||||
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
|
||||
const [query, setQuery] = queryState;
|
||||
const [mode, setMode] = modeState;
|
||||
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
|
||||
const childrenOptions = childrenStack.at(-1) ?? null;
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
|
||||
// Works as always the same amount of hooks are executed
|
||||
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
|
||||
const groups = useGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroups();
|
||||
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
|
||||
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
|
||||
|
||||
const clearChildrenStack = useCallback(() => {
|
||||
setChildrenStack([]);
|
||||
}, []);
|
||||
|
||||
const pushChildrenOptions = useCallback(
|
||||
(options: ChildrenOptions) => {
|
||||
setChildrenStack((currentStack) => [...currentStack, options]);
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
},
|
||||
[setQuery],
|
||||
);
|
||||
|
||||
const popChildrenOptions = useCallback(() => {
|
||||
setChildrenStack((currentStack) => currentStack.slice(0, -1));
|
||||
setQuery("");
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}, [setQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMediaRequestSearch = (event: Event) => {
|
||||
const { integrationIds, query } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
|
||||
setMediaRequestSearchScope({ integrationIds });
|
||||
setMode("media");
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
setQuery(query ?? "");
|
||||
spotlightActions.open();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
@@ -79,7 +98,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
return () => {
|
||||
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
|
||||
};
|
||||
}, [setMediaRequestSearchScope, setMode, setQuery]);
|
||||
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "media") {
|
||||
@@ -94,7 +113,7 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
|
||||
onSpotlightClose={() => {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
setQuery("");
|
||||
}}
|
||||
query={query}
|
||||
@@ -102,17 +121,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
|
||||
setQuery(sanitizedQuery);
|
||||
|
||||
// Only switch modes when a single trigger character is typed
|
||||
if (sanitizedQuery.length !== 1) return;
|
||||
|
||||
const modeToActivate = searchModes.find((mode) => mode.character === sanitizedQuery);
|
||||
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
|
||||
if (!modeToActivate) return;
|
||||
|
||||
setMode(modeToActivate.modeKey);
|
||||
setChildrenOptions(null);
|
||||
|
||||
clearChildrenStack();
|
||||
setQuery("");
|
||||
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
store={spotlightStore}
|
||||
@@ -135,31 +151,53 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
},
|
||||
}}
|
||||
rightSection={
|
||||
mode === defaultMode ? null : (
|
||||
mode === defaultMode && childrenStack.length === 0 ? null : (
|
||||
<ActionIcon
|
||||
title={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
onClick={() => {
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
variant="subtle"
|
||||
aria-label={t("common.action.close")}
|
||||
aria-label={
|
||||
childrenStack.length > 0
|
||||
? t("search.mode.command.group.preferences.children.detail.backAction")
|
||||
: t("common.action.close")
|
||||
}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
value={query}
|
||||
onKeyDown={(event) => {
|
||||
if (query.length === 0 && mode !== defaultMode && event.key === "Backspace") {
|
||||
if (query.length !== 0 || event.key !== "Backspace") return;
|
||||
|
||||
if (childrenStack.length > 0) {
|
||||
popChildrenOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== defaultMode) {
|
||||
setMode(defaultMode);
|
||||
setChildrenOptions(null);
|
||||
clearChildrenStack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{childrenOptions ? (
|
||||
<Group>
|
||||
<Group key={childrenStack.length}>
|
||||
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||
</Group>
|
||||
) : null}
|
||||
@@ -167,25 +205,21 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.ActionsList>
|
||||
{childrenOptions ? (
|
||||
<SpotlightChildrenActions
|
||||
key={childrenStack.length}
|
||||
childrenOptions={childrenOptions}
|
||||
query={query}
|
||||
setChildrenOptions={setChildrenOptions}
|
||||
setChildrenOptions={pushChildrenOptions}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightActionGroups
|
||||
setQuery={setQuery}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
setChildrenOptions(null);
|
||||
setMode={(nextMode) => {
|
||||
setMode(nextMode);
|
||||
clearChildrenStack();
|
||||
setTimeout(() => selectAction(0, spotlightStore));
|
||||
}}
|
||||
setChildrenOptions={(options) => {
|
||||
setChildrenOptions(options);
|
||||
|
||||
setTimeout(() => {
|
||||
setQuery("");
|
||||
selectAction(0, spotlightStore);
|
||||
});
|
||||
pushChildrenOptions(options);
|
||||
}}
|
||||
query={query}
|
||||
groups={mode === defaultMode && query.length === 0 ? [...homeEmptyGroups] : groups}
|
||||
|
||||
@@ -10,7 +10,10 @@ const createSearchInteraction = <TType extends string>(type: TType) => ({
|
||||
// This is used to define search interactions with their options
|
||||
const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{
|
||||
onSelect: () => MaybePromise<void>;
|
||||
closeSpotlightOnTrigger?: boolean;
|
||||
}>(),
|
||||
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
|
||||
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
|
||||
createSearchInteraction("children").optionsType<{
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { useUserPreference } from "../../../preferences/use-user-preference";
|
||||
import { LanguageIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../lib/children";
|
||||
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./preferences/action-row";
|
||||
|
||||
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { value: currentLocale, setValue: setLocale, isPending } = useUserPreference("locale");
|
||||
|
||||
return supportedLanguages
|
||||
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
|
||||
.filter(
|
||||
@@ -18,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
configuration.name.toLowerCase().includes(normalizedQuery) ||
|
||||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
.sort(
|
||||
.toSorted(
|
||||
(languageA, languageB) =>
|
||||
Math.min(
|
||||
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
|
||||
@@ -29,38 +30,25 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
||||
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
|
||||
),
|
||||
)
|
||||
.map(({ localeKey, configuration }) => ({
|
||||
key: localeKey,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
.map(({ localeKey, configuration }) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: localeKey,
|
||||
isSelected: localeKey === currentLocale,
|
||||
onSelect: () => setLocale(localeKey as never),
|
||||
isPending,
|
||||
labelContent: (
|
||||
<Group wrap="nowrap">
|
||||
<LanguageIcon icon={localeConfigurations[localeKey].icon} />
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{configuration.name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({configuration.translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction() {
|
||||
const { changeLocale } = useChangeLocale();
|
||||
|
||||
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
|
||||
},
|
||||
}));
|
||||
},
|
||||
DetailComponent: () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey="locale.children.detail.title" />,
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { interaction } from "../../../../lib/interaction";
|
||||
|
||||
export const PreferenceDetailHeader = ({ titleKey }: { titleKey: string }) => {
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{tOption(titleKey as never)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const createCheckmarkPreferenceAction = ({
|
||||
key,
|
||||
label,
|
||||
labelContent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
dimmed,
|
||||
isPending,
|
||||
}: {
|
||||
key: string;
|
||||
label?: string;
|
||||
labelContent?: ReactNode;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
dimmed?: boolean;
|
||||
isPending?: boolean;
|
||||
}) => ({
|
||||
key,
|
||||
Component() {
|
||||
return (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%" opacity={isPending ? 0.5 : 1}>
|
||||
{labelContent ?? <Text c={dimmed ? "dimmed" : undefined}>{label}</Text>}
|
||||
{isSelected && <IconCheck color="currentColor" size={24} />}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: isPending ? () => undefined : onSelect,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
|
||||
export const createLoadingPreferenceAction = () => ({
|
||||
key: "loading",
|
||||
Component() {
|
||||
return (
|
||||
<Center mx="md" my="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: () => undefined,
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { settingsChildrenOptions } from "./settings-children";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createCheckmarkPreferenceAction } from "./action-row";
|
||||
|
||||
interface SelectablePreferenceItem {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const createSelectablePreferenceActions = ({
|
||||
query,
|
||||
currentValue,
|
||||
noneLabel,
|
||||
unavailableLabel,
|
||||
items,
|
||||
onSelect,
|
||||
isPending,
|
||||
}: {
|
||||
query: string;
|
||||
currentValue: string | null;
|
||||
noneLabel: string;
|
||||
unavailableLabel: string;
|
||||
items: SelectablePreferenceItem[];
|
||||
onSelect: (value: string | null) => void;
|
||||
isPending?: boolean;
|
||||
}) => {
|
||||
const hasCurrentSelection = currentValue !== null && !items.some((item) => item.value === currentValue);
|
||||
const orphanedItem: SelectablePreferenceItem | null = hasCurrentSelection
|
||||
? { key: `orphaned-${currentValue}`, label: unavailableLabel, value: currentValue }
|
||||
: null;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredItems = items.filter((item) => item.label.toLowerCase().includes(normalizedQuery));
|
||||
const visibleItems = orphanedItem ? [orphanedItem, ...filteredItems] : filteredItems;
|
||||
|
||||
const noneAction = createCheckmarkPreferenceAction({
|
||||
key: "none",
|
||||
label: noneLabel,
|
||||
isSelected: currentValue === null,
|
||||
onSelect: () => onSelect(null),
|
||||
dimmed: true,
|
||||
isPending,
|
||||
});
|
||||
|
||||
const itemActions = visibleItems.map((item) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
isSelected: currentValue === item.value,
|
||||
onSelect: () => onSelect(item.value),
|
||||
dimmed: item.key.startsWith("orphaned-"),
|
||||
isPending,
|
||||
}),
|
||||
);
|
||||
|
||||
return [noneAction, ...itemActions];
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Stack, Switch, Text } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||
import { localeConfigurations } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../../../../lib/children";
|
||||
import type { ChildrenAction } from "../../../../lib/children";
|
||||
import { interaction } from "../../../../lib/interaction";
|
||||
import { useUserPreferences } from "../../../../preferences/use-user-preference";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
type SettingsAction = ChildrenAction<Record<string, unknown>>;
|
||||
|
||||
const BooleanRow = ({
|
||||
label,
|
||||
Icon,
|
||||
checked,
|
||||
isPending,
|
||||
}: {
|
||||
label: string;
|
||||
Icon: TablerIcon;
|
||||
checked: boolean;
|
||||
isPending: boolean;
|
||||
}) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
<Switch checked={checked} readOnly size="md" disabled={isPending} styles={{ track: { cursor: "pointer" } }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{valueLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
|
||||
<Group mx="md" my="sm" wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} size={20} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
|
||||
|
||||
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||
const { data: session } = useSession();
|
||||
const isAuthenticated = Boolean(session?.user);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const preferences = useUserPreferences();
|
||||
|
||||
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
|
||||
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
|
||||
|
||||
const valueLabelResolvers: Record<string, () => string> = {
|
||||
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
|
||||
locale: () => {
|
||||
const val = preferences.getPreference("locale").value;
|
||||
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
|
||||
},
|
||||
defaultSearchEngineId: () =>
|
||||
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
|
||||
?.label ?? t("searchEngine.none" as never),
|
||||
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
|
||||
homeBoardId: () =>
|
||||
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
|
||||
t("board.none" as never),
|
||||
mobileHomeBoardId: () =>
|
||||
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
|
||||
t("board.none" as never),
|
||||
};
|
||||
|
||||
return visiblePreferenceDefinitions(isAuthenticated)
|
||||
.map((definition): SettingsAction | null => {
|
||||
const label = t(`${definition.key}.label` as never);
|
||||
const Icon = preferenceIcons[definition.key];
|
||||
|
||||
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
|
||||
if (!matchesSearch) return null;
|
||||
|
||||
if (definition.kind === "boolean") {
|
||||
const pref = preferences.getPreference(definition.key);
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => (
|
||||
<BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />
|
||||
),
|
||||
useInteraction: interaction.javaScript(() => ({
|
||||
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
|
||||
closeSpotlightOnTrigger: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.kind === "link") {
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => <LinkRow label={label} Icon={Icon} />,
|
||||
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
|
||||
};
|
||||
}
|
||||
|
||||
if (SELECT_KINDS.has(definition.kind)) {
|
||||
const children = preferenceChildrenOptionsByKey[definition.key];
|
||||
if (!children) return null;
|
||||
return {
|
||||
key: definition.key,
|
||||
Component: () => (
|
||||
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
|
||||
),
|
||||
useInteraction: interaction.children(children),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((action): action is SettingsAction => action !== null);
|
||||
};
|
||||
|
||||
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: useSettingsActions,
|
||||
DetailComponent: () => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
return (
|
||||
<Stack mx="md" my="sm" gap="xs">
|
||||
<Text>{t("title")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("children.detail.backHint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconBox,
|
||||
IconCategoryPlus,
|
||||
IconDeviceDesktop,
|
||||
IconFileImport,
|
||||
IconLanguage,
|
||||
IconMailForward,
|
||||
IconMoon,
|
||||
IconPlug,
|
||||
IconSun,
|
||||
IconUserPlus,
|
||||
IconUsersGroup,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -22,7 +18,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { createGroup } from "../../lib/group";
|
||||
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
|
||||
import { interaction } from "../../lib/interaction";
|
||||
import { languageChildrenOptions } from "./children/language";
|
||||
import { newIntegrationChildrenOptions } from "./children/new-integration";
|
||||
|
||||
// This has to be type so it can be interpreted as Record<string, unknown>.
|
||||
@@ -52,33 +47,9 @@ export const globalCommandGroup = createGroup<Command>({
|
||||
},
|
||||
useOptions() {
|
||||
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme("light");
|
||||
const { data: session } = useSession();
|
||||
|
||||
const commands: (Command & { hidden?: boolean })[] = [
|
||||
{
|
||||
commandKey: "colorScheme",
|
||||
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
|
||||
name:
|
||||
colorScheme === "auto"
|
||||
? tOption("colorScheme.auto")
|
||||
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
|
||||
useInteraction: () => {
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
|
||||
return {
|
||||
type: "javaScript",
|
||||
onSelect: toggleColorScheme,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "language",
|
||||
icon: IconLanguage,
|
||||
name: tOption("language.label"),
|
||||
useInteraction: interaction.children(languageChildrenOptions),
|
||||
},
|
||||
{
|
||||
commandKey: "newBoard",
|
||||
icon: IconCategoryPlus,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
|
||||
import { globalCommandGroup } from "./global-group";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
|
||||
export const commandMode = {
|
||||
modeKey: "command",
|
||||
character: ">",
|
||||
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
|
||||
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
|
||||
} satisfies SearchMode;
|
||||
@@ -21,11 +21,11 @@ import { commandMode } from "../command";
|
||||
import { externalMode } from "../external";
|
||||
import { mediaMode } from "../media";
|
||||
import { pageMode } from "../page";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { userGroupMode } from "../user-group";
|
||||
|
||||
// We need type to allow it as generic type for Record<string, unknown>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type QuickLinkOption = {
|
||||
key: string;
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -42,19 +42,13 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
return [
|
||||
createGroup({
|
||||
keyPath: "path",
|
||||
keyPath: "key",
|
||||
title: () => "Quick links",
|
||||
options: (() => {
|
||||
const quickLinks: QuickLinkOption[] = [];
|
||||
if (session?.user) {
|
||||
quickLinks.push({
|
||||
icon: IconSettings,
|
||||
path: `/manage/users/${session.user.id}/general`,
|
||||
name: tPages("preferences.label"),
|
||||
});
|
||||
}
|
||||
|
||||
quickLinks.push({
|
||||
key: "manageBoards",
|
||||
icon: IconLayoutDashboard,
|
||||
path: "/manage/boards",
|
||||
name: tPages("manageBoard.label"),
|
||||
@@ -62,6 +56,7 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session?.user.permissions.includes("admin")) {
|
||||
quickLinks.push({
|
||||
key: "manageSettings",
|
||||
icon: IconSettings,
|
||||
path: "/manage/settings",
|
||||
name: tPages("manageSettings.label"),
|
||||
@@ -70,6 +65,7 @@ export const useHomeEmptyGroups = () => {
|
||||
|
||||
if (session) {
|
||||
quickLinks.push({
|
||||
key: "manageSearchEngines",
|
||||
icon: IconSearch,
|
||||
path: "/manage/search-engines",
|
||||
name: tPages("manageSearchEngine.label"),
|
||||
@@ -139,3 +135,5 @@ export const useHomeEmptyGroups = () => {
|
||||
}),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SearchMode } from "../../lib/mode";
|
||||
import { preferencesGroup } from "../preferences/groups";
|
||||
import { contextSpecificSearchGroups } from "./context-specific-group";
|
||||
import { homeSearchEngineGroup } from "./home-search-engine-group";
|
||||
|
||||
export const homeMode = {
|
||||
character: undefined,
|
||||
modeKey: "home",
|
||||
groups: [homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
|
||||
} satisfies SearchMode;
|
||||
@@ -55,8 +55,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
|
||||
|
||||
const { result } = option;
|
||||
const hasAvailabilityLabel =
|
||||
result.availability &&
|
||||
(availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
|
||||
const badgeLabel = hasAvailabilityLabel
|
||||
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
|
||||
: undefined;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import { visiblePreferenceDefinitions } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
|
||||
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
|
||||
|
||||
type PreferencesGroupOption = { key: "settings"; preferenceKey: null } | { key: string; preferenceKey: string };
|
||||
|
||||
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
|
||||
|
||||
export const preferencesGroup = createGroup<PreferencesGroupOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.command.group.preferences.title"),
|
||||
useOptions(query) {
|
||||
const t = useI18n();
|
||||
const tColorScheme = useScopedI18n("common.colorScheme.options");
|
||||
const { data: session } = useSession();
|
||||
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (q.length === 0) return [settingsOption];
|
||||
|
||||
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
|
||||
if (!preferenceChildrenOptionsByKey[def.key]) return [];
|
||||
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
|
||||
const searchValues = [
|
||||
label,
|
||||
...def.aliases,
|
||||
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
|
||||
];
|
||||
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
|
||||
return [{ key: def.key, preferenceKey: def.key }];
|
||||
});
|
||||
|
||||
if (matching.length > 0) return matching;
|
||||
|
||||
const allSearchValues = [
|
||||
t("search.mode.command.group.preferences.title"),
|
||||
...definitions.flatMap((d) => d.aliases),
|
||||
];
|
||||
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
|
||||
},
|
||||
Component: (option) => {
|
||||
const t = useScopedI18n("search.mode.command.group.preferences");
|
||||
const tOption = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
const Icon = option.preferenceKey
|
||||
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
|
||||
: IconSettings;
|
||||
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
|
||||
|
||||
return (
|
||||
<Group px="md" py="sm" wrap="nowrap" gap="sm">
|
||||
<Icon stroke={1.5} />
|
||||
<Text>{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
filter: () => true,
|
||||
useInteraction: (option) => ({
|
||||
type: "children",
|
||||
...(option.preferenceKey
|
||||
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
|
||||
settingsChildrenOptions({}))
|
||||
: settingsChildrenOptions({})),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconActivity,
|
||||
IconCalendarWeek,
|
||||
IconDeviceDesktop,
|
||||
IconExternalLink,
|
||||
IconHome,
|
||||
IconLanguage,
|
||||
IconLayoutBoard,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconWorldWww,
|
||||
} from "@tabler/icons-react";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { colorSchemes } from "@homarr/definitions";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { createChildrenOptions } from "../lib/children";
|
||||
import {
|
||||
createCheckmarkPreferenceAction,
|
||||
createLoadingPreferenceAction,
|
||||
PreferenceDetailHeader,
|
||||
} from "../modes/command/children/preferences/action-row";
|
||||
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
|
||||
import { languageChildrenOptions } from "../modes/command/children/language";
|
||||
import { useUserPreference } from "./use-user-preference";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
|
||||
|
||||
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
|
||||
colorScheme: IconDeviceDesktop,
|
||||
locale: IconLanguage,
|
||||
defaultSearchEngineId: IconSearch,
|
||||
openSearchInNewTab: IconExternalLink,
|
||||
ddgBangs: IconWorldWww,
|
||||
firstDayOfWeek: IconCalendarWeek,
|
||||
homeBoardId: IconHome,
|
||||
mobileHomeBoardId: IconLayoutBoard,
|
||||
pingIconsEnabled: IconActivity,
|
||||
fullPreferencesPage: IconSettings,
|
||||
};
|
||||
|
||||
const createStaticChildren = (
|
||||
preferenceKey: UserPreferenceKey,
|
||||
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
|
||||
): ChildrenFactory =>
|
||||
createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: () => {
|
||||
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||
return useOptions().map((opt) =>
|
||||
createCheckmarkPreferenceAction({
|
||||
key: opt.key,
|
||||
label: opt.label,
|
||||
isSelected: value === opt.value,
|
||||
onSelect: () => setValue(opt.value as never),
|
||||
isPending,
|
||||
}),
|
||||
);
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||
});
|
||||
|
||||
const createAsyncSelectableChildren = <TItem,>(
|
||||
preferenceKey: UserPreferenceKey,
|
||||
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
|
||||
mapItem: (item: TItem) => { key: string; label: string; value: string },
|
||||
noneKey: string,
|
||||
unavailableKey: string,
|
||||
): ChildrenFactory =>
|
||||
createChildrenOptions<Record<string, unknown>>({
|
||||
useActions: (_, query) => {
|
||||
const { value, setValue, isPending } = useUserPreference(preferenceKey);
|
||||
const result = useQuery();
|
||||
const t = useScopedI18n("search.mode.command.group.preferences.option");
|
||||
|
||||
if (result.isLoading) return [createLoadingPreferenceAction()];
|
||||
|
||||
return createSelectablePreferenceActions({
|
||||
query,
|
||||
currentValue: value as string | null,
|
||||
noneLabel: t(noneKey as never),
|
||||
unavailableLabel: t(unavailableKey as never),
|
||||
items: (result.data ?? []).map(mapItem),
|
||||
onSelect: (v) => setValue(v as never),
|
||||
isPending,
|
||||
});
|
||||
},
|
||||
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
|
||||
});
|
||||
|
||||
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
|
||||
|
||||
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
|
||||
colorScheme: createStaticChildren("colorScheme", () => {
|
||||
const t = useScopedI18n("common.colorScheme.options");
|
||||
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
|
||||
}),
|
||||
locale: languageChildrenOptions,
|
||||
defaultSearchEngineId: createAsyncSelectableChildren(
|
||||
"defaultSearchEngineId",
|
||||
() => clientApi.searchEngine.getSelectable.useQuery(),
|
||||
(e) => ({ key: e.value, label: e.label, value: e.value }),
|
||||
"searchEngine.none",
|
||||
"searchEngine.unavailable",
|
||||
),
|
||||
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
|
||||
firstDayOfWeekOptions.map((day) => ({
|
||||
key: day.toString(),
|
||||
label: dayjs.weekdays(false)[day] ?? day.toString(),
|
||||
value: day,
|
||||
})),
|
||||
),
|
||||
homeBoardId: createAsyncSelectableChildren(
|
||||
"homeBoardId",
|
||||
() => clientApi.board.getAllBoards.useQuery(),
|
||||
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||
"board.none",
|
||||
"board.unavailable",
|
||||
),
|
||||
mobileHomeBoardId: createAsyncSelectableChildren(
|
||||
"mobileHomeBoardId",
|
||||
() => clientApi.board.getAllBoards.useQuery(),
|
||||
(b) => ({ key: b.id, label: b.name, value: b.id }),
|
||||
"board.none",
|
||||
"board.unavailable",
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { userPreferenceDefinitions, userPreferenceDefinitionByKey } from "@homarr/settings";
|
||||
import type { UserPreferenceKey } from "@homarr/settings";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
|
||||
// --- Optimistic store (module-level, shared across all hook instances) ---
|
||||
|
||||
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
|
||||
let optimisticVersion = 0;
|
||||
const optimisticListeners = new Set<() => void>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
optimisticVersion += 1;
|
||||
optimisticListeners.forEach((fn) => fn());
|
||||
};
|
||||
|
||||
const subscribeOptimistic = (listener: () => void) => {
|
||||
optimisticListeners.add(listener);
|
||||
return () => optimisticListeners.delete(listener);
|
||||
};
|
||||
|
||||
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
|
||||
optimisticRef.current[key] = value;
|
||||
notifyListeners();
|
||||
};
|
||||
|
||||
const clearOptimistic = (keys: readonly UserPreferenceKey[]) => {
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
changed ||= key in optimisticRef.current;
|
||||
delete optimisticRef.current[key];
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
};
|
||||
|
||||
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
|
||||
|
||||
const isDbBacked = (key: UserPreferenceKey) => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
|
||||
};
|
||||
|
||||
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group) return [key];
|
||||
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
|
||||
};
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
export const useUserPreferences = () => {
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale } = useChangeLocale();
|
||||
const t = useI18n();
|
||||
const userId = session?.user.id;
|
||||
const pendingRef = useRef<Record<string, boolean>>({});
|
||||
const prevUserIdRef = useRef(userId);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevUserIdRef.current === userId) return;
|
||||
prevUserIdRef.current = userId;
|
||||
clearOptimistic(userPreferenceDefinitions.map((d) => d.key));
|
||||
}, [userId]);
|
||||
|
||||
useSyncExternalStore(
|
||||
subscribeOptimistic,
|
||||
() => optimisticVersion,
|
||||
() => optimisticVersion,
|
||||
);
|
||||
|
||||
const refresh = () => router.refresh();
|
||||
|
||||
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
|
||||
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
|
||||
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
|
||||
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
|
||||
|
||||
const getEffective = (key: UserPreferenceKey): unknown =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : settingsRef.current[key as keyof SettingsContextProps];
|
||||
|
||||
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
|
||||
field === changedKey ? value : getEffective(field);
|
||||
|
||||
const groupDispatchers: Record<string, (uid: string, key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
|
||||
searchPreferences: (uid, key, value) =>
|
||||
searchMutation.mutateAsync({
|
||||
userId: uid,
|
||||
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
|
||||
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
|
||||
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
|
||||
}),
|
||||
homeBoards: (uid, key, value) =>
|
||||
homeBoardsMutation.mutateAsync({
|
||||
userId: uid,
|
||||
homeBoardId: pick("homeBoardId", key, value) as string | null,
|
||||
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
|
||||
}),
|
||||
firstDayOfWeek: (uid, _key, value) => firstDayMutation.mutateAsync({ id: uid, firstDayOfWeek: value as DayOfWeek }),
|
||||
pingIconsEnabled: (uid, _key, value) => pingMutation.mutateAsync({ id: uid, pingIconsEnabled: value as boolean }),
|
||||
};
|
||||
|
||||
const groupPending: Record<string, boolean> = {
|
||||
searchPreferences: searchMutation.isPending,
|
||||
homeBoards: homeBoardsMutation.isPending,
|
||||
firstDayOfWeek: firstDayMutation.isPending,
|
||||
pingIconsEnabled: pingMutation.isPending,
|
||||
};
|
||||
|
||||
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
|
||||
const values: Record<string, unknown> = {
|
||||
colorScheme: colorScheme as ColorScheme,
|
||||
locale: currentLocale,
|
||||
fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null,
|
||||
};
|
||||
for (const def of userPreferenceDefinitions) {
|
||||
if (def.key in values) continue;
|
||||
values[def.key] = settings[def.key as keyof SettingsContextProps];
|
||||
}
|
||||
return values as Record<UserPreferenceKey, unknown>;
|
||||
}, [colorScheme, currentLocale, userId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const matchedKeys = Object.entries(persistedValues)
|
||||
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
|
||||
.map(([key]) => key as UserPreferenceKey);
|
||||
clearOptimistic(matchedKeys);
|
||||
}, [persistedValues]);
|
||||
|
||||
const getValue = (key: UserPreferenceKey) =>
|
||||
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
|
||||
|
||||
const isPreferencePending = (key: UserPreferenceKey): boolean => {
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
|
||||
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
|
||||
};
|
||||
|
||||
const clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
|
||||
colorScheme: (value) => setColorScheme(value as ColorScheme),
|
||||
locale: (value) => changeLocale(value as typeof currentLocale),
|
||||
fullPreferencesPage: () => undefined,
|
||||
};
|
||||
|
||||
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
|
||||
if (isDbBacked(key) && !userId) return;
|
||||
if (isPreferencePending(key)) return;
|
||||
|
||||
const clientSetter = clientSetters[key];
|
||||
if (clientSetter) {
|
||||
clientSetter(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = userPreferenceDefinitionByKey[key].mutationGroup;
|
||||
if (!group) return;
|
||||
const dispatcher = groupDispatchers[group];
|
||||
if (!dispatcher || !userId) return;
|
||||
|
||||
setOptimistic(key, value);
|
||||
|
||||
pendingRef.current[group] = true;
|
||||
try {
|
||||
await dispatcher(userId, key, value);
|
||||
} catch {
|
||||
clearOptimistic(getGroupKeys(key));
|
||||
showErrorNotification({
|
||||
message: t("search.mode.command.group.preferences.notification.error.message"),
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current[group] = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getPreference(preferenceKey: UserPreferenceKey) {
|
||||
return {
|
||||
value: getValue(preferenceKey),
|
||||
setValue: (value: never) => setValueAsync(preferenceKey, value),
|
||||
isPending: isPreferencePending(preferenceKey),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);
|
||||
@@ -4558,6 +4558,113 @@
|
||||
"label": "Create a new group"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select a preference to change",
|
||||
"backHint": "Press Backspace or use the back button to go back",
|
||||
"backAction": "Go back"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"error": {
|
||||
"message": "Unable to update preference"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
"preferences": {
|
||||
"label": "Open preferences"
|
||||
},
|
||||
"boolean": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"board": {
|
||||
"none": "None",
|
||||
"unavailable": "Currently selected (unavailable)"
|
||||
},
|
||||
"searchEngine": {
|
||||
"none": "None",
|
||||
"unavailable": "Currently selected (unavailable)"
|
||||
},
|
||||
"colorScheme": {
|
||||
"label": "Color scheme",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your color scheme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"label": "Language",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your preferred language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"label": "Default search engine",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your default search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openSearchInNewTab": {
|
||||
"label": "Open search results in new tab",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Open search results in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": "Enable DuckDuckGo bangs",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Enable DuckDuckGo bangs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firstDayOfWeek": {
|
||||
"label": "First day of the week",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select the first day of the week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeBoardId": {
|
||||
"label": "Home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobileHomeBoardId": {
|
||||
"label": "Mobile home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Select your mobile home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "Use icons for pings",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": "Use icons for pings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fullPreferencesPage": {
|
||||
"label": "Open full preferences page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+3
@@ -2144,6 +2144,9 @@ importers:
|
||||
'@homarr/modals-collection':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals-collection
|
||||
'@homarr/notifications':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../notifications
|
||||
'@homarr/settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../settings
|
||||
|
||||
Reference in new issue
Block a user