mirror of
https://github.com/ajnart/homarr.git
synced 2026-07-04 08:37:31 +02:00
⚗️ Add database board procedure
This commit is contained in:
@@ -6,6 +6,6 @@ export default {
|
||||
driver: 'better-sqlite',
|
||||
out: './drizzle',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
url: process.env.DATABASE_URL ?? './database/db.sqlite',
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { SSRConfig } from 'next-i18next';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { useInitConfig } from '~/config/init';
|
||||
import { env } from '~/env';
|
||||
import { createTrpcServersideHelpers } from '~/server/api/helper';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { db } from '~/server/db';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { userSettings } from '~/server/db/schema';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { ConfigType } from '~/types/config';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
export default function BoardPage({
|
||||
config: initialConfig,
|
||||
dockerEnabled,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
useInitConfig(initialConfig);
|
||||
type BoardContextType = {
|
||||
boardName: string;
|
||||
layout?: string;
|
||||
board: RouterOutputs['boards']['byName'];
|
||||
};
|
||||
const BoardContext = createContext<BoardContextType>(null!);
|
||||
type BoardProviderProps = {
|
||||
boardName: string;
|
||||
layout?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
const BoardProvider = ({ children, ...props }: BoardProviderProps) => {
|
||||
const { data: board } = api.boards.byName.useQuery(props);
|
||||
|
||||
return (
|
||||
<BoardLayout dockerEnabled={dockerEnabled}>
|
||||
<Dashboard />
|
||||
</BoardLayout>
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
...props,
|
||||
board: board!,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useBoard = () => {
|
||||
const ctx = useContext(BoardContext);
|
||||
if (!ctx) throw new Error('useBoard must be used within a BoardProvider');
|
||||
return ctx.board;
|
||||
};
|
||||
|
||||
const Data = () => {
|
||||
const board = useBoard();
|
||||
|
||||
return <pre>{JSON.stringify(board, null, 2)}</pre>;
|
||||
};
|
||||
|
||||
export default function BoardPage({
|
||||
boardName,
|
||||
dockerEnabled,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
return (
|
||||
<BoardProvider boardName={boardName}>
|
||||
<BoardLayout dockerEnabled={dockerEnabled}>
|
||||
<Data />
|
||||
<Dashboard />
|
||||
</BoardLayout>
|
||||
</BoardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type BoardGetServerSideProps = {
|
||||
config: ConfigType;
|
||||
boardName: string;
|
||||
dockerEnabled: boolean;
|
||||
_nextI18Next?: SSRConfig['_nextI18Next'];
|
||||
};
|
||||
@@ -43,25 +79,23 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
const config = await getFrontendConfig(boardName);
|
||||
|
||||
if (!config.settings.access.allowGuests && !session?.user) {
|
||||
const helpers = await createTrpcServersideHelpers(ctx);
|
||||
await helpers.boards.byName.prefetch({ boardName });
|
||||
const board = await helpers.boards.byNameSimple.fetch({ boardName });
|
||||
|
||||
if (!board.allowGuests && !session?.user) {
|
||||
return {
|
||||
notFound: true,
|
||||
props: {
|
||||
primaryColor: config.settings.customization.colors.primary,
|
||||
secondaryColor: config.settings.customization.colors.secondary,
|
||||
primaryShade: config.settings.customization.colors.shade,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
config,
|
||||
primaryColor: config.settings.customization.colors.primary,
|
||||
secondaryColor: config.settings.customization.colors.secondary,
|
||||
primaryShade: config.settings.customization.colors.shade,
|
||||
boardName,
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
primaryShade: board.primaryShade,
|
||||
dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT,
|
||||
...translations,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { boards, layoutItems, layouts, sections } from '~/server/db/schema';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { generateDefaultApp } from '~/tools/shared/app';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
import { configNameSchema } from './config';
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -82,4 +85,281 @@ export const boardRouter = createTRPCRouter({
|
||||
const targetPath = `data/configs/${input.boardName}.json`;
|
||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
||||
}),
|
||||
byName: publicProcedure
|
||||
.input(z.object({ boardName: configNameSchema, layout: z.string().optional() }))
|
||||
.query(async ({ input }) => {
|
||||
const board = await getFullBoardWithLayoutSectionsAsync(
|
||||
input.boardName,
|
||||
input.layout ?? 'default'
|
||||
);
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Board not found',
|
||||
});
|
||||
}
|
||||
|
||||
const layout = board.layouts.at(0);
|
||||
if (!layout) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Layout not found',
|
||||
});
|
||||
}
|
||||
const sectionIds = layout.sections.map((x) => x.id);
|
||||
const apps = await getAppsForSectionsAsync(sectionIds);
|
||||
const widgets = await getWidgetsForSectionsAsync(sectionIds);
|
||||
|
||||
const preparedSections = layout.sections.map((section) => {
|
||||
const filteredApps = apps.filter((x) =>
|
||||
x.item.layouts.some((y) => y.sectionId === section.id)
|
||||
);
|
||||
const filteredWidgets = widgets.filter((x) =>
|
||||
x.item.layouts.some((y) => y.sectionId === section.id)
|
||||
);
|
||||
return mapSection(section, [
|
||||
...filteredApps.map(mapApp),
|
||||
...filteredWidgets.map(mapWidget),
|
||||
]);
|
||||
});
|
||||
const { layouts, ...withoutLayouts } = board;
|
||||
return {
|
||||
...withoutLayouts,
|
||||
sections: preparedSections,
|
||||
};
|
||||
}),
|
||||
byNameSimple: publicProcedure
|
||||
.input(z.object({ boardName: configNameSchema }))
|
||||
.query(async ({ input }) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: eq(boards.name, input.boardName),
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Board not found',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(board);
|
||||
return board;
|
||||
}),
|
||||
});
|
||||
|
||||
const getAppsForSectionsAsync = async (sectionIds: string[]) => {
|
||||
if (sectionIds.length === 0) return [];
|
||||
return await db.query.appItems.findMany({
|
||||
with: {
|
||||
app: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
},
|
||||
statusCodes: {
|
||||
columns: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
item: {
|
||||
with: {
|
||||
layouts: {
|
||||
where: inArray(layoutItems.sectionId, sectionIds),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getFullBoardWithLayoutSectionsAsync = async (boardName: string, layoutName: string) => {
|
||||
return await db.query.boards.findFirst({
|
||||
columns: {
|
||||
ownerId: false,
|
||||
},
|
||||
with: {
|
||||
owner: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
layouts: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
with: {
|
||||
sections: {
|
||||
orderBy: sections.position,
|
||||
},
|
||||
},
|
||||
where: eq(layouts.name, layoutName),
|
||||
},
|
||||
},
|
||||
where: eq(boards.name, boardName),
|
||||
});
|
||||
};
|
||||
|
||||
const getWidgetsForSectionsAsync = async (sectionIds: string[]) => {
|
||||
if (sectionIds.length === 0) return [];
|
||||
return await db.query.widgets.findMany({
|
||||
with: {
|
||||
options: true,
|
||||
item: {
|
||||
with: {
|
||||
layouts: {
|
||||
where: inArray(layoutItems.sectionId, sectionIds),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type FullBoardWithLayout = Exclude<
|
||||
Awaited<ReturnType<typeof getFullBoardWithLayoutSectionsAsync>>,
|
||||
undefined
|
||||
>;
|
||||
|
||||
type MapSection = FullBoardWithLayout['layouts'][number]['sections'][number];
|
||||
type MapApp = Awaited<ReturnType<typeof getAppsForSectionsAsync>>[number];
|
||||
type MapWidget = Awaited<ReturnType<typeof getWidgetsForSectionsAsync>>[number];
|
||||
type MapSecret = Exclude<MapApp['app']['integration'], null>['secrets'][number];
|
||||
type MapOption = MapWidget['options'][number];
|
||||
|
||||
const mapSection = (
|
||||
section: Omit<MapSection, 'items'>,
|
||||
items: (ReturnType<typeof mapWidget> | ReturnType<typeof mapApp>)[]
|
||||
) => {
|
||||
const { layoutId, ...withoutLayoutId } = section;
|
||||
if (section.type === 'empty') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
type,
|
||||
position: section.position!,
|
||||
items,
|
||||
};
|
||||
}
|
||||
if (section.type === 'hidden') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
type,
|
||||
position: null,
|
||||
items,
|
||||
};
|
||||
}
|
||||
if (section.type === 'category') {
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
return {
|
||||
...sectionProps,
|
||||
type,
|
||||
name: name!,
|
||||
position: section.position!,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
const { name, position, type, ...sectionProps } = withoutLayoutId;
|
||||
|
||||
return {
|
||||
...sectionProps,
|
||||
type,
|
||||
position: section.position === 0 ? ('left' as const) : ('right' as const),
|
||||
items,
|
||||
};
|
||||
};
|
||||
|
||||
const mapWidget = (widgetItem: MapWidget) => {
|
||||
const { sectionId, itemId, id, ...commonLayoutItem } = widgetItem.item.layouts.at(0)!;
|
||||
const common = { ...commonLayoutItem, id: itemId };
|
||||
const { id: _id, itemId: _itemId, type, item, ...widget } = widgetItem;
|
||||
return {
|
||||
...common,
|
||||
...widget,
|
||||
type: 'widget' as const,
|
||||
sort: type,
|
||||
options: mapOptions(widget.options),
|
||||
};
|
||||
};
|
||||
|
||||
const mapApp = (appItem: MapApp) => {
|
||||
const { sectionId, itemId, id, ...commonLayoutItem } = appItem.item.layouts.at(0)!;
|
||||
const common = { ...commonLayoutItem, id: itemId };
|
||||
const { app: innerApp, appId, itemId: _itemId, item, ...otherAppItem } = appItem;
|
||||
const { id: _id, integration, statusCodes, ...app } = appItem.app!;
|
||||
return {
|
||||
...common,
|
||||
...otherAppItem,
|
||||
...app,
|
||||
type: 'app' as const,
|
||||
integration: integration
|
||||
? {
|
||||
...integration,
|
||||
secrets: integration.secrets.map(mapSecret),
|
||||
}
|
||||
: null,
|
||||
statusCodes: statusCodes.map((x) => x.code),
|
||||
};
|
||||
};
|
||||
|
||||
const mapSecret = ({ integrationId, ...secret }: MapSecret) => {
|
||||
const isDefined = secret.value !== null && secret.value !== '';
|
||||
if (secret.visibility === 'private') {
|
||||
return {
|
||||
...secret,
|
||||
visibility: 'private' as const,
|
||||
isDefined,
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...secret,
|
||||
visibility: 'public' as const,
|
||||
isDefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapOptions = (options: MapOption[]) => {
|
||||
const result = {} as Record<string, unknown>;
|
||||
const sorted = options.sort((a, b) => a.path.localeCompare(b.path));
|
||||
sorted.forEach((item) => {
|
||||
addAtPath(result, item);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const addAtPath = (outerObj: Record<string, unknown>, item: MapOption) => {
|
||||
const { path, value } = item;
|
||||
const pathArray = path.split('.');
|
||||
const lastKey = pathArray.pop()!;
|
||||
let current: any = outerObj;
|
||||
pathArray.forEach((key) => {
|
||||
if (Array.isArray(current)) {
|
||||
current = current[parseInt(key, 10)];
|
||||
} else if (typeof current === 'object') {
|
||||
current = current[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (item.type === 'array') {
|
||||
current[lastKey] = [];
|
||||
} else if (item.type === 'object') {
|
||||
current[lastKey] = {};
|
||||
} else if (item.type === 'number' && value) {
|
||||
current[lastKey] = value.includes('.') ? parseFloat(value) : parseInt(value, 10);
|
||||
} else if (item.type === 'boolean') {
|
||||
current[lastKey] = value === 'true';
|
||||
} else if (item.type === 'string') {
|
||||
current[lastKey] = value;
|
||||
} else if (item.type === 'null') {
|
||||
current[lastKey] = null;
|
||||
}
|
||||
};
|
||||
|
||||
178
src/server/db/items.ts
Normal file
178
src/server/db/items.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { IconKey, IconPassword, IconUser, TablerIconsProps } from '@tabler/icons-react';
|
||||
import { objectEntries, objectKeys } from '~/tools/object';
|
||||
import widgets from '~/widgets';
|
||||
|
||||
type IntegrationTypeDefinition = {
|
||||
secrets: IntegrationSecretKey[];
|
||||
iconUrl: string;
|
||||
label: string;
|
||||
groups: IntegrationGroup[];
|
||||
};
|
||||
type IntegrationGroup =
|
||||
| 'mediaServer'
|
||||
| 'mediaRequest'
|
||||
| 'mediaApp'
|
||||
| 'usenet'
|
||||
| 'torrent'
|
||||
| 'download'
|
||||
| 'dns';
|
||||
|
||||
type IntegrationSecretDefinition = {
|
||||
visibility: IntegrationSecretVisibility;
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
};
|
||||
|
||||
export const colorSchemes = ['environment', 'light', 'dark'] as const;
|
||||
export const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const;
|
||||
export const integrationSecretVisibility = ['private', 'public'] as const;
|
||||
export const integrationSecrets = {
|
||||
apiKey: {
|
||||
visibility: 'private',
|
||||
icon: IconKey,
|
||||
},
|
||||
username: {
|
||||
visibility: 'public',
|
||||
icon: IconUser,
|
||||
},
|
||||
password: {
|
||||
visibility: 'private',
|
||||
icon: IconPassword,
|
||||
},
|
||||
} satisfies Record<string, IntegrationSecretDefinition>;
|
||||
export const widgetTypes = objectKeys(widgets);
|
||||
export const widgetOptionTypes = [
|
||||
'string',
|
||||
'number',
|
||||
'boolean',
|
||||
'object',
|
||||
'array',
|
||||
'null',
|
||||
] as const;
|
||||
export const appNamePosition = ['right', 'left', 'top', 'bottom'] as const;
|
||||
export const appNameStyles = ['show', 'hide', 'hover'] as const;
|
||||
export const statusCodeTypes = [
|
||||
'information',
|
||||
'success',
|
||||
'redirect',
|
||||
'clientError',
|
||||
'serverError',
|
||||
] as const;
|
||||
export const sectionTypes = ['sidebar', 'empty', 'category', 'hidden'] as const;
|
||||
export const integrationTypes = {
|
||||
readarr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
|
||||
label: 'Readarr',
|
||||
groups: ['mediaApp'],
|
||||
},
|
||||
radarr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
|
||||
label: 'Radarr',
|
||||
groups: ['mediaApp'],
|
||||
},
|
||||
sonarr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
|
||||
label: 'Sonarr',
|
||||
groups: ['mediaApp'],
|
||||
},
|
||||
lidarr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
|
||||
label: 'Lidarr',
|
||||
groups: ['mediaApp'],
|
||||
},
|
||||
sabnzbd: {
|
||||
secrets: [],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
|
||||
label: 'SABnzbd',
|
||||
groups: ['usenet', 'download'],
|
||||
},
|
||||
jellyseerr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
|
||||
label: 'Jellyseerr',
|
||||
groups: ['mediaRequest'],
|
||||
},
|
||||
overseerr: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
|
||||
label: 'Overseerr',
|
||||
groups: ['mediaRequest'],
|
||||
},
|
||||
deluge: {
|
||||
secrets: ['password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
|
||||
label: 'Deluge',
|
||||
groups: ['torrent'],
|
||||
},
|
||||
qBittorrent: {
|
||||
secrets: ['username', 'password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
|
||||
label: 'qBittorrent',
|
||||
groups: ['torrent'],
|
||||
},
|
||||
transmission: {
|
||||
secrets: ['username', 'password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
|
||||
label: 'Transmission',
|
||||
groups: ['torrent'],
|
||||
},
|
||||
plex: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
|
||||
label: 'Plex',
|
||||
groups: ['mediaServer'],
|
||||
},
|
||||
jellyfin: {
|
||||
secrets: ['username', 'password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
|
||||
label: 'Jellyfin',
|
||||
groups: ['mediaServer'],
|
||||
},
|
||||
nzbGet: {
|
||||
secrets: ['username', 'password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
|
||||
label: 'NZBGet',
|
||||
groups: ['usenet', 'download'],
|
||||
},
|
||||
pihole: {
|
||||
secrets: ['apiKey'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
|
||||
label: 'PiHole',
|
||||
groups: ['dns'],
|
||||
},
|
||||
adGuardHome: {
|
||||
secrets: ['username', 'password'],
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
|
||||
label: 'AdGuard Home',
|
||||
groups: ['dns'],
|
||||
},
|
||||
} satisfies Record<string, IntegrationTypeDefinition>;
|
||||
|
||||
export type ColorScheme = (typeof colorSchemes)[number];
|
||||
export type FirstDayOfWeek = (typeof firstDaysOfWeek)[number];
|
||||
export type IntegrationType = keyof typeof integrationTypes;
|
||||
export type IntegrationSecretVisibility = (typeof integrationSecretVisibility)[number];
|
||||
export type IntegrationSecretKey = keyof typeof integrationSecrets;
|
||||
export type WidgetType = (typeof widgetTypes)[number];
|
||||
export type WidgetOptionType = (typeof widgetOptionTypes)[number];
|
||||
export type AppNamePosition = (typeof appNamePosition)[number];
|
||||
export type AppNameStyle = (typeof appNameStyles)[number];
|
||||
export type StatusCodeType = (typeof statusCodeTypes)[number];
|
||||
export type SectionType = (typeof sectionTypes)[number];
|
||||
|
||||
type InferIntegrationTypeFromGroup<TGroup extends IntegrationGroup> = {
|
||||
[key in keyof typeof integrationTypes]: (typeof integrationTypes)[key] extends {
|
||||
groups: TGroup[];
|
||||
}
|
||||
? key
|
||||
: never;
|
||||
}[keyof typeof integrationTypes];
|
||||
|
||||
export const integrationGroup = <TGroup extends IntegrationGroup>(group: TGroup) => {
|
||||
return objectEntries(integrationTypes)
|
||||
.filter(([, { groups }]) => groups.some((g) => group === g))
|
||||
.map(([key]) => key) as InferIntegrationTypeFromGroup<TGroup>[];
|
||||
};
|
||||
@@ -2,6 +2,20 @@ import { InferSelectModel, relations } from 'drizzle-orm';
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { type AdapterAccount } from 'next-auth/adapters';
|
||||
|
||||
import {
|
||||
AppNamePosition,
|
||||
AppNameStyle,
|
||||
ColorScheme,
|
||||
FirstDayOfWeek,
|
||||
IntegrationSecretKey,
|
||||
IntegrationSecretVisibility,
|
||||
IntegrationType,
|
||||
SectionType,
|
||||
StatusCodeType,
|
||||
WidgetOptionType,
|
||||
WidgetType,
|
||||
} from './items';
|
||||
|
||||
export const users = sqliteTable('user', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name'),
|
||||
@@ -63,23 +77,15 @@ export const verificationTokens = sqliteTable(
|
||||
})
|
||||
);
|
||||
|
||||
const validColorScheme = ['environment', 'light', 'dark'] as const;
|
||||
type ValidColorScheme = (typeof validColorScheme)[number];
|
||||
const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const;
|
||||
type ValidFirstDayOfWeek = (typeof firstDaysOfWeek)[number];
|
||||
|
||||
export const userSettings = sqliteTable('user_setting', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
colorScheme: text('color_scheme').$type<ValidColorScheme>().notNull().default('environment'),
|
||||
colorScheme: text('color_scheme').$type<ColorScheme>().notNull().default('environment'),
|
||||
language: text('language').notNull().default('en'),
|
||||
defaultBoard: text('default_board').notNull().default('default'),
|
||||
firstDayOfWeek: text('first_day_of_week')
|
||||
.$type<ValidFirstDayOfWeek>()
|
||||
.notNull()
|
||||
.default('monday'),
|
||||
firstDayOfWeek: text('first_day_of_week').$type<FirstDayOfWeek>().notNull().default('monday'),
|
||||
searchTemplate: text('search_template').notNull().default('https://google.com/search?q=%s'),
|
||||
openSearchInNewTab: int('open_search_in_new_tab', { mode: 'boolean' }).notNull().default(true),
|
||||
disablePingPulse: int('disable_ping_pulse', { mode: 'boolean' }).notNull().default(false),
|
||||
@@ -90,8 +96,6 @@ export const userSettings = sqliteTable('user_setting', {
|
||||
autoFocusSearch: int('auto_focus_search', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export type UserSettings = InferSelectModel<typeof userSettings>;
|
||||
|
||||
export const invites = sqliteTable('invite', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
token: text('token').notNull().unique(),
|
||||
@@ -103,7 +107,270 @@ export const invites = sqliteTable('invite', {
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export type Invite = InferSelectModel<typeof invites>;
|
||||
export const boards = sqliteTable('board', {
|
||||
// Common
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
|
||||
// Layout settings
|
||||
isLeftSidebarVisible: int('is_left_sidebar_visible', { mode: 'boolean' }).default(false),
|
||||
isRightSidebarVisible: int('is_right_sidebar_visible', { mode: 'boolean' }).default(false),
|
||||
isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).default(false),
|
||||
|
||||
// Access control
|
||||
allowGuests: int('allow_guests', { mode: 'boolean' }).default(false),
|
||||
|
||||
// Page metadata
|
||||
pageTitle: text('page_title'),
|
||||
metaTitle: text('meta_title'),
|
||||
logoImageUrl: text('logo_image_url'),
|
||||
faviconImageUrl: text('favicon_image_url'),
|
||||
|
||||
// Appearance
|
||||
backgroundImageUrl: text('background_image_url'),
|
||||
primaryColor: text('primary_color'),
|
||||
secondaryColor: text('secondary_color'),
|
||||
primaryShade: text('primary_shade'),
|
||||
appOpacity: int('app_opacity'),
|
||||
customCss: text('custom_css'),
|
||||
|
||||
// Other
|
||||
ownerId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const integrations = sqliteTable('integration', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').$type<IntegrationType>().notNull(),
|
||||
url: text('url').notNull(),
|
||||
});
|
||||
|
||||
export const integrationSecrets = sqliteTable(
|
||||
'integration_secret',
|
||||
{
|
||||
key: text('key').$type<IntegrationSecretKey>().notNull(),
|
||||
value: text('value'),
|
||||
visibility: text('visibility').$type<IntegrationSecretVisibility>().notNull(),
|
||||
integrationId: text('integration_id')
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(secret) => ({
|
||||
compoundKey: primaryKey(secret.integrationId, secret.key),
|
||||
})
|
||||
);
|
||||
|
||||
export const widgets = sqliteTable('widget', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
type: text('type').$type<WidgetType>().notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
});
|
||||
|
||||
export const widgetOptions = sqliteTable(
|
||||
'widget_option',
|
||||
{
|
||||
path: text('path').notNull(),
|
||||
value: text('value'),
|
||||
type: text('type').$type<WidgetOptionType>().notNull(),
|
||||
widgetId: text('widget_id')
|
||||
.notNull()
|
||||
.references(() => widgets.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(widgetOption) => ({
|
||||
compoundKey: primaryKey(widgetOption.widgetId, widgetOption.path),
|
||||
})
|
||||
);
|
||||
|
||||
export const items = sqliteTable('item', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
type: text('type').$type<'app' | 'widget'>().notNull(),
|
||||
boardId: text('board_id')
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const apps = sqliteTable('app', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
internalUrl: text('internal_url').notNull(),
|
||||
externalUrl: text('external_url'),
|
||||
iconUrl: text('icon_url'),
|
||||
integrationId: text('integration_id').references(() => integrations.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const appItems = sqliteTable('app_item', {
|
||||
openInNewTab: int('open_in_new_tab', { mode: 'boolean' }).notNull().default(false),
|
||||
isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
fontSize: int('font_size').notNull().default(16),
|
||||
namePosition: text('name_position').$type<AppNamePosition>().notNull().default('right'),
|
||||
nameStyle: text('name_style').$type<AppNameStyle>().notNull().default('show'),
|
||||
nameLineClamp: int('name_line_clamp').notNull().default(1),
|
||||
appId: text('app_id')
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: 'cascade' }),
|
||||
itemId: text('item_id')
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const statusCodes = sqliteTable('status_code', {
|
||||
code: int('code').notNull().primaryKey(),
|
||||
group: text('group').$type<StatusCodeType>().notNull(),
|
||||
});
|
||||
|
||||
export const appStatusCodes = sqliteTable(
|
||||
'app_status_code',
|
||||
{
|
||||
appId: text('app_id')
|
||||
.notNull()
|
||||
.references(() => apps.id, { onDelete: 'cascade' }),
|
||||
code: int('code')
|
||||
.notNull()
|
||||
.references(() => statusCodes.code, { onDelete: 'cascade' }),
|
||||
},
|
||||
(appStatusCode) => ({
|
||||
compoundKey: primaryKey(appStatusCode.appId, appStatusCode.code),
|
||||
})
|
||||
);
|
||||
|
||||
export const layoutItems = sqliteTable('layout_item', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
sectionId: text('section_id').notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
x: int('x').notNull(),
|
||||
y: int('y').notNull(),
|
||||
width: int('width').notNull(),
|
||||
height: int('height').notNull(),
|
||||
});
|
||||
|
||||
export const layouts = sqliteTable('layout', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
boardId: text('board_id')
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const sections = sqliteTable('section', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
type: text('type').$type<SectionType>().notNull(),
|
||||
position: int('position'), // number, right = 0, left = 1
|
||||
name: text('name'),
|
||||
layoutId: text('layout_id')
|
||||
.notNull()
|
||||
.references(() => layouts.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const sectionRelations = relations(sections, ({ one, many }) => ({
|
||||
layout: one(layouts, {
|
||||
fields: [sections.layoutId],
|
||||
references: [layouts.id],
|
||||
}),
|
||||
items: many(layoutItems),
|
||||
}));
|
||||
|
||||
export const layoutRelations = relations(layouts, ({ many, one }) => ({
|
||||
sections: many(sections),
|
||||
board: one(boards, {
|
||||
fields: [layouts.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const layoutItemRelations = relations(layoutItems, ({ one }) => ({
|
||||
item: one(items, {
|
||||
fields: [layoutItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
section: one(sections, {
|
||||
fields: [layoutItems.sectionId],
|
||||
references: [sections.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const statusCodeRelations = relations(statusCodes, ({ many }) => ({
|
||||
apps: many(appStatusCodes),
|
||||
}));
|
||||
|
||||
export const appStatusCodeRelations = relations(appStatusCodes, ({ one }) => ({
|
||||
statusCode: one(statusCodes, {
|
||||
fields: [appStatusCodes.code],
|
||||
references: [statusCodes.code],
|
||||
}),
|
||||
app: one(apps, {
|
||||
fields: [appStatusCodes.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const widgetOptionRelations = relations(widgetOptions, ({ one }) => ({
|
||||
widget: one(widgets, {
|
||||
fields: [widgetOptions.widgetId],
|
||||
references: [widgets.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const widgetRelations = relations(widgets, ({ one, many }) => ({
|
||||
item: one(items, {
|
||||
fields: [widgets.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
options: many(widgetOptions),
|
||||
}));
|
||||
|
||||
export const itemRelations = relations(items, ({ one, many }) => ({
|
||||
board: one(boards, {
|
||||
fields: [items.boardId],
|
||||
references: [boards.id],
|
||||
}),
|
||||
widget: one(widgets),
|
||||
app: one(appItems),
|
||||
layouts: many(layoutItems),
|
||||
}));
|
||||
|
||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
secrets: many(integrationSecrets),
|
||||
apps: many(apps),
|
||||
}));
|
||||
|
||||
export const appRelations = relations(apps, ({ one, many }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [apps.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
statusCodes: many(appStatusCodes),
|
||||
items: many(appItems),
|
||||
}));
|
||||
|
||||
export const appItemRelations = relations(appItems, ({ one }) => ({
|
||||
app: one(apps, {
|
||||
fields: [appItems.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [appItems.itemId],
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [integrationSecrets.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const boardRelations = relations(boards, ({ one, many }) => ({
|
||||
owner: one(users, {
|
||||
fields: [boards.ownerId],
|
||||
references: [users.id],
|
||||
}),
|
||||
items: many(items),
|
||||
layouts: many(layouts),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
@@ -116,6 +383,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
|
||||
accounts: many(accounts),
|
||||
settings: one(userSettings),
|
||||
invites: many(invites),
|
||||
boards: many(boards),
|
||||
}));
|
||||
|
||||
export const userSettingRelations = relations(userSettings, ({ one }) => ({
|
||||
@@ -131,3 +399,7 @@ export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type UserSettings = InferSelectModel<typeof userSettings>;
|
||||
export type Invite = InferSelectModel<typeof invites>;
|
||||
export type Section = InferSelectModel<typeof sections>;
|
||||
|
||||
41
src/tools/object.ts
Normal file
41
src/tools/object.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const objectKeys = <T extends object>(
|
||||
obj: T
|
||||
): (keyof T extends infer U
|
||||
? U extends string
|
||||
? U
|
||||
: U extends number
|
||||
? `${U}`
|
||||
: never
|
||||
: never)[] => {
|
||||
return Object.keys(obj) as any;
|
||||
};
|
||||
|
||||
export const objectEntries = <T extends {}>(object: T): ReadonlyArray<Entry<T>> => {
|
||||
return Object.entries(object) as unknown as ReadonlyArray<Entry<T>>;
|
||||
};
|
||||
|
||||
type TupleEntry<
|
||||
T extends readonly unknown[],
|
||||
I extends unknown[] = [],
|
||||
R = never,
|
||||
> = T extends readonly [infer Head, ...infer Tail]
|
||||
? TupleEntry<Tail, [...I, unknown], R | [`${I['length']}`, Head]>
|
||||
: R;
|
||||
|
||||
type ObjectEntry<T extends {}> =
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
T extends object
|
||||
? { [K in keyof T]: [K, Required<T>[K]] }[keyof T] extends infer E
|
||||
? E extends [infer K, infer V]
|
||||
? K extends string | number
|
||||
? [`${K}`, V]
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type Entry<T extends {}> = T extends readonly [unknown, ...unknown[]]
|
||||
? TupleEntry<T>
|
||||
: T extends ReadonlyArray<infer U>
|
||||
? [`${number}`, U]
|
||||
: ObjectEntry<T>;
|
||||
Reference in New Issue
Block a user