From 2f1921b392fd1c6b82ef875e64843205c7210f23 Mon Sep 17 00:00:00 2001 From: hillaliy Date: Thu, 24 Oct 2024 17:46:59 +0300 Subject: [PATCH 1/2] feat: open apps in new tab --- .../src/app/[locale]/manage/apps/page.tsx | 10 ++- .../_components/_open-apps-in-new-tab.tsx | 66 +++++++++++++++++++ .../manage/users/[userId]/general/page.tsx | 6 ++ packages/api/src/router/user.ts | 34 ++++++++++ packages/db/schema/mysql.ts | 1 + packages/db/schema/sqlite.ts | 3 +- .../apps-search-group.tsx | 66 ++++++++++--------- packages/translation/src/lang/en.ts | 14 ++++ packages/validation/src/user.ts | 5 ++ 9 files changed, 173 insertions(+), 32 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_open-apps-in-new-tab.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index 11aa2e8eb..513bd24ae 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -3,6 +3,7 @@ import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, import { IconApps, IconPencil } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; import { api } from "@homarr/api/server"; import { parseAppHrefWithVariablesServer } from "@homarr/common/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; @@ -45,6 +46,7 @@ interface AppCardProps { const AppCard = async ({ app }: AppCardProps) => { const t = await getScopedI18n("app"); + const [openAppsInNewTab] = clientApi.user.getOpenAppsInNewTabOrDefault.useSuspenseQuery(); return ( @@ -70,7 +72,13 @@ const AppCard = async ({ app }: AppCardProps) => { )} {app.href && ( - + {parseAppHrefWithVariablesServer(app.href)} )} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_open-apps-in-new-tab.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_open-apps-in-new-tab.tsx new file mode 100644 index 000000000..8cab4cd0c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_open-apps-in-new-tab.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button, Group, Stack, Switch } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import type { z } from "@homarr/validation"; +import { validation } from "@homarr/validation"; + +interface OpenAppsInNewTabProps { + user: RouterOutputs["user"]["getById"]; +} + +export const OpenAppsInNewTab = ({ user }: OpenAppsInNewTabProps) => { + const t = useI18n(); + const { mutate, isPending } = clientApi.user.changeOpenAppsInNewTab.useMutation({ + async onSettled() { + await revalidatePathActionAsync(`/manage/users/${user.id}`); + }, + onSuccess(_, variables) { + form.setInitialValues({ + openAppsInNewTab: variables.openAppsInNewTab, + }); + showSuccessNotification({ + message: t("user.action.changeOpenAppsInNewTab.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + message: t("user.action.changeOpenAppsInNewTab.notification.error.message"), + }); + }, + }); + const form = useZodForm(validation.user.openAppsInNewTab, { + initialValues: { + openAppsInNewTab: user.openAppsInNewTab, + }, + }); + + const handleSubmit = (values: FormType) => { + mutate({ + id: user.id, + ...values, + }); + }; + + return ( +
+ + + + + + + +
+ ); +}; + +type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx index fa26d7bd1..7308fed24 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/page.tsx @@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access"; import { ChangeHomeBoardForm } from "./_components/_change-home-board"; import { DeleteUserButton } from "./_components/_delete-user-button"; import { FirstDayOfWeek } from "./_components/_first-day-of-week"; +import { OpenAppsInNewTab } from "./_components/_open-apps-in-new-tab"; import { PingIconsEnabled } from "./_components/_ping-icons-enabled"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileForm } from "./_components/_profile-form"; @@ -100,6 +101,11 @@ export default async function EditUserPage({ params }: Props) { + + {tGeneral("item.openAppsInNewTab")} + + + {tGeneral("item.accessibility")} diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 805140540..21b387a88 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({ provider: true, homeBoardId: true, firstDayOfWeek: true, + openAppsInNewTab: true, pingIconsEnabled: true, }, where: eq(users.id, input.userId), @@ -457,6 +458,39 @@ export const userRouter = createTRPCRouter({ }) .where(eq(users.id, ctx.session.user.id)); }), + getOpenAppsInNewTabOrDefault: publicProcedure.query(async ({ ctx }) => { + if (!ctx.session?.user) { + return false; + } + + const user = await ctx.db.query.users.findFirst({ + columns: { + id: true, + openAppsInNewTab: true, + }, + where: eq(users.id, ctx.session.user.id), + }); + + return user?.openAppsInNewTab ?? false; + }), + changeOpenAppsInNewTab: protectedProcedure + .input(validation.user.openAppsInNewTab.and(validation.common.byId)) + .mutation(async ({ input, ctx }) => { + // Only admins can change other users open apps in new tab + if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + await ctx.db + .update(users) + .set({ + openAppsInNewTab: input.openAppsInNewTab, + }) + .where(eq(users.id, ctx.session.user.id)); + }), }); const createUserAsync = async (db: Database, input: z.infer) => { diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index e5b2b050f..c93186403 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -46,6 +46,7 @@ export const users = mysqlTable("user", { colorScheme: varchar("colorScheme", { length: 5 }).$type().default("dark").notNull(), firstDayOfWeek: tinyint("firstDayOfWeek").$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(), + openAppsInNewTab: boolean("openAppsInNewTab").default(false).notNull(), }); export const accounts = mysqlTable( diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 08a8b8c44..3ba041054 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -5,7 +5,6 @@ import { relations } from "drizzle-orm"; import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core"; import { index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; import type { BackgroundImageAttachment, BackgroundImageRepeat, @@ -20,6 +19,7 @@ import type { SupportedAuthProvider, WidgetKind, } from "@homarr/definitions"; +import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; export const apiKeys = sqliteTable("apiKey", { id: text("id").notNull().primaryKey(), @@ -47,6 +47,7 @@ export const users = sqliteTable("user", { colorScheme: text("colorScheme").$type().default("dark").notNull(), firstDayOfWeek: int("firstDayOfWeek").$type().default(1).notNull(), // Defaults to Monday pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(), + openAppsInNewTab: int("openAppsInNewTab", { mode: "boolean" }).default(false).notNull(), }); export const accounts = sqliteTable( diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx index 477842ac2..694a49b33 100644 --- a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx +++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx @@ -13,40 +13,46 @@ import { interaction } from "../../lib/interaction"; type App = { id: string; name: string; iconUrl: string; href: string | null }; const appChildrenOptions = createChildrenOptions({ - useActions: () => [ - { - key: "open", - Component: () => { - const t = useI18n(); + useActions: () => { + const [openAppsInNewTab] = clientApi.user.getOpenAppsInNewTabOrDefault.useSuspenseQuery(); + return [ + { + key: "open", + Component: () => { + const t = useI18n(); - return ( - - - {t("search.mode.appIntegrationBoard.group.app.children.action.open.label")} - - ); - }, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - useInteraction: interaction.link((option) => ({ href: option.href! })), - hide(option) { - return !option.href; + return ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.open.label")} + + ); + }, + + useInteraction: interaction.link((option) => ({ + href: option.href!, + newTab: openAppsInNewTab, + })), + hide(option) { + return !option.href; + }, }, - }, - { - key: "edit", - Component: () => { - const t = useI18n(); + { + key: "edit", + Component: () => { + const t = useI18n(); - return ( - - - {t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")} - - ); + return ( + + + {t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")} + + ); + }, + useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })), }, - useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })), - }, - ], + ]; + }, DetailComponent: ({ options }) => { const t = useI18n(); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 670be6712..9a9cd3685 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -51,6 +51,9 @@ export default { pingIconsEnabled: { label: "Use icons for pings", }, + openAppsInNewTab: { + label: "Open apps in new tab", + }, }, error: { usernameTaken: "Username already taken", @@ -119,6 +122,16 @@ export default { }, }, }, + changeOpenAppsInNewTab: { + notification: { + success: { + message: "Open apps in new tab changed successfully", + }, + error: { + message: "Unable to change Open apps in new tab", + }, + }, + }, changePingIconsEnabled: { notification: { success: { @@ -1743,6 +1756,7 @@ export default { language: "Language & Region", board: "Home board", firstDayOfWeek: "First day of the week", + openAppsInNewTab: "Open apps in new tab", accessibility: "Accessibility", }, }, diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 566e43363..9c051e135 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -108,6 +108,10 @@ const firstDayOfWeekSchema = z.object({ firstDayOfWeek: z.custom((value) => z.number().min(0).max(6).safeParse(value).success), }); +const openAppsInNewTabSchema = z.object({ + openAppsInNewTab: z.boolean(), +}); + const pingIconsEnabledSchema = z.object({ pingIconsEnabled: z.boolean(), }); @@ -125,5 +129,6 @@ export const userSchemas = { changePasswordApi: changePasswordApiSchema, changeColorScheme: changeColorSchemeSchema, firstDayOfWeek: firstDayOfWeekSchema, + openAppsInNewTab: openAppsInNewTabSchema, pingIconsEnabled: pingIconsEnabledSchema, }; From 6a6acf43a5293c6448dabb982980f6c02001af9b Mon Sep 17 00:00:00 2001 From: hillaliy Date: Thu, 24 Oct 2024 17:51:45 +0300 Subject: [PATCH 2/2] fix: deep source error --- .../src/modes/app-integration-board/apps-search-group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx index 694a49b33..48e3b27e2 100644 --- a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx +++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx @@ -30,7 +30,7 @@ const appChildrenOptions = createChildrenOptions({ }, useInteraction: interaction.link((option) => ({ - href: option.href!, + href: option.href ?? "", newTab: openAppsInNewTab, })), hide(option) {