feat: #420 reimplement icon picker (#421)

This commit is contained in:
Manuel
2024-05-04 23:00:15 +02:00
committed by GitHub
parent 51aaab2f23
commit 60a35e2583
37 changed files with 2974 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { groupRouter } from "./router/group";
import { iconsRouter } from "./router/icons";
import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
widget: widgetRouter,
location: locationRouter,
log: logRouter,
icon: iconsRouter,
});
// export type definition of API

View File

@@ -0,0 +1,32 @@
import { count, like } from "@homarr/db";
import { icons } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
findIcons: publicProcedure
.input(validation.icons.findIcons)
.query(async ({ ctx, input }) => {
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
},
where:
input.searchText?.length ?? 0 > 0
? like(icons.name, `%${input.searchText}%`)
: undefined,
limit: 5,
},
},
}),
countIcons:
(await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
};
}),
});

View File

@@ -1,3 +1,4 @@
export * from "./object";
export * from "./string";
export * from "./cookie";
export * from "./stopwatch";

View File

@@ -0,0 +1,44 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
dayjs.updateLocale("en", {
relativeTime: {
future: "in %s",
past: "%s ago",
s: "one second",
ss: "%d seconds",
m: "a minute",
mm: "%d minutes",
h: "an hour",
hh: "%d hours",
d: "a day",
dd: "%d days",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years",
},
});
export class Stopwatch {
private startTime: number;
constructor() {
this.startTime = performance.now();
}
getElapsedInHumanWords() {
const difference = performance.now() - this.startTime;
if (difference < 1000) {
return `${Math.floor(difference)} ms`;
}
return dayjs().millisecond(this.startTime).fromNow(true);
}
reset() {
this.startTime = performance.now();
}
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE `iconRepository` (
`iconRepository_id` varchar(256) NOT NULL,
`iconRepository_slug` varchar(150) NOT NULL,
CONSTRAINT `iconRepository_iconRepository_id` PRIMARY KEY(`iconRepository_id`)
);
--> statement-breakpoint
CREATE TABLE `icon` (
`icon_id` varchar(256) NOT NULL,
`icon_name` varchar(250) NOT NULL,
`icon_url` text NOT NULL,
`icon_checksum` text NOT NULL,
`iconRepository_id` varchar(256) NOT NULL,
CONSTRAINT `icon_icon_id` PRIMARY KEY(`icon_id`)
);
--> statement-breakpoint
ALTER TABLE `icon` ADD CONSTRAINT `icon_iconRepository_id_iconRepository_iconRepository_id_fk` FOREIGN KEY (`iconRepository_id`) REFERENCES `iconRepository`(`iconRepository_id`) ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1714817536714,
"tag": "0000_hot_mandrill",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1714854892785,
"tag": "0001_fluffy_overlord",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,13 @@
CREATE TABLE `iconRepository` (
`iconRepository_id` text PRIMARY KEY NOT NULL,
`iconRepository_slug` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `icon` (
`icon_id` text PRIMARY KEY NOT NULL,
`icon_name` text NOT NULL,
`icon_url` text NOT NULL,
`icon_checksum` text NOT NULL,
`iconRepository_id` text NOT NULL,
FOREIGN KEY (`iconRepository_id`) REFERENCES `iconRepository`(`iconRepository_id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1714817544524,
"tag": "0000_premium_forgotten_one",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1714854863811,
"tag": "0001_unusual_rage",
"breakpoints": true
}
]
}

View File

@@ -21,7 +21,7 @@
"migration:run": "tsx ./migrate.ts",
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
"push": "drizzle-kit push:sqlite --config ./sqlite.config.ts",
"studio": "drizzle-kit studio --config ./sqlite.config.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -285,6 +285,21 @@ export const integrationItems = mysqlTable(
}),
);
export const icons = mysqlTable("icon", {
id: varchar("icon_id", { length: 256 }).notNull().primaryKey(),
name: varchar("icon_name", { length: 250 }).notNull(),
url: text("icon_url").notNull(),
checksum: text("icon_checksum").notNull(),
iconRepositoryId: varchar("iconRepository_id", { length: 256 })
.notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const iconRepositories = mysqlTable("iconRepository", {
id: varchar("iconRepository_id", { length: 256 }).notNull().primaryKey(),
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -301,6 +316,20 @@ export const userRelations = relations(users, ({ many }) => ({
invites: many(invites),
}));
export const iconRelations = relations(icons, ({ one }) => ({
repository: one(iconRepositories, {
fields: [icons.iconRepositoryId],
references: [iconRepositories.id],
}),
}));
export const iconRepositoryRelations = relations(
iconRepositories,
({ many }) => ({
icons: many(icons),
}),
);
export const inviteRelations = relations(invites, ({ one }) => ({
creator: one(users, {
fields: [invites.creatorId],

View File

@@ -282,6 +282,21 @@ export const integrationItems = sqliteTable(
}),
);
export const icons = sqliteTable("icon", {
id: text("icon_id").notNull().primaryKey(),
name: text("icon_name").notNull(),
url: text("icon_url").notNull(),
checksum: text("icon_checksum").notNull(),
iconRepositoryId: text("iconRepository_id")
.notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const iconRepositories = sqliteTable("iconRepository", {
id: text("iconRepository_id").notNull().primaryKey(),
slug: text("iconRepository_slug").notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -298,6 +313,20 @@ export const userRelations = relations(users, ({ many }) => ({
invites: many(invites),
}));
export const iconRelations = relations(icons, ({ one }) => ({
repository: one(iconRepositories, {
fields: [icons.iconRepositoryId],
references: [iconRepositories.id],
}),
}));
export const iconRepositoryRelations = relations(
iconRepositories,
({ many }) => ({
icons: many(icons),
}),
);
export const inviteRelations = relations(invites, ({ one }) => ({
creator: one(users, {
fields: [invites.creatorId],

2
packages/icons/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./src/icons-fetcher";
export * from "./src/types";

View File

@@ -0,0 +1,39 @@
{
"name": "@homarr/icons",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"
]
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,42 @@
import { GitHubIconRepository } from "./repositories/github.icon-repository";
import { JsdelivrIconRepository } from "./repositories/jsdelivr.icon-repository";
import type { RepositoryIconGroup } from "./types";
const repositories = [
new GitHubIconRepository(
"Walkxcode",
"walkxcode/dashboard-icons",
undefined,
new URL("https://github.com/walkxcode/dashboard-icons"),
new URL(
"https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true",
),
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
),
new JsdelivrIconRepository(
"Papirus",
"PapirusDevelopmentTeam/papirus-icon-theme",
"GPL-3.0",
new URL("https://github.com/PapirusDevelopmentTeam/papirus-icon-theme"),
new URL(
"https://data.jsdelivr.com/v1/packages/gh/PapirusDevelopmentTeam/papirus_icons@master?structure=flat",
),
"https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons/{0}",
),
new JsdelivrIconRepository(
"Homelab SVG assets",
"loganmarchione/homelab-svg-assets",
"MIT",
new URL("https://github.com/loganmarchione/homelab-svg-assets"),
new URL(
"https://data.jsdelivr.com/v1/packages/gh/loganmarchione/homelab-svg-assets@main?structure=flat",
),
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/{0}",
),
];
export const fetchIconsAsync = async (): Promise<RepositoryIconGroup[]> => {
return await Promise.all(
repositories.map(async (repository) => await repository.getAllIconsAsync()),
);
};

View File

@@ -0,0 +1,72 @@
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
import { IconRepository } from "./icon-repository";
export class GitHubIconRepository extends IconRepository {
constructor(
public readonly name: string,
public readonly slug: string,
public readonly license: IconRepositoryLicense,
public readonly repositoryUrl?: URL,
public readonly repositoryIndexingUrl?: URL,
public readonly repositoryBlobUrlTemplate?: string,
) {
super(
name,
slug,
license,
repositoryUrl,
repositoryIndexingUrl,
repositoryBlobUrlTemplate,
);
}
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
if (!this.repositoryIndexingUrl || !this.repositoryBlobUrlTemplate) {
throw new Error("Repository URLs are required for this repository");
}
const response = await fetch(this.repositoryIndexingUrl);
const listOfFiles = (await response.json()) as GitHubApiResponse;
return {
success: true,
icons: listOfFiles.tree
.filter((treeItem) =>
this.allowedImageFileTypes.some((allowedExtension) =>
treeItem.path.includes(allowedExtension),
),
)
.map((treeItem) => {
const fileNameWithExtension =
this.getFileNameWithoutExtensionFromPath(treeItem.path);
return {
imageUrl: new URL(
this.repositoryBlobUrlTemplate!.replace("{0}", treeItem.path),
),
fileNameWithExtension: fileNameWithExtension,
local: false,
sizeInBytes: treeItem.size,
checksum: treeItem.sha,
};
}),
slug: this.slug,
};
}
}
interface GitHubApiResponse {
sha: string;
url: string;
tree: TreeItem[];
truncated: boolean;
}
export interface TreeItem {
path: string;
mode: string;
sha: string;
url: string;
size?: number;
}

View File

@@ -0,0 +1,38 @@
import { logger } from "@homarr/log";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
export abstract class IconRepository {
protected readonly allowedImageFileTypes = [".png", ".svg", ".jpeg"];
protected constructor(
public readonly name: string,
public readonly slug: string,
public readonly license: IconRepositoryLicense,
public readonly repositoryUrl?: URL,
public readonly repositoryIndexingUrl?: URL,
public readonly repositoryBlobUrlTemplate?: string,
) {}
public async getAllIconsAsync(): Promise<RepositoryIconGroup> {
try {
return await this.getAllIconsInternalAsync();
} catch (err) {
logger.error(
`Unable to request icons from repository "${this.slug}": ${JSON.stringify(err)}`,
);
return {
success: false,
icons: [],
slug: this.slug,
};
}
}
protected abstract getAllIconsInternalAsync(): Promise<RepositoryIconGroup>;
protected getFileNameWithoutExtensionFromPath(path: string) {
return path.replace(/^.*[\\/]/, "");
}
}

View File

@@ -0,0 +1,63 @@
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
import { IconRepository } from "./icon-repository";
export class JsdelivrIconRepository extends IconRepository {
constructor(
public readonly name: string,
public readonly slug: string,
public readonly license: IconRepositoryLicense,
public readonly repositoryUrl: URL,
public readonly repositoryIndexingUrl: URL,
public readonly repositoryBlobUrlTemplate: string,
) {
super(
name,
slug,
license,
repositoryUrl,
repositoryIndexingUrl,
repositoryBlobUrlTemplate,
);
}
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
const response = await fetch(this.repositoryIndexingUrl);
const listOfFiles = (await response.json()) as JsdelivrApiResponse;
return {
success: true,
icons: listOfFiles.files
.filter((file) =>
this.allowedImageFileTypes.some((allowedImageFileType) =>
file.name.includes(allowedImageFileType),
),
)
.map((file) => {
const fileNameWithExtension =
this.getFileNameWithoutExtensionFromPath(file.name);
return {
imageUrl: new URL(
this.repositoryBlobUrlTemplate.replace("{0}", file.name),
),
fileNameWithExtension: fileNameWithExtension,
local: false,
sizeInBytes: file.size,
checksum: file.hash,
};
}),
slug: this.slug,
};
}
}
interface JsdelivrApiResponse {
files: JsdelivrFile[];
}
interface JsdelivrFile {
name: string;
size: number;
hash: string;
}

View File

@@ -0,0 +1 @@
export type IconRepositoryLicense = "MIT" | "GPL-3.0" | undefined;

View File

@@ -0,0 +1,3 @@
export * from "./icon-repository-license";
export * from "./repository-icon-group";
export * from "./repository-icon";

View File

@@ -0,0 +1,7 @@
import type { RepositoryIcon } from "./repository-icon";
export interface RepositoryIconGroup {
icons: RepositoryIcon[];
success: boolean;
slug: string;
}

View File

@@ -0,0 +1,7 @@
export interface RepositoryIcon {
fileNameWithExtension: string;
sizeInBytes?: number;
imageUrl: URL;
local: boolean;
checksum: string;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -360,6 +360,10 @@ export default {
next: "Next",
checkoutDocs: "Check out the documentation",
},
iconPicker: {
header:
"Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
},
notification: {
create: {
success: "Creation successful",

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
const findIconsSchema = z.object({
searchText: z.string().optional(),
});
export const iconsSchemas = {
findIcons: findIconsSchema,
};

View File

@@ -1,6 +1,7 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { groupSchemas } from "./group";
import { iconsSchemas } from "./icons";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { userSchemas } from "./user";
@@ -14,6 +15,7 @@ export const validation = {
app: appSchemas,
widget: widgetSchemas,
location: locationSchemas,
icons: iconsSchemas,
};
export { createSectionSchema, sharedItemSchema } from "./shared";