feat: add server settings for default board, default color scheme and default locale (#1373)
* feat: add server settings for default board, default color scheme and default locale * chore: address pull request feedback * test: adjust unit tests to match requirements * fix: deepsource issue * chore: add deepsource as dependency to translation library * refactor: restructure language-combobox, adjust default locale for next-intl * chore: change cookie keys prefix from homarr- to homarr.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import type { UmamiEventData } from "@umami/node";
|
||||
import { Umami } from "@umami/node";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { count, db, eq } from "@homarr/db";
|
||||
import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { integrations, items, users } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
@@ -12,18 +12,7 @@ import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const setting = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "analytics"),
|
||||
});
|
||||
|
||||
if (!setting) {
|
||||
logger.info(
|
||||
"Server does not know the configured state of analytics. No data will be sent. Enable analytics in the settings",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const analyticsSettings = SuperJSON.parse<typeof defaultServerSettings.analytics>(setting.value);
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticsSettings.enableGeneral) {
|
||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||
|
||||
@@ -4,6 +4,7 @@ import superjson from "superjson";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { and, createId, eq, inArray, like, or } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -41,6 +42,16 @@ export const boardRouter = createTRPCRouter({
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
getPublicBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoImageUrl: true,
|
||||
},
|
||||
where: eq(boards.isPublic, true),
|
||||
});
|
||||
}),
|
||||
getAllBoards: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
|
||||
@@ -216,6 +227,14 @@ export const boardRouter = createTRPCRouter({
|
||||
.input(validation.board.changeVisibility)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
|
||||
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make default board private",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
@@ -240,7 +259,22 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
: null;
|
||||
|
||||
const boardWhere = user?.homeBoardId ? eq(boards.id, user.homeBoardId) : eq(boards.name, "home");
|
||||
// 1. user home board, 2. default board, 3. not found
|
||||
let boardWhere: SQL<unknown> | null = null;
|
||||
if (user?.homeBoardId) {
|
||||
boardWhere = eq(boards.id, user.homeBoardId);
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null;
|
||||
}
|
||||
|
||||
if (!boardWhere) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No home board found",
|
||||
});
|
||||
}
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { defaultServerSettings, ServerSettings } from "@homarr/server-settings";
|
||||
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const serverSettingsRouter = createTRPCRouter({
|
||||
// this must be public so anonymous users also get analytics
|
||||
getAnalytics: publicProcedure.query(async ({ ctx }) => {
|
||||
const setting = await ctx.db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "analytics"),
|
||||
});
|
||||
|
||||
if (!setting) {
|
||||
logger.info(
|
||||
"Server settings for analytics is currently undefined. Using default values instead. If this persists, there may be an issue with the server settings",
|
||||
);
|
||||
return {
|
||||
enableGeneral: true,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
enableWidgetData: false,
|
||||
} as (typeof defaultServerSettings)["analytics"];
|
||||
}
|
||||
|
||||
return SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(setting.value);
|
||||
getCulture: publicProcedure.query(async ({ ctx }) => {
|
||||
return await getServerSettingByKeyAsync(ctx.db, "culture");
|
||||
}),
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const settings = await ctx.db.query.serverSettings.findMany();
|
||||
|
||||
const data = {} as ServerSettings;
|
||||
defaultServerSettingsKeys.forEach((key) => {
|
||||
const settingValue = settings.find((setting) => setting.settingKey === key)?.value;
|
||||
if (!settingValue) {
|
||||
return;
|
||||
}
|
||||
data[key] = SuperJSON.parse(settingValue);
|
||||
});
|
||||
return data;
|
||||
return await getServerSettingsAsync(ctx.db);
|
||||
}),
|
||||
saveSettings: protectedProcedure
|
||||
.input(
|
||||
@@ -51,12 +20,10 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const databaseRunResult = await ctx.db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(input.value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, input.settingsKey));
|
||||
return databaseRunResult.changes === 1;
|
||||
await updateServerSettingByKeyAsync(
|
||||
ctx.db,
|
||||
input.settingsKey,
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
integrations,
|
||||
items,
|
||||
sections,
|
||||
serverSettings,
|
||||
users,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
@@ -473,13 +474,19 @@ describe("deleteBoard should delete board", () => {
|
||||
});
|
||||
|
||||
describe("getHomeBoard should return home board", () => {
|
||||
it("should return home board", async () => {
|
||||
test("should return user home board when user has one", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: fullBoardProps.boardId,
|
||||
})
|
||||
.where(eq(users.id, defaultCreatorId));
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
@@ -491,6 +498,40 @@ describe("getHomeBoard should return home board", () => {
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
test("should return global home board when user doesn't have one", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: "board",
|
||||
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "home",
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
test("should throw error when home board not configured in serverSettings", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: defaultSession });
|
||||
await createFullBoardAsync(db, "home");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("No home board found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardByName should return board by name", () => {
|
||||
|
||||
@@ -40,56 +40,20 @@ describe("getAll server settings", () => {
|
||||
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
});
|
||||
test("getAll should return server", async () => {
|
||||
test("getAll should return default server settings when nothing in database", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await db.insert(serverSettings).values([
|
||||
{
|
||||
settingKey: defaultServerSettingsKeys[0],
|
||||
value: SuperJSON.stringify(defaultServerSettings.analytics),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.getAll();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: false,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
},
|
||||
});
|
||||
expect(result).toStrictEqual(defaultServerSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
test("saveSettings should return false when it did not update one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const result = await caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings.length).toBe(0);
|
||||
});
|
||||
test("saveSettings should update settings and return true when it updated only one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -104,7 +68,7 @@ describe("saveSettings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.saveSettings({
|
||||
await caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
@@ -114,8 +78,6 @@ describe("saveSettings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NextAuthConfig } from "next-auth";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { env } from "./env.mjs";
|
||||
@@ -52,7 +52,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
}
|
||||
|
||||
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
|
||||
cookies().set("homarr-color-scheme", dbUser.colorScheme, {
|
||||
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
|
||||
path: "/",
|
||||
expires: dayjs().add(1, "year").toDate(),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { eq } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { groupMembers, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbUser?.name).toBe("test-new");
|
||||
});
|
||||
test("signInEventHandler should set homarr-color-scheme cookie", async () => {
|
||||
test("signInEventHandler should set color-scheme cookie", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
@@ -239,7 +239,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
|
||||
// Assert
|
||||
expect(cookies().set).toHaveBeenCalledWith(
|
||||
"homarr-color-scheme",
|
||||
colorSchemeCookieKey,
|
||||
"dark",
|
||||
expect.objectContaining({
|
||||
path: "/",
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
}).withCallback(async () => {
|
||||
const analyticSetting = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "analytics"),
|
||||
});
|
||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
|
||||
|
||||
if (!value.enableGeneral) {
|
||||
if (!analyticSetting.enableGeneral) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { objectKeys } from "@homarr/common";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
@@ -32,21 +33,33 @@ const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
|
||||
const seedServerSettingsAsync = async (db: Database) => {
|
||||
const serverSettingsData = await db.query.serverSettings.findMany();
|
||||
let insertedSettingsCount = 0;
|
||||
|
||||
for (const settingsKey of defaultServerSettingsKeys) {
|
||||
if (serverSettingsData.some((setting) => setting.settingKey === settingsKey)) {
|
||||
return;
|
||||
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
|
||||
if (!currentDbEntry) {
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: settingsKey,
|
||||
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
||||
});
|
||||
console.log(`Created serverSetting through seed key=${settingsKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: settingsKey,
|
||||
value: SuperJSON.stringify(defaultServerSettings[settingsKey]),
|
||||
});
|
||||
insertedSettingsCount++;
|
||||
}
|
||||
const currentSettings = SuperJSON.parse<Record<string, unknown>>(currentDbEntry.value);
|
||||
const defaultSettings = defaultServerSettings[settingsKey];
|
||||
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
|
||||
|
||||
if (insertedSettingsCount > 0) {
|
||||
console.info(`Inserted ${insertedSettingsCount} missing settings`);
|
||||
if (missingKeys.length === 0) {
|
||||
console.info(`Skipping seeding for serverSetting as it already exists key=${settingsKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify({ ...defaultSettings, ...currentSettings }), // Add missing keys
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, settingsKey));
|
||||
console.log(`Updated serverSetting through seed key=${settingsKey}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./item";
|
||||
export * from "./server-setting";
|
||||
|
||||
52
packages/db/queries/server-setting.ts
Normal file
52
packages/db/queries/server-setting.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { serverSettings } from "../schema/sqlite";
|
||||
|
||||
export const getServerSettingsAsync = async (db: Database) => {
|
||||
const settings = await db.query.serverSettings.findMany();
|
||||
|
||||
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||
const setting = settings.find((setting) => setting.settingKey === settingKey);
|
||||
if (!setting) {
|
||||
// Typescript is not happy because the key is a union and it does not know that they are the same
|
||||
acc[settingKey] = defaultServerSettings[settingKey] as never;
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[settingKey] = {
|
||||
...defaultServerSettings[settingKey],
|
||||
...SuperJSON.parse(setting.value),
|
||||
};
|
||||
return acc;
|
||||
}, {} as ServerSettings);
|
||||
};
|
||||
|
||||
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
|
||||
const dbSettings = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, key),
|
||||
});
|
||||
|
||||
if (!dbSettings) {
|
||||
return defaultServerSettings[key];
|
||||
}
|
||||
|
||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
};
|
||||
|
||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||
db: Database,
|
||||
key: TKey,
|
||||
value: ServerSettings[TKey],
|
||||
) => {
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, key));
|
||||
};
|
||||
2
packages/definitions/src/cookie.ts
Normal file
2
packages/definitions/src/cookie.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const colorSchemeCookieKey = "homarr.color-scheme";
|
||||
export const localeCookieKey = "homarr.locale";
|
||||
@@ -8,3 +8,4 @@ export * from "./auth";
|
||||
export * from "./user";
|
||||
export * from "./group";
|
||||
export * from "./docs";
|
||||
export * from "./cookie";
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export const defaultServerSettingsKeys = ["analytics", "crawlingAndIndexing"] as const;
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
|
||||
export const defaultServerSettingsKeys = [
|
||||
"analytics",
|
||||
"crawlingAndIndexing",
|
||||
"board",
|
||||
"appearance",
|
||||
"culture",
|
||||
] as const;
|
||||
|
||||
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
|
||||
|
||||
@@ -15,6 +24,15 @@ export const defaultServerSettings = {
|
||||
noTranslate: true,
|
||||
noSiteLinksSearchBox: false,
|
||||
},
|
||||
board: {
|
||||
defaultBoardId: null as string | null,
|
||||
},
|
||||
appearance: {
|
||||
defaultColorScheme: "light" as ColorScheme,
|
||||
},
|
||||
culture: {
|
||||
defaultLocale: "en" as SupportedLanguage,
|
||||
},
|
||||
} satisfies ServerSettingsRecord;
|
||||
|
||||
export type ServerSettings = typeof defaultServerSettings;
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "4.3.1",
|
||||
"mantine-react-table": "2.0.0-beta.7",
|
||||
"next": "^14.2.16",
|
||||
"next-intl": "3.24.0",
|
||||
|
||||
@@ -23,4 +23,4 @@ export const localeConfigurations = {
|
||||
export const supportedLanguages = objectKeys(localeConfigurations);
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
export const defaultLocale = "en" satisfies SupportedLanguage;
|
||||
export const fallbackLocale = "en" satisfies SupportedLanguage;
|
||||
|
||||
@@ -1922,6 +1922,14 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
notification: {
|
||||
success: {
|
||||
message: "Settings saved successfully",
|
||||
},
|
||||
error: {
|
||||
message: "Failed to save settings",
|
||||
},
|
||||
},
|
||||
section: {
|
||||
analytics: {
|
||||
title: "Analytics",
|
||||
@@ -1963,6 +1971,29 @@ export default {
|
||||
text: "Google will build a search box with the crawled links along with other direct links. Enabling this will ask Google to disable that box.",
|
||||
},
|
||||
},
|
||||
board: {
|
||||
title: "Boards",
|
||||
defaultBoard: {
|
||||
label: "Global default board",
|
||||
description: "Only public boards are available for selection",
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
defaultColorScheme: {
|
||||
label: "Default color scheme",
|
||||
options: {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
},
|
||||
},
|
||||
},
|
||||
culture: {
|
||||
title: "Culture",
|
||||
defaultLocale: {
|
||||
label: "Default language",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
|
||||
import { routing } from "./routing";
|
||||
import type { SupportedLanguage } from ".";
|
||||
import { createRouting } from "./routing";
|
||||
|
||||
export const I18nMiddleware = createMiddleware(routing);
|
||||
export const createI18nMiddleware = (defaultLocale: SupportedLanguage) =>
|
||||
createMiddleware(createRouting(defaultLocale));
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import deepmerge from "deepmerge";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
import { isLocaleSupported } from ".";
|
||||
import { fallbackLocale, isLocaleSupported } from ".";
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { createLanguageMapping } from "./mapping";
|
||||
import { routing } from "./routing";
|
||||
|
||||
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let currentLocale = await requestLocale;
|
||||
|
||||
if (!currentLocale || !isLocaleSupported(currentLocale)) {
|
||||
currentLocale = routing.defaultLocale;
|
||||
currentLocale = fallbackLocale;
|
||||
}
|
||||
const typedLocale = currentLocale as SupportedLanguage;
|
||||
|
||||
@@ -19,8 +18,8 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const currentMessages = (await languageMap[typedLocale]()).default;
|
||||
|
||||
// Fallback to default locale if the current locales messages if not all messages are present
|
||||
if (currentLocale !== routing.defaultLocale) {
|
||||
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
|
||||
if (currentLocale !== fallbackLocale) {
|
||||
const fallbackMessages = (await languageMap[fallbackLocale]()).default;
|
||||
return {
|
||||
locale: currentLocale,
|
||||
messages: deepmerge(fallbackMessages, currentMessages),
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
import { defaultLocale, supportedLanguages } from "./config";
|
||||
import { localeCookieKey } from "@homarr/definitions";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: supportedLanguages,
|
||||
defaultLocale,
|
||||
localePrefix: {
|
||||
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
||||
},
|
||||
});
|
||||
import type { SupportedLanguage } from "./config";
|
||||
import { supportedLanguages } from "./config";
|
||||
|
||||
export const createRouting = (defaultLocale: SupportedLanguage) =>
|
||||
defineRouting({
|
||||
locales: supportedLanguages,
|
||||
defaultLocale,
|
||||
localeCookie: {
|
||||
name: localeCookieKey,
|
||||
},
|
||||
localePrefix: {
|
||||
mode: "never", // Rewrite the URL with locale parameter but without shown in url
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ export { OverflowBadge } from "./overflow-badge";
|
||||
export { SearchInput } from "./search-input";
|
||||
export * from "./select-with-description";
|
||||
export * from "./select-with-description-and-badge";
|
||||
export { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
export { TablePagination } from "./table-pagination";
|
||||
export { TextMultiSelect } from "./text-multi-select";
|
||||
export { UserAvatar } from "./user-avatar";
|
||||
|
||||
Reference in New Issue
Block a user