diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/_app-delete-button.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/_app-delete-button.tsx
new file mode 100644
index 000000000..eb34d6729
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/_app-delete-button.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useCallback } from "react";
+
+import type { RouterOutputs } from "@homarr/api";
+import { clientApi } from "@homarr/api/client";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import { useScopedI18n } from "@homarr/translation/client";
+import { ActionIcon, IconTrash } from "@homarr/ui";
+
+import { revalidatePathAction } from "../../../revalidatePathAction";
+import { modalEvents } from "../../modals";
+
+interface AppDeleteButtonProps {
+ app: RouterOutputs["app"]["all"][number];
+}
+
+export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
+ const t = useScopedI18n("app.page.delete");
+ const { mutate, isPending } = clientApi.app.delete.useMutation();
+
+ const onClick = useCallback(() => {
+ modalEvents.openConfirmModal({
+ title: t("title"),
+ children: t("message", app),
+ onConfirm: () => {
+ mutate(
+ { id: app.id },
+ {
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("notification.success.title"),
+ message: t("notification.success.message"),
+ });
+ void revalidatePathAction("/apps");
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("notification.error.title"),
+ message: t("notification.error.message"),
+ });
+ },
+ },
+ );
+ },
+ });
+ }, [app, mutate, t]);
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/_form.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/_form.tsx
new file mode 100644
index 000000000..ca73e3f4a
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/_form.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import Link from "next/link";
+
+import { useForm, zodResolver } from "@homarr/form";
+import type { TranslationFunction } from "@homarr/translation";
+import { useI18n } from "@homarr/translation/client";
+import { Button, Group, Stack, Textarea, TextInput } from "@homarr/ui";
+import type { z } from "@homarr/validation";
+import { validation } from "@homarr/validation";
+
+// TODO: add icon picker
+type FormType = z.infer;
+
+interface AppFormProps {
+ submitButtonTranslation: (t: TranslationFunction) => string;
+ initialValues?: FormType;
+ handleSubmit: (values: FormType) => void;
+ isPending: boolean;
+}
+
+export const AppForm = (props: AppFormProps) => {
+ const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
+ props;
+ const t = useI18n();
+
+ const form = useForm({
+ initialValues: initialValues ?? {
+ name: "",
+ description: "",
+ iconUrl: "",
+ href: "",
+ },
+ validate: zodResolver(validation.app.manage),
+ });
+
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/_app-edit-form.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/_app-edit-form.tsx
new file mode 100644
index 000000000..054e74e5e
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/_app-edit-form.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter } from "next/navigation";
+
+import type { RouterOutputs } from "@homarr/api";
+import { clientApi } from "@homarr/api/client";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import type { TranslationFunction } from "@homarr/translation";
+import { useScopedI18n } from "@homarr/translation/client";
+import type { validation, z } from "@homarr/validation";
+
+import { revalidatePathAction } from "~/app/revalidatePathAction";
+import { AppForm } from "../../_form";
+
+interface AppEditFormProps {
+ app: RouterOutputs["app"]["byId"];
+}
+
+export const AppEditForm = ({ app }: AppEditFormProps) => {
+ const t = useScopedI18n("app.page.edit.notification");
+ const router = useRouter();
+
+ const { mutate, isPending } = clientApi.app.update.useMutation({
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("success.title"),
+ message: t("success.message"),
+ });
+ void revalidatePathAction("/apps").then(() => {
+ router.push("/apps");
+ });
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("error.title"),
+ message: t("error.message"),
+ });
+ },
+ });
+
+ const handleSubmit = useCallback(
+ (values: z.infer) => {
+ mutate({
+ id: app.id,
+ ...values,
+ });
+ },
+ [mutate, app.id],
+ );
+
+ const submitButtonTranslation = useCallback(
+ (t: TranslationFunction) => t("common.action.save"),
+ [],
+ );
+
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx
new file mode 100644
index 000000000..d41f17ac6
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/edit/[id]/page.tsx
@@ -0,0 +1,23 @@
+import { getI18n } from "@homarr/translation/server";
+import { Container, Stack, Title } from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { AppEditForm } from "./_app-edit-form";
+
+interface AppEditPageProps {
+ params: { id: string };
+}
+
+export default async function AppEditPage({ params }: AppEditPageProps) {
+ const app = await api.app.byId({ id: params.id });
+ const t = await getI18n();
+
+ return (
+
+
+ {t("app.page.edit.title")}
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/new/_app-new-form.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/new/_app-new-form.tsx
new file mode 100644
index 000000000..0b2005bd8
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/new/_app-new-form.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter } from "next/navigation";
+
+import { clientApi } from "@homarr/api/client";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "@homarr/notifications";
+import type { TranslationFunction } from "@homarr/translation";
+import { useScopedI18n } from "@homarr/translation/client";
+import type { validation, z } from "@homarr/validation";
+
+import { revalidatePathAction } from "~/app/revalidatePathAction";
+import { AppForm } from "../_form";
+
+export const AppNewForm = () => {
+ const t = useScopedI18n("app.page.create.notification");
+ const router = useRouter();
+
+ const { mutate, isPending } = clientApi.app.create.useMutation({
+ onSuccess: () => {
+ showSuccessNotification({
+ title: t("success.title"),
+ message: t("success.message"),
+ });
+ void revalidatePathAction("/apps").then(() => {
+ router.push("/apps");
+ });
+ },
+ onError: () => {
+ showErrorNotification({
+ title: t("error.title"),
+ message: t("error.message"),
+ });
+ },
+ });
+
+ const handleSubmit = useCallback(
+ (values: z.infer) => {
+ mutate(values);
+ },
+ [mutate],
+ );
+
+ const submitButtonTranslation = useCallback(
+ (t: TranslationFunction) => t("common.action.create"),
+ [],
+ );
+
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/new/page.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/new/page.tsx
new file mode 100644
index 000000000..e208f599e
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/new/page.tsx
@@ -0,0 +1,14 @@
+import { Container, Stack, Title } from "@homarr/ui";
+
+import { AppNewForm } from "./_app-new-form";
+
+export default function AppNewPage() {
+ return (
+
+
+ New app
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx b/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx
new file mode 100644
index 000000000..af682e07f
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/(main)/apps/page.tsx
@@ -0,0 +1,117 @@
+import Link from "next/link";
+
+import type { RouterOutputs } from "@homarr/api";
+import { getI18n } from "@homarr/translation/server";
+import {
+ ActionIcon,
+ ActionIconGroup,
+ Anchor,
+ Avatar,
+ Button,
+ Card,
+ Container,
+ Group,
+ IconApps,
+ IconPencil,
+ Stack,
+ Text,
+ Title,
+} from "@homarr/ui";
+
+import { api } from "~/trpc/server";
+import { AppDeleteButton } from "./_app-delete-button";
+
+export default async function AppsPage() {
+ const apps = await api.app.all();
+
+ return (
+
+
+
+ Apps
+
+
+ {apps.length === 0 && }
+ {apps.length > 0 && (
+
+ {apps.map((app) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+interface AppCardProps {
+ app: RouterOutputs["app"]["all"][number];
+}
+
+const AppCard = ({ app }: AppCardProps) => {
+ return (
+
+
+
+
+
+ {app.name}
+ {app.description && (
+
+ {app.description}
+
+ )}
+ {app.href && (
+
+ {app.href}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const AppNoResults = async () => {
+ const t = await getI18n();
+
+ return (
+
+
+
+
+ {t("app.page.list.noResults.title")}
+
+
+ {t("app.page.list.noResults.description")}
+
+
+
+ );
+};
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 28afc6416..51187c39b 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,3 +1,4 @@
+import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { integrationRouter } from "./router/integration";
import { userRouter } from "./router/user";
@@ -7,6 +8,7 @@ export const appRouter = createTRPCRouter({
user: userRouter,
integration: integrationRouter,
board: boardRouter,
+ app: innerAppRouter,
});
// export type definition of API
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
new file mode 100644
index 000000000..9d631be1e
--- /dev/null
+++ b/packages/api/src/router/app.ts
@@ -0,0 +1,71 @@
+import { TRPCError } from "@trpc/server";
+
+import { asc, createId, eq } from "@homarr/db";
+import { apps } from "@homarr/db/schema/sqlite";
+import { validation } from "@homarr/validation";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+export const appRouter = createTRPCRouter({
+ all: publicProcedure.query(async ({ ctx }) => {
+ return await ctx.db.query.apps.findMany({
+ orderBy: asc(apps.name),
+ });
+ }),
+ byId: publicProcedure
+ .input(validation.app.byId)
+ .query(async ({ ctx, input }) => {
+ const app = await ctx.db.query.apps.findFirst({
+ where: eq(apps.id, input.id),
+ });
+
+ if (!app) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "App not found",
+ });
+ }
+
+ return app;
+ }),
+ create: publicProcedure
+ .input(validation.app.manage)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(apps).values({
+ id: createId(),
+ name: input.name,
+ description: input.description,
+ iconUrl: input.iconUrl,
+ href: input.href,
+ });
+ }),
+ update: publicProcedure
+ .input(validation.app.edit)
+ .mutation(async ({ ctx, input }) => {
+ const app = await ctx.db.query.apps.findFirst({
+ where: eq(apps.id, input.id),
+ });
+
+ if (!app) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "App not found",
+ });
+ }
+
+ await ctx.db
+ .update(apps)
+ .set({
+ name: input.name,
+ description: input.description,
+ iconUrl: input.iconUrl,
+ href: input.href,
+ })
+ .where(eq(apps.id, input.id));
+ }),
+ delete: publicProcedure
+ .input(validation.app.byId)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(apps).where(eq(apps.id, input.id));
+ }),
+});
diff --git a/packages/api/src/router/test/app.spec.ts b/packages/api/src/router/test/app.spec.ts
new file mode 100644
index 000000000..2ea48013b
--- /dev/null
+++ b/packages/api/src/router/test/app.spec.ts
@@ -0,0 +1,209 @@
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/db";
+import { apps } from "@homarr/db/schema/sqlite";
+import { createDb } from "@homarr/db/test";
+
+import { appRouter } from "../app";
+
+// Mock the auth module to return an empty session
+vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
+
+describe("all should return all apps", () => {
+ test("should return all apps", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(apps).values([
+ {
+ id: "2",
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ },
+ {
+ id: "1",
+ name: "Tabler Icons",
+ iconUrl: "https://tabler.io/favicon.ico",
+ },
+ ]);
+
+ const result = await caller.all();
+ expect(result.length).toBe(2);
+ expect(result[0]!.id).toBe("2");
+ expect(result[1]!.id).toBe("1");
+ expect(result[0]!.href).toBeDefined();
+ expect(result[0]!.description).toBeDefined();
+ expect(result[1]!.href).toBeNull();
+ expect(result[1]!.description).toBeNull();
+ });
+});
+
+describe("byId should return an app by id", () => {
+ test("should return an app by id", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ await db.insert(apps).values([
+ {
+ id: "2",
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ },
+ {
+ id: "1",
+ name: "Tabler Icons",
+ iconUrl: "https://tabler.io/favicon.ico",
+ },
+ ]);
+
+ const result = await caller.byId({ id: "2" });
+ expect(result.name).toBe("Mantine");
+ });
+
+ test("should throw an error if the app does not exist", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () => await caller.byId({ id: "2" });
+ await expect(act()).rejects.toThrow("App not found");
+ });
+});
+
+describe("create should create a new app with all arguments", () => {
+ test("should create a new app", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+ const input = {
+ name: "Mantine",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: "https://mantine.dev",
+ };
+
+ await caller.create(input);
+
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ });
+
+ test("should create a new app only with required arguments", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+ const input = {
+ name: "Mantine",
+ description: null,
+ iconUrl: "https://mantine.dev/favicon.svg",
+ href: null,
+ };
+
+ await caller.create(input);
+
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ });
+});
+
+describe("update should update an app", () => {
+ test("should update an app", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const appId = createId();
+ const toInsert = {
+ id: appId,
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ };
+
+ await db.insert(apps).values(toInsert);
+
+ const input = {
+ id: appId,
+ name: "Mantine2",
+ description: "React components and hooks library",
+ iconUrl: "https://mantine.dev/favicon.svg2",
+ href: "https://mantine.dev",
+ };
+
+ await caller.update(input);
+
+ const dbApp = await db.query.apps.findFirst();
+
+ expect(dbApp).toBeDefined();
+ expect(dbApp!.name).toBe(input.name);
+ expect(dbApp!.description).toBe(input.description);
+ expect(dbApp!.iconUrl).toBe(input.iconUrl);
+ expect(dbApp!.href).toBe(input.href);
+ });
+
+ test("should throw an error if the app does not exist", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const act = async () =>
+ await caller.update({
+ id: createId(),
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ description: null,
+ href: null,
+ });
+ await expect(act()).rejects.toThrow("App not found");
+ });
+});
+
+describe("delete should delete an app", () => {
+ test("should delete an app", async () => {
+ const db = createDb();
+ const caller = appRouter.createCaller({
+ db,
+ session: null,
+ });
+
+ const appId = createId();
+ await db.insert(apps).values({
+ id: appId,
+ name: "Mantine",
+ iconUrl: "https://mantine.dev/favicon.svg",
+ });
+
+ await caller.delete({ id: appId });
+
+ const dbApp = await db.query.apps.findFirst();
+ expect(dbApp).toBeUndefined();
+ });
+});
diff --git a/packages/db/migrations/0001_slim_swarm.sql b/packages/db/migrations/0001_slim_swarm.sql
new file mode 100644
index 000000000..051cb4b8e
--- /dev/null
+++ b/packages/db/migrations/0001_slim_swarm.sql
@@ -0,0 +1,7 @@
+CREATE TABLE `app` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `description` text,
+ `icon_url` text NOT NULL,
+ `href` text
+);
diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json
new file mode 100644
index 000000000..01cc294ee
--- /dev/null
+++ b/packages/db/migrations/meta/0001_snapshot.json
@@ -0,0 +1,722 @@
+{
+ "version": "5",
+ "dialect": "sqlite",
+ "id": "f7263224-116a-42ba-8fb1-4574cb637880",
+ "prevId": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": ["provider", "providerAccountId"],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 10
+ }
+ },
+ "indexes": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["integration_id", "item_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_section_id_section_id_fk": {
+ "name": "item_section_id_section_id_fk",
+ "tableFrom": "item",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["userId"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["userId"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ }
+}
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index f74d224ab..28dd3c9ac 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1709409142712,
"tag": "0000_sloppy_bloodstorm",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "5",
+ "when": 1709585624230,
+ "tag": "0001_slim_swarm",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 3655b38b6..3f159e3e7 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -169,6 +169,14 @@ export const items = sqliteTable("item", {
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
});
+export const apps = sqliteTable("app", {
+ id: text("id").notNull().primaryKey(),
+ name: text("name").notNull(),
+ description: text("description"),
+ iconUrl: text("icon_url").notNull(),
+ href: text("href"),
+});
+
export const integrationItems = sqliteTable(
"integration_item",
{
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index 99b6f9b8f..eb57d5148 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -28,6 +28,57 @@ export default {
create: "Create user",
},
},
+ app: {
+ page: {
+ list: {
+ title: "Apps",
+ noResults: {
+ title: "There aren't any apps.",
+ description: "Create your first app",
+ },
+ },
+ create: {
+ title: "New app",
+ notification: {
+ success: {
+ title: "Creation successful",
+ message: "The app was successfully created",
+ },
+ error: {
+ title: "Creation failed",
+ message: "The app could not be created",
+ },
+ },
+ },
+ edit: {
+ title: "Edit app",
+ notification: {
+ success: {
+ title: "Changes applied successfully",
+ message: "The app was successfully saved",
+ },
+ error: {
+ title: "Unable to apply changes",
+ message: "The app could not be saved",
+ },
+ },
+ },
+ delete: {
+ title: "Delete app",
+ message: "Are you sure you want to delete the app {name}?",
+ notification: {
+ success: {
+ title: "Deletion successful",
+ message: "The app was successfully deleted",
+ },
+ error: {
+ title: "Deletion failed",
+ message: "Unable to delete the app",
+ },
+ },
+ },
+ },
+ },
integration: {
page: {
list: {
diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts
new file mode 100644
index 000000000..db3d94d59
--- /dev/null
+++ b/packages/validation/src/app.ts
@@ -0,0 +1,18 @@
+import { z } from "zod";
+
+const manageAppSchema = z.object({
+ name: z.string().min(1).max(64),
+ description: z.string().max(512).nullable(),
+ iconUrl: z.string().min(1),
+ href: z.string().nullable(),
+});
+
+const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
+
+const byIdSchema = z.object({ id: z.string() });
+
+export const appSchemas = {
+ manage: manageAppSchema,
+ edit: editAppSchema,
+ byId: byIdSchema,
+};
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 91716e812..ba1f7daec 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,3 +1,4 @@
+import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { integrationSchemas } from "./integration";
import { userSchemas } from "./user";
@@ -6,6 +7,7 @@ export const validation = {
user: userSchemas,
integration: integrationSchemas,
board: boardSchemas,
+ app: appSchemas,
};
export { createSectionSchema, sharedItemSchema } from "./shared";