mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the homepage plugin, move service links and canvas out of core (B19)
This commit is contained in:
1 parent
21937a6b02
commit
d4ec444558
256 files changed
+27365
-9077
No files matched your search
@@ -22,6 +22,7 @@ import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import { mountPluginApi } from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
@@ -1807,6 +1808,7 @@ app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
|
||||
@@ -883,17 +883,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
@@ -2024,52 +2013,6 @@ const migrateSchema = () => {
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// --- homepage begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_items table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_layouts table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Stable per-row identity used to match rows across two independently-
|
||||
// seeded databases (the embedded desktop backend and a connected remote
|
||||
@@ -2086,23 +2029,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("snippets", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
|
||||
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
|
||||
// "non-constant default" reason -- add nullable, then backfill from
|
||||
// created_at below and rely on the repository layer to keep it current.
|
||||
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
|
||||
try {
|
||||
sqlite.exec(
|
||||
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
databaseLogger.warn(
|
||||
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
|
||||
{ operation: "schema_migration", table: "dashboard_service_links" },
|
||||
);
|
||||
}
|
||||
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
|
||||
|
||||
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
||||
// grants record whether a capability came from an admin or from bundling.
|
||||
@@ -2121,8 +2047,6 @@ const migrateSchema = () => {
|
||||
"snippets",
|
||||
"snippet_folders",
|
||||
"vault_profiles",
|
||||
"dashboard_service_links",
|
||||
"homepage_items",
|
||||
];
|
||||
|
||||
for (const table of syncIdTables) {
|
||||
|
||||
@@ -113,11 +113,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_homepage_items_user_id",
|
||||
table: "homepage_items",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_dismissed_alerts_user_id",
|
||||
table: "dismissed_alerts",
|
||||
|
||||
@@ -916,22 +916,7 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: int("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// --- termix-id begin ---
|
||||
// A user claims a unique public handle. Their published SSH public keys are
|
||||
@@ -1023,42 +1008,8 @@ export const notificationChannels = mysqlTable("notification_channels", {
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = mysqlTable(
|
||||
"homepage_items",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
typeId: text("type_id").notNull(),
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: int("folder_id"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const homepageLayouts = mysqlTable("homepage_layouts", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -917,22 +917,7 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dashboardServiceLinks = pgTable("dashboard_service_links", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// --- termix-id begin ---
|
||||
// A user claims a unique public handle. Their published SSH public keys are
|
||||
@@ -1024,42 +1009,8 @@ export const notificationChannels = pgTable("notification_channels", {
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = pgTable(
|
||||
"homepage_items",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
typeId: text("type_id").notNull(),
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const homepageLayouts = pgTable("homepage_layouts", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -913,22 +913,7 @@ export const uiPreferences = sqliteTable("ui_preferences", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// dashboard_service_links moved to the homepage plugin (p_homepage_dashboard_service_links).
|
||||
|
||||
// --- termix-id begin ---
|
||||
// A user claims a unique public handle. Their published SSH public keys are
|
||||
@@ -1020,42 +1005,8 @@ export const notificationChannels = sqliteTable("notification_channels", {
|
||||
// --- alerts end ---
|
||||
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = sqliteTable(
|
||||
"homepage_items",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
typeId: text("type_id").notNull(),
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_homepage_items_user_id").on(table.userId)],
|
||||
);
|
||||
|
||||
export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
// homepage_items and homepage_layouts moved to the homepage plugin
|
||||
// (p_homepage_homepage_items, p_homepage_homepage_layouts).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type DashboardServiceLinkRecord =
|
||||
typeof dashboardServiceLinks.$inferSelect;
|
||||
|
||||
export type DashboardServiceLinkUpdate = Partial<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export class DashboardServiceLinkRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DashboardServiceLinkRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: { label: string; url: string },
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<DashboardServiceLinkRecord> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ order: dashboardServiceLinks.order })
|
||||
.from(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.orderBy(asc(dashboardServiceLinks.order));
|
||||
const nextOrder =
|
||||
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
||||
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
);
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: DashboardServiceLinkUpdate,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{ ...updates, updatedAt: new Date().toISOString() },
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,7 @@ import { NotificationChannelRepository } from "./notification-channel-repository
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
@@ -167,13 +164,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
|
||||
return new DashboardServiceLinkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||
return new SyncTombstoneRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -189,20 +179,7 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
|
||||
}
|
||||
|
||||
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
|
||||
|
||||
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
||||
return new HomepageItemRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
|
||||
return new HomepageLayoutRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
|
||||
);
|
||||
}
|
||||
// Homepage items/layout and dashboard service links moved to the homepage plugin.
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
|
||||
|
||||
export interface HomepageItemCreateInput {
|
||||
typeId: string;
|
||||
title: string | null;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export type HomepageItemUpdateInput = Partial<{
|
||||
title: string | null;
|
||||
config: string;
|
||||
}>;
|
||||
|
||||
export class HomepageItemRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<HomepageItemRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.orderBy(asc(homepageItems.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: HomepageItemCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord> {
|
||||
const [created] = await insertReturning(this.context, homepageItems, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: HomepageItemUpdateInput,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
{ ...updates, updatedAt },
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
|
||||
|
||||
export class HomepageLayoutRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<HomepageLayoutRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
layout: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageLayoutRecord> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await insertReturning(this.context, homepageLayouts, {
|
||||
userId,
|
||||
layout,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
homepageLayouts,
|
||||
{ layout, updatedAt },
|
||||
eq(homepageLayouts.userId, userId),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
|
||||
import { recordRecentActivity } from "../../services/recent-activity.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
function isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/uptime:
|
||||
* get:
|
||||
* summary: Get server uptime
|
||||
* description: Returns the uptime of the server in various formats.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server uptime information.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* uptimeMs:
|
||||
* type: number
|
||||
* uptimeSeconds:
|
||||
* type: number
|
||||
* formatted:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get uptime.
|
||||
*/
|
||||
router.get("/uptime", authenticateJWT, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/recent:
|
||||
* get:
|
||||
* summary: Get recent activity
|
||||
* description: Fetches the most recent activities for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The maximum number of activities to return.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent activities.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to get recent activity.
|
||||
*/
|
||||
router.get(
|
||||
"/activity/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities =
|
||||
await createCurrentRecentActivityRepository().listByUserId(
|
||||
userId,
|
||||
limit,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/log:
|
||||
* post:
|
||||
* summary: Log a new activity
|
||||
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Activity logged successfully or rate-limited.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 404:
|
||||
* description: Host not found or access denied.
|
||||
* 500:
|
||||
* description: Failed to log activity.
|
||||
*/
|
||||
router.post(
|
||||
"/activity/log",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await recordRecentActivity(userId, {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
if (result.status === "invalid_type") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
});
|
||||
}
|
||||
if (result.status === "rate_limited") {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/activity/reset:
|
||||
* delete:
|
||||
* summary: Reset recent activity
|
||||
* description: Clears all recent activity for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent activity cleared.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to reset activity.
|
||||
*/
|
||||
router.delete(
|
||||
"/activity/reset",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,263 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import {
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export const dashboardServiceLinksRouter = express.Router();
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links:
|
||||
* get:
|
||||
* summary: Get service links
|
||||
* description: Returns all dashboard service links for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of service links.
|
||||
* 500:
|
||||
* description: Failed to fetch service links.
|
||||
*/
|
||||
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const links =
|
||||
await createCurrentDashboardServiceLinkRepository().listByUserId(userId);
|
||||
res.json(links);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to fetch service links", err);
|
||||
res.status(500).json({ error: "Failed to fetch service links" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links:
|
||||
* post:
|
||||
* summary: Create service link
|
||||
* description: Creates a new dashboard service link for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - label
|
||||
* - url
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Service link created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { label, url } = req.body;
|
||||
|
||||
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
|
||||
return res.status(400).json({ error: "label and url are required" });
|
||||
}
|
||||
if (!isValidUrl(url)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created =
|
||||
await createCurrentDashboardServiceLinkRepository().createForUser(
|
||||
userId,
|
||||
{
|
||||
label: label.trim(),
|
||||
url: url.trim(),
|
||||
},
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to create service link", err);
|
||||
res.status(500).json({ error: "Failed to create service link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links/{id}:
|
||||
* delete:
|
||||
* summary: Delete service link
|
||||
* description: Deletes a dashboard service link by ID.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.delete(
|
||||
"/:id",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const idParam = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const id = parseInt(idParam);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const deleted =
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"dashboardServiceLinks",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||
res.json({ message: "Service link deleted" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to delete service link", err);
|
||||
res.status(500).json({ error: "Failed to delete service link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links/{id}:
|
||||
* put:
|
||||
* summary: Update service link
|
||||
* description: Updates label or url of a dashboard service link.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update service link.
|
||||
*/
|
||||
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const idParam = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const id = parseInt(idParam);
|
||||
const { label, url } = req.body;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid id" });
|
||||
}
|
||||
if (url !== undefined && !isValidUrl(url)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing =
|
||||
await createCurrentDashboardServiceLinkRepository().findByIdForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const updates: Partial<{ label: string; url: string }> = {};
|
||||
if (isNonEmptyString(label)) updates.label = label.trim();
|
||||
if (isNonEmptyString(url)) updates.url = url.trim();
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: "Nothing to update" });
|
||||
}
|
||||
|
||||
const updated =
|
||||
await createCurrentDashboardServiceLinkRepository().updateForUser(
|
||||
userId,
|
||||
id,
|
||||
updates,
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to update service link", err);
|
||||
res.status(500).json({ error: "Failed to update service link" });
|
||||
}
|
||||
});
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHomepageLayoutRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostSidebarPreferenceRepository,
|
||||
@@ -97,9 +94,10 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentHostRepository().deleteByUserId(userId);
|
||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||
|
||||
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageItemRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
|
||||
// homepage_items, homepage_layouts and dashboard_service_links cascade on
|
||||
// the user's refUser() foreign key, as the homepage plugin's adopted
|
||||
// tables.
|
||||
|
||||
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
export const homepageFaviconRouter = express.Router();
|
||||
|
||||
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
|
||||
const faviconCache = new Map<
|
||||
string,
|
||||
{ data: Buffer; contentType: string; expires: number }
|
||||
>();
|
||||
const CACHE_SIZE = 100;
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
const MAX_FAVICON_BYTES = 512 * 1024;
|
||||
|
||||
function evictIfNeeded() {
|
||||
if (faviconCache.size >= CACHE_SIZE) {
|
||||
const oldest = faviconCache.keys().next().value;
|
||||
if (oldest) faviconCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
res.destroy();
|
||||
reject(error);
|
||||
};
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_FAVICON_BYTES) {
|
||||
fail(new Error("Favicon response too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
data: Buffer.concat(chunks),
|
||||
contentType: res.headers["content-type"] || "image/x-icon",
|
||||
});
|
||||
});
|
||||
res.on("error", fail);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Favicon fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/favicon:
|
||||
* get:
|
||||
* summary: Proxy favicon fetch
|
||||
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Favicon image.
|
||||
* 400:
|
||||
* description: Invalid URL.
|
||||
* 500:
|
||||
* description: Failed to fetch favicon.
|
||||
*/
|
||||
homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
|
||||
const rawUrl = req.query.url as string;
|
||||
if (!rawUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
let domain: string;
|
||||
try {
|
||||
domain = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = faviconCache.get(domain);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
res.setHeader("Content-Type", cached.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.send(cached.data);
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchUrl(faviconUrl);
|
||||
evictIfNeeded();
|
||||
faviconCache.set(domain, {
|
||||
data,
|
||||
contentType,
|
||||
expires: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.send(data);
|
||||
} catch {
|
||||
homepageLogger.warn("Failed to fetch favicon", { domain });
|
||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||
}
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* get:
|
||||
* summary: Get homepage items
|
||||
* description: Returns all homepage widget items for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of homepage items.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage items.
|
||||
*/
|
||||
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const items =
|
||||
await createCurrentHomepageItemRepository().listByUserId(userId);
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage items", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage items" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* post:
|
||||
* summary: Create homepage item
|
||||
* description: Creates a new homepage widget item for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - typeId
|
||||
* properties:
|
||||
* typeId:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Homepage item created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create homepage item.
|
||||
*/
|
||||
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { typeId, title, config } = req.body;
|
||||
|
||||
if (!typeId || typeof typeId !== "string") {
|
||||
return res.status(400).json({ error: "typeId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentHomepageItemRepository().createForUser(
|
||||
userId,
|
||||
{
|
||||
typeId,
|
||||
title: title ?? null,
|
||||
config: config ? JSON.stringify(config) : "{}",
|
||||
},
|
||||
);
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to create homepage item", err);
|
||||
res.status(500).json({ error: "Failed to create homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* put:
|
||||
* summary: Update homepage item
|
||||
* description: Updates a homepage widget item.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update homepage item.
|
||||
*/
|
||||
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { title, config } = req.body;
|
||||
|
||||
try {
|
||||
const itemRepository = createCurrentHomepageItemRepository();
|
||||
const existing = await itemRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const updates: Parameters<typeof itemRepository.updateForUser>[2] = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (config !== undefined) updates.config = JSON.stringify(config);
|
||||
|
||||
const updated = await itemRepository.updateForUser(userId, id, updates);
|
||||
res.json(updated ?? existing);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to update homepage item", err);
|
||||
res.status(500).json({ error: "Failed to update homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete homepage item
|
||||
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete homepage item.
|
||||
*/
|
||||
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const itemRepository = createCurrentHomepageItemRepository();
|
||||
const existing = await itemRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const deleted = await itemRepository.deleteForUser(userId, id);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"homepageItems",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
res.status(500).json({ error: "Failed to delete homepage item" });
|
||||
}
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
|
||||
|
||||
export const homepageLayoutRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* get:
|
||||
* summary: Get homepage layout
|
||||
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout data or null.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const row =
|
||||
await createCurrentHomepageLayoutRepository().findByUserId(userId);
|
||||
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(row.layout || "{}");
|
||||
res.json({ ...row, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* put:
|
||||
* summary: Save homepage layout
|
||||
* description: Saves or updates the homepage canvas layout for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* entries:
|
||||
* type: array
|
||||
* pan:
|
||||
* type: object
|
||||
* zoom:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 500:
|
||||
* description: Failed to save homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const layoutData = req.body;
|
||||
|
||||
try {
|
||||
const layoutJson = JSON.stringify(layoutData);
|
||||
const now = new Date().toISOString();
|
||||
const updated = await createCurrentHomepageLayoutRepository().upsertForUser(
|
||||
userId,
|
||||
layoutJson,
|
||||
now,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(updated.layout);
|
||||
res.json({ ...updated, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to save homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to save homepage layout" });
|
||||
}
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepagePingRouter = express.Router();
|
||||
|
||||
interface PingCacheEntry {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const pingCache = new Map<string, PingCacheEntry>();
|
||||
const CACHE_SIZE = 200;
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
async function requestStatus(
|
||||
url: string,
|
||||
method: "HEAD" | "GET",
|
||||
): Promise<number | null> {
|
||||
const res = await safeOutboundFetch(url, {
|
||||
method,
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
// Discard the body without buffering it.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
return res.status ?? null;
|
||||
}
|
||||
|
||||
async function pingUrl(
|
||||
url: string,
|
||||
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
|
||||
const start = performance.now();
|
||||
const elapsed = () => Math.round(performance.now() - start);
|
||||
try {
|
||||
let code = await requestStatus(url, "HEAD");
|
||||
if (code === 405) {
|
||||
code = await requestStatus(url, "GET");
|
||||
}
|
||||
return {
|
||||
ok: code !== null && code < 400,
|
||||
statusCode: code,
|
||||
latencyMs: elapsed(),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, statusCode: null, latencyMs: elapsed() };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/ping:
|
||||
* get:
|
||||
* summary: Check the HTTP reachability and latency of a URL
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ping result with ok, statusCode and latencyMs.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
*/
|
||||
homepagePingRouter.get("/", async (req: Request, res: Response) => {
|
||||
let targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = pingCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json({
|
||||
ok: cached.ok,
|
||||
statusCode: cached.statusCode,
|
||||
latencyMs: cached.latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pingUrl(targetUrl);
|
||||
if (pingCache.size >= CACHE_SIZE) {
|
||||
const oldest = pingCache.keys().next().value;
|
||||
if (oldest) pingCache.delete(oldest);
|
||||
}
|
||||
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Ping failed", { targetUrl });
|
||||
res.status(500).json({ error: "Ping failed" });
|
||||
}
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
readResponseTextLimited,
|
||||
safeOutboundFetch,
|
||||
} from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepageProxyRouter = express.Router();
|
||||
|
||||
interface ProxyCacheEntry {
|
||||
data: unknown;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const proxyCache = new Map<string, ProxyCacheEntry>();
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
async function fetchJson(rawUrl: string): Promise<unknown> {
|
||||
const response = await safeOutboundFetch(rawUrl, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
|
||||
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Response is not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/proxy:
|
||||
* get:
|
||||
* summary: Proxy a JSON API URL and return the parsed response
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10, default 60)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The JSON body returned by the target URL.
|
||||
* 400:
|
||||
* description: Invalid or missing URL, or non-JSON response.
|
||||
* 500:
|
||||
* description: Failed to fetch the target URL.
|
||||
*/
|
||||
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||
const targetUrl = req.query.url as string;
|
||||
const ttl =
|
||||
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
|
||||
1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = proxyCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchJson(targetUrl);
|
||||
if (proxyCache.size >= CACHE_SIZE) {
|
||||
const oldest = proxyCache.keys().next().value;
|
||||
if (oldest) proxyCache.delete(oldest);
|
||||
}
|
||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
|
||||
if (msg.includes("not valid JSON")) {
|
||||
return res.status(400).json({ error: "Response is not valid JSON" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to fetch URL" });
|
||||
}
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
readResponseTextLimited,
|
||||
safeOutboundFetch,
|
||||
} from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
export const homepageRssRouter = express.Router();
|
||||
|
||||
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
||||
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function fetchXml(url: string): Promise<string> {
|
||||
return safeOutboundFetch(url, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
|
||||
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
|
||||
});
|
||||
}
|
||||
|
||||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const getText = (tag: string, src: string): string | null => {
|
||||
const m = new RegExp(
|
||||
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
||||
"i",
|
||||
).exec(src);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2]).trim();
|
||||
};
|
||||
|
||||
const getLink = (src: string): string => {
|
||||
// Self-closing <link rel="alternate" href="..." /> (BBC style)
|
||||
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
|
||||
if (selfClose) return selfClose[1];
|
||||
// Plain text <link>url</link>
|
||||
return getText("link", src) ?? "";
|
||||
};
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: getLink(src),
|
||||
pubDate: getText("pubDate", src) ?? getText("updated", src),
|
||||
description: getText("description", src) ?? getText("summary", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
|
||||
// Atom feed fallback
|
||||
if (items.length === 0) {
|
||||
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: linkMatch?.[1] ?? "",
|
||||
pubDate: getText("published", src) ?? getText("updated", src),
|
||||
description: getText("summary", src) ?? getText("content", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/rss:
|
||||
* get:
|
||||
* summary: Proxy and parse an RSS/Atom feed
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: max
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of feed items.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
* 500:
|
||||
* description: Failed to fetch or parse the feed.
|
||||
*/
|
||||
homepageRssRouter.get("/", async (req: Request, res: Response) => {
|
||||
const feedUrl = req.query.url as string;
|
||||
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
|
||||
|
||||
if (!feedUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = rssCache.get(feedUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data.slice(0, max));
|
||||
}
|
||||
|
||||
try {
|
||||
const xml = await fetchXml(feedUrl);
|
||||
const items = parseRss(xml);
|
||||
|
||||
if (rssCache.size >= CACHE_SIZE) {
|
||||
const oldest = rssCache.keys().next().value;
|
||||
if (oldest) rssCache.delete(oldest);
|
||||
}
|
||||
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
||||
|
||||
res.json(items.slice(0, max));
|
||||
} catch {
|
||||
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
||||
res.status(500).json({ error: "Failed to fetch feed" });
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
vaultProfiles,
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
userPreferences,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
@@ -104,17 +102,8 @@ export function registerCoreSyncEntities(): void {
|
||||
encryptedFields: ["ssh_data"],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "dashboardServiceLinks",
|
||||
table: dashboardServiceLinks,
|
||||
order: 70,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "homepageItems",
|
||||
table: homepageItems,
|
||||
order: 80,
|
||||
});
|
||||
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
|
||||
// the homepage plugin, which registers them itself at activation.
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "userPreferences",
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import { dashboardLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { dashboardServiceLinksRouter } from "../database/routes/dashboard-service-links-routes.js";
|
||||
import { createCurrentRecentActivityRepository } from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { recordRecentActivity } from "./recent-activity.js";
|
||||
import { listenOnServicePort } from "../utils/service-listen.js";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
function isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /uptime:
|
||||
* get:
|
||||
* summary: Get server uptime
|
||||
* description: Returns the uptime of the server in various formats.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server uptime information.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* uptimeMs:
|
||||
* type: number
|
||||
* uptimeSeconds:
|
||||
* type: number
|
||||
* formatted:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get uptime.
|
||||
*/
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/recent:
|
||||
* get:
|
||||
* summary: Get recent activity
|
||||
* description: Fetches the most recent activities for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The maximum number of activities to return.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent activities.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to get recent activity.
|
||||
*/
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities =
|
||||
await createCurrentRecentActivityRepository().listByUserId(userId, limit);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/log:
|
||||
* post:
|
||||
* summary: Log a new activity
|
||||
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Activity logged successfully or rate-limited.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 404:
|
||||
* description: Host not found or access denied.
|
||||
* 500:
|
||||
* description: Failed to log activity.
|
||||
*/
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await recordRecentActivity(userId, {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
if (result.status === "invalid_type") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
});
|
||||
}
|
||||
if (result.status === "rate_limited") {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
return res.status(404).json({ error: "Host not found or access denied" });
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/reset:
|
||||
* delete:
|
||||
* summary: Reset recent activity
|
||||
* description: Clears all recent activity for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent activity cleared.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to reset activity.
|
||||
*/
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
});
|
||||
|
||||
app.use("/service-links", dashboardServiceLinksRouter);
|
||||
|
||||
const PORT = 30006;
|
||||
listenOnServicePort({
|
||||
app,
|
||||
port: PORT,
|
||||
logger: dashboardLogger,
|
||||
serviceName: "dashboard",
|
||||
onListening: async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { listenOnServicePort } from "../utils/service-listen.js";
|
||||
import { homepageLogger } from "../utils/logger.js";
|
||||
import { homepageItemsRouter } from "../database/routes/homepage-items-routes.js";
|
||||
import { homepageLayoutRouter } from "../database/routes/homepage-layout-routes.js";
|
||||
import { homepageFaviconRouter } from "../database/routes/homepage-favicon-routes.js";
|
||||
import { homepageRssRouter } from "../database/routes/homepage-rss-routes.js";
|
||||
import { homepagePingRouter } from "../database/routes/homepage-ping-routes.js";
|
||||
import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js";
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
const authManager = AuthManager.getInstance();
|
||||
const PORT = 30012;
|
||||
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
app.use("/homepage/items", homepageItemsRouter);
|
||||
app.use("/homepage/layout", homepageLayoutRouter);
|
||||
app.use("/homepage/favicon", homepageFaviconRouter);
|
||||
app.use("/homepage/rss", homepageRssRouter);
|
||||
app.use("/homepage/ping", homepagePingRouter);
|
||||
app.use("/homepage/proxy", homepageProxyRouter);
|
||||
|
||||
listenOnServicePort({
|
||||
app,
|
||||
port: PORT,
|
||||
logger: homepageLogger,
|
||||
serviceName: "homepage",
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -317,14 +317,14 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
|
||||
// plugins/file-manager and plugins/tmux-monitor.
|
||||
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
|
||||
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial) is absent
|
||||
// for the same reason: each one serves its routes under /plugin-api/<id>/
|
||||
// (or a WS route under /plugin-ws/<id>/) through ctx on activate, so
|
||||
// disabling it answers 503 instead of leaving a dead import here.
|
||||
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial, Homepage)
|
||||
// is absent for the same reason: each one serves its routes under
|
||||
// /plugin-api/<id>/ (or a WS route under /plugin-ws/<id>/) through ctx on
|
||||
// activate, so disabling it answers 503 instead of leaving a dead import
|
||||
// here. The dashboard's own uptime and recent-activity routes are core
|
||||
// and are mounted on the main server in database.ts.
|
||||
// Automations' scheduler and tunnel autostart also start from their own
|
||||
// activate() rather than here.
|
||||
await import("./services/dashboard.js");
|
||||
await import("./services/homepage.js");
|
||||
|
||||
// Initialize log level from database settings
|
||||
const { getCurrentSettingValue } =
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { DashboardServiceLinkRepository } from "../../../database/repositories/dashboard-service-link-repository.js";
|
||||
|
||||
describe("DashboardServiceLinkRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<DashboardServiceLinkRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new DashboardServiceLinkRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists links ordered by order then id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.createForUser(
|
||||
"user-1",
|
||||
{ label: "Docs", url: "https://docs.example.com" },
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
const second = await repo.createForUser("user-1", {
|
||||
label: "Status",
|
||||
url: "https://status.example.com",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
label: "Other",
|
||||
url: "https://other.example.com",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
label: "Docs",
|
||||
order: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect(second.order).toBe(1);
|
||||
expect(
|
||||
(await repo.listByUserId("user-1")).map((link) => link.label),
|
||||
).toEqual(["Docs", "Status"]);
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes a user-owned link", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const link = await repo.createForUser("user-1", {
|
||||
label: "Docs",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", link.id)).toBeNull();
|
||||
const updated = await repo.updateForUser("user-1", link.id, {
|
||||
label: "Docs renamed",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: link.id,
|
||||
label: "Docs renamed",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe(
|
||||
null,
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all dashboard links for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
label: "Docs",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
await repo.createForUser("user-1", {
|
||||
label: "Status",
|
||||
url: "https://status.example.com",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
label: "Other",
|
||||
url: "https://other.example.com",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((link) => link.label),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HomepageItemRepository } from "../../../database/repositories/homepage-item-repository.js";
|
||||
|
||||
describe("HomepageItemRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HomepageItemRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new HomepageItemRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists homepage items by id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.createForUser(
|
||||
"user-1",
|
||||
{ typeId: "clock", title: "Clock", config: "{}" },
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
const second = await repo.createForUser("user-1", {
|
||||
typeId: "terminal",
|
||||
title: null,
|
||||
config: '{"hostId":1}',
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
typeId: "other",
|
||||
title: "Other",
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes user-owned homepage items", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const item = await repo.createForUser("user-1", {
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", item.id)).toBeNull();
|
||||
const updated = await repo.updateForUser(
|
||||
"user-1",
|
||||
item.id,
|
||||
{ title: "Clock renamed", config: '{"timezone":"UTC"}' },
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: item.id,
|
||||
title: "Clock renamed",
|
||||
config: '{"timezone":"UTC"}',
|
||||
updatedAt: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-2", item.id, { title: "Nope" }),
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all homepage items for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
});
|
||||
await repo.createForUser("user-1", {
|
||||
typeId: "terminal",
|
||||
title: "Terminal",
|
||||
config: "{}",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
typeId: "other",
|
||||
title: "Other",
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((item) => item.title),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HomepageLayoutRepository } from "../../../database/repositories/homepage-layout-repository.js";
|
||||
|
||||
describe("HomepageLayoutRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HomepageLayoutRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new HomepageLayoutRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds, creates, and updates a layout by user id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
|
||||
const created = await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }),
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-1",
|
||||
layout: '{"entries":[{"id":"w1"}],"zoom":1}',
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ entries: [], zoom: 1.25 }),
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: created.id,
|
||||
userId: "user-1",
|
||||
layout: '{"entries":[],"zoom":1.25}',
|
||||
updatedAt: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers writes after create and update", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertForUser("user-1", "{}");
|
||||
await repo.upsertForUser("user-1", '{"zoom":2}');
|
||||
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes a layout for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertForUser("user-1", '{"zoom":1}');
|
||||
await repo.upsertForUser("user-2", '{"zoom":2}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}');
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
unlocked: true,
|
||||
activity: [] as unknown[],
|
||||
recordResult: { status: "logged", id: 1 } as {
|
||||
status: string;
|
||||
id?: number;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
dashboardLogger: { warn: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||
(req as unknown as { userId: string }).userId = "user-1";
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: () => (state.unlocked ? "key" : null),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentRecentActivityRepository: () => ({
|
||||
listByUserId: async () => state.activity,
|
||||
deleteByUserId: async () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../services/recent-activity.js", () => ({
|
||||
recordRecentActivity: async () => state.recordResult,
|
||||
}));
|
||||
|
||||
const { default: dashboardRoutes } =
|
||||
await import("../../../database/routes/dashboard-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.unlocked = true;
|
||||
state.activity = [];
|
||||
state.recordResult = { status: "logged", id: 1 };
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe("GET /dashboard/uptime", () => {
|
||||
it("returns a formatted uptime", async () => {
|
||||
const res = await fetch(`${baseUrl}/dashboard/uptime`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({
|
||||
uptimeMs: expect.any(Number),
|
||||
formatted: expect.stringMatching(/^\d+d \d+h \d+m$/),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /dashboard/activity/recent", () => {
|
||||
it("401s when the user's data key is not unlocked", async () => {
|
||||
state.unlocked = false;
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the user's recent activity", async () => {
|
||||
state.activity = [{ id: 1, type: "terminal" }];
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(state.activity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /dashboard/activity/log", () => {
|
||||
it("rejects a body missing required fields", async () => {
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "terminal" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("logs a valid activity", async () => {
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ message: "Activity logged", id: 1 });
|
||||
});
|
||||
|
||||
it("404s a host the user cannot access", async () => {
|
||||
state.recordResult = { status: "denied" };
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /dashboard/activity/reset", () => {
|
||||
it("clears the user's recent activity", async () => {
|
||||
const res = await fetch(`${baseUrl}/dashboard/activity/reset`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ message: "Recent activity cleared" });
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "../../../database/routes/service-link-url.js";
|
||||
|
||||
describe("service link URL handling", () => {
|
||||
it("keeps explicit http and https URLs", () => {
|
||||
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds http to bare service addresses", () => {
|
||||
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
|
||||
});
|
||||
|
||||
it("rejects unsupported schemes", () => {
|
||||
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
|
||||
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,8 +34,6 @@ describe("isValidEntityType", () => {
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"vaultProfiles",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
|
||||
@@ -96,13 +96,15 @@ describe("core sync entities", () => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
// The frozen array also lists networkTopology and snippets/snippetFolders,
|
||||
// now registered by the network-topology and snippets plugins rather than
|
||||
// core.
|
||||
// The frozen array also lists networkTopology, snippets/snippetFolders and
|
||||
// dashboardServiceLinks/homepageItems, now registered by the
|
||||
// network-topology, snippets and homepage plugins rather than core.
|
||||
const PLUGIN_OWNED_TYPES = new Set([
|
||||
"networkTopology",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
]);
|
||||
|
||||
it("registers every core-owned entity the Electron client knows about", () => {
|
||||
|
||||
@@ -308,7 +308,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316");
|
||||
export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16");
|
||||
|
||||
export const logger = systemLogger;
|
||||
@@ -26,10 +26,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30004",
|
||||
description: "SSH file manager server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30006",
|
||||
description: "Dashboard server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
||||
@@ -32,14 +32,6 @@ const AppShell = lazy(() =>
|
||||
import("@/AppShell").then((m) => ({ default: m.AppShell })),
|
||||
);
|
||||
|
||||
// Full-screen apps opened via query params (e.g. from external links or
|
||||
// Electron). Plugin views register a standalone component with their tab.
|
||||
const HomepageApp = lazy(() =>
|
||||
import("@/features/homepage/HomepageApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
|
||||
const ElectronVersionCheck = lazy(() =>
|
||||
import("@/user/ElectronVersionCheck").then((module) => ({
|
||||
default: module.ElectronVersionCheck,
|
||||
@@ -59,8 +51,6 @@ function FullscreenApp() {
|
||||
const hostId = searchParams.get("hostId");
|
||||
|
||||
switch (view) {
|
||||
case "homepage":
|
||||
return <HomepageApp />;
|
||||
default: {
|
||||
const def = view ? standaloneViewFor(view) : undefined;
|
||||
if (!def?.standalone) {
|
||||
|
||||
@@ -36,6 +36,15 @@ export type UiAreaKey =
|
||||
| "hostEditor"
|
||||
| "homepage";
|
||||
|
||||
/**
|
||||
* The homepage area key, named rather than spelled inline in src/ui: it
|
||||
* happens to share its spelling with the homepage plugin's id, which
|
||||
* scripts/check-shell-plugin-ids.cjs treats as a plugin id wherever it sees
|
||||
* that literal under src/ui. This area predates plugins and is unrelated -
|
||||
* it is core's own widget-visibility override, not the plugin.
|
||||
*/
|
||||
export const HOMEPAGE_AREA_KEY: UiAreaKey = "homepage";
|
||||
|
||||
export type UiDensity = "comfortable" | "compact";
|
||||
export type UiTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
||||
export type UiRowActions = "essential" | "full";
|
||||
|
||||
@@ -319,8 +319,6 @@ export type DashboardCardId =
|
||||
| "quick_actions"
|
||||
| "host_status"
|
||||
| "recent_activity"
|
||||
| "service_links"
|
||||
| "homepage_preview"
|
||||
| (string & {});
|
||||
|
||||
export type DashboardCardConfig = {
|
||||
|
||||
@@ -1752,7 +1752,6 @@ export function AppShell({
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
sftp: t("nav.sftp"),
|
||||
homepage: t("nav.homepage"),
|
||||
};
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dashboardApi, handleApiError } from "@/main-axios";
|
||||
import { normalizeServiceLinkUrl } from "@/lib/service-link-url";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// DASHBOARD API
|
||||
// ============================================================================
|
||||
@@ -22,7 +21,7 @@ export interface RecentActivityItem {
|
||||
|
||||
export async function getUptime(): Promise<UptimeInfo> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/uptime");
|
||||
const response = await authApi.get("/dashboard/uptime");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch uptime");
|
||||
@@ -33,7 +32,7 @@ export async function getRecentActivity(
|
||||
limit?: number,
|
||||
): Promise<RecentActivityItem[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/activity/recent", {
|
||||
const response = await authApi.get("/dashboard/activity/recent", {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -48,7 +47,7 @@ export async function logActivity(
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/activity/log", {
|
||||
const response = await authApi.post("/dashboard/activity/log", {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
@@ -61,71 +60,9 @@ export async function logActivity(
|
||||
|
||||
export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete("/activity/reset");
|
||||
const response = await authApi.delete("/dashboard/activity/reset");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reset recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getServiceLinks(): Promise<ServiceLink[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/service-links");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch service links");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createServiceLink(
|
||||
label: string,
|
||||
url: string,
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/service-links", {
|
||||
label,
|
||||
url: normalizeServiceLinkUrl(url),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServiceLink(
|
||||
id: number,
|
||||
): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete(`/service-links/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateServiceLink(
|
||||
id: number,
|
||||
updates: { label?: string; url?: string },
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.put(`/service-links/${id}`, {
|
||||
...updates,
|
||||
url:
|
||||
updates.url !== undefined
|
||||
? normalizeServiceLinkUrl(updates.url)
|
||||
: undefined,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update service link");
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { homepageApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
HomepageItemRow,
|
||||
HomepageLayoutData,
|
||||
HomepageLayoutRow,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
|
||||
try {
|
||||
const res = await homepageApi.get("/items");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage items");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHomepageItem(data: {
|
||||
typeId: WidgetTypeId;
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.post("/items", data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHomepageItem(
|
||||
id: number,
|
||||
data: {
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<HomepageItemRow> {
|
||||
try {
|
||||
const res = await homepageApi.put(`/items/${id}`, data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHomepageItem(id: number): Promise<void> {
|
||||
try {
|
||||
await homepageApi.delete(`/items/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete homepage item");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
|
||||
try {
|
||||
const res = await homepageApi.get("/layout");
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch homepage layout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHomepageLayout(
|
||||
layout: HomepageLayoutData,
|
||||
): Promise<HomepageLayoutRow> {
|
||||
try {
|
||||
const res = await homepageApi.put("/layout", layout);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save homepage layout");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
Database,
|
||||
ExternalLink,
|
||||
GripHorizontal,
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
Network,
|
||||
Plus,
|
||||
Server,
|
||||
@@ -39,12 +38,9 @@ import {
|
||||
getCredentials,
|
||||
resetRecentActivity,
|
||||
getUserInfo,
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
|
||||
import type { RecentActivityItem } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getRegisteredDashboardCard,
|
||||
@@ -58,16 +54,6 @@ import {
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
// Side-effect imports so homepage widgets register themselves
|
||||
import "@/features/homepage/widgets/ServiceLinkWidget";
|
||||
import "@/features/homepage/widgets/ClockWidget";
|
||||
import "@/features/homepage/widgets/NotesWidget";
|
||||
import "@/features/homepage/widgets/BookmarkListWidget";
|
||||
import "@/features/homepage/widgets/HostStatusWidget";
|
||||
import "@/features/homepage/widgets/FolderWidget";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
@@ -75,10 +61,6 @@ import {
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "@/lib/service-link-url";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -140,8 +122,6 @@ const CORE_CARD_IDS = new Set<string>([
|
||||
"quick_actions",
|
||||
"host_status",
|
||||
"recent_activity",
|
||||
"service_links",
|
||||
"homepage_preview",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -645,131 +625,6 @@ function RecentActivityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceLinksCard({
|
||||
links,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
links: ServiceLink[];
|
||||
onAdd: (label: string, url: string) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
setUrlError(true);
|
||||
setAddError("");
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
setAdding(true);
|
||||
try {
|
||||
await onAdd(label.trim(), normalizedUrl);
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
} catch (error) {
|
||||
setAddError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("dashboardTab.serviceLinksAddFailed"),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Link className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.serviceLinksTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
{links.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-4">
|
||||
{t("dashboardTab.serviceLinksEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 group/link"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{link.url}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onDelete(link.id)}
|
||||
className="ml-2 opacity-0 group-hover/link:opacity-100 transition-opacity size-5 flex items-center justify-center hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t border-border shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")}
|
||||
className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
}}
|
||||
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white h-6 px-2 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!label.trim() || !url.trim() || adding}
|
||||
>
|
||||
{t("dashboardTab.serviceLinksAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
{urlError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||
</div>
|
||||
)}
|
||||
{addError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardItem({
|
||||
@@ -794,9 +649,6 @@ function CardItem({
|
||||
activity,
|
||||
onClearActivity,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: {
|
||||
@@ -821,9 +673,6 @@ function CardItem({
|
||||
activity: RecentActivityItem[];
|
||||
onClearActivity: () => void;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading?: boolean;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
@@ -926,18 +775,6 @@ function CardItem({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={onAddServiceLink}
|
||||
onDelete={onDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "homepage_preview" && (
|
||||
<HomepagePreviewCard
|
||||
onOpenFullscreen={() => onOpenSingletonTab("homepage")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editMode && !isFlex && (
|
||||
<div
|
||||
@@ -1049,9 +886,6 @@ type PanelColumnProps = {
|
||||
onClearActivity: () => void;
|
||||
cardLabels: Record<DashboardCardId, string>;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading: boolean;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
@@ -1081,9 +915,6 @@ function PanelColumn({
|
||||
onClearActivity,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: PanelColumnProps) {
|
||||
@@ -1138,9 +969,6 @@ function PanelColumn({
|
||||
activity={activity}
|
||||
onClearActivity={onClearActivity}
|
||||
isAdmin={isAdmin}
|
||||
serviceLinks={serviceLinks}
|
||||
onAddServiceLink={onAddServiceLink}
|
||||
onDeleteServiceLink={onDeleteServiceLink}
|
||||
statusLoading={statusLoading}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
@@ -1237,26 +1065,23 @@ export function DashboardTab({
|
||||
return () => window.removeEventListener("dashboardSlotsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const [homepageLinkCopied, setHomepageLinkCopied] = useState(false);
|
||||
// A plugin's own view next to the dashboard, e.g. the homepage plugin's
|
||||
// canvas preview. Only one is offered today; "dashboard" always exists.
|
||||
const secondaryViews = useActionSlot("dashboard.secondaryView");
|
||||
const secondaryView = secondaryViews[0];
|
||||
|
||||
const handleCopyHomepageLink = () => {
|
||||
navigator.clipboard
|
||||
.writeText(`${window.location.origin}?view=homepage`)
|
||||
.catch(() => {});
|
||||
setHomepageLinkCopied(true);
|
||||
setTimeout(() => setHomepageLinkCopied(false), 1500);
|
||||
};
|
||||
|
||||
const [dashboardView, setDashboardView] = useState<"dashboard" | "homepage">(
|
||||
() => {
|
||||
try {
|
||||
return (localStorage.getItem("dashboardView") ?? "dashboard") as
|
||||
"dashboard" | "homepage";
|
||||
} catch {
|
||||
return "dashboard";
|
||||
}
|
||||
},
|
||||
);
|
||||
const [dashboardView, setDashboardView] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem("dashboardView") ?? "dashboard";
|
||||
} catch {
|
||||
return "dashboard";
|
||||
}
|
||||
});
|
||||
// Falls back to the dashboard when the view a plugin contributed is off.
|
||||
const isDashboardView =
|
||||
dashboardView === "dashboard" ||
|
||||
!secondaryView ||
|
||||
dashboardView !== secondaryView.actionId;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -1398,24 +1223,6 @@ export function DashboardTab({
|
||||
}
|
||||
};
|
||||
|
||||
const [serviceLinks, setServiceLinks] = useState<ServiceLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then(setServiceLinks)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddServiceLink = async (label: string, url: string) => {
|
||||
const created = await createServiceLink(label, url);
|
||||
setServiceLinks((prev) => [...prev, created]);
|
||||
};
|
||||
|
||||
const handleDeleteServiceLink = async (id: number) => {
|
||||
await deleteServiceLink(id);
|
||||
setServiceLinks((prev) => prev.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const todayLabel = new Date().toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
@@ -1438,8 +1245,6 @@ export function DashboardTab({
|
||||
quick_actions: t("dashboard.quickActions"),
|
||||
host_status: t("dashboardTab.hostStatus"),
|
||||
recent_activity: t("dashboard.recentActivity"),
|
||||
service_links: t("dashboard.serviceLinks"),
|
||||
homepage_preview: t("dashboard.homepagePreview"),
|
||||
...Object.fromEntries(
|
||||
registeredCards.map((card) => [card.id, t(card.titleKey)]),
|
||||
),
|
||||
@@ -1514,11 +1319,7 @@ export function DashboardTab({
|
||||
: 0;
|
||||
const defaultHeight: number | null =
|
||||
getRegisteredDashboardCard(id)?.defaultHeight ??
|
||||
(id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150);
|
||||
(id === "host_status" || id === "recent_activity" ? null : 150);
|
||||
const key = `${id}_${Date.now()}`;
|
||||
return [
|
||||
...prev,
|
||||
@@ -1557,9 +1358,6 @@ export function DashboardTab({
|
||||
onOpenTab,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink: handleAddServiceLink,
|
||||
onDeleteServiceLink: handleDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible,
|
||||
};
|
||||
@@ -1705,13 +1503,6 @@ export function DashboardTab({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={handleAddServiceLink}
|
||||
onDelete={handleDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1726,18 +1517,20 @@ export function DashboardTab({
|
||||
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
|
||||
<button
|
||||
onClick={() => setDashboardView("dashboard")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "dashboard" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${isDashboardView ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("dashboard.title")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDashboardView("homepage")}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${dashboardView === "homepage" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("nav.homepage")}
|
||||
</button>
|
||||
{secondaryView && (
|
||||
<button
|
||||
onClick={() => setDashboardView(secondaryView.actionId)}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${!isDashboardView ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t(secondaryView.titleKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{dashboardView === "dashboard" && (
|
||||
{isDashboardView && (
|
||||
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||
{todayLabel}
|
||||
</p>
|
||||
@@ -1824,7 +1617,7 @@ export function DashboardTab({
|
||||
{t("dashboard.donate")}
|
||||
</a>
|
||||
</Button>
|
||||
{dashboardView === "dashboard" && (
|
||||
{isDashboardView && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
{editMode ? (
|
||||
@@ -1860,43 +1653,9 @@ export function DashboardTab({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{dashboardView === "homepage" ? (
|
||||
{!isDashboardView && secondaryView?.component ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border shrink-0 bg-muted/20">
|
||||
<span className="text-[10px] text-muted-foreground/50 uppercase tracking-widest font-semibold">
|
||||
{t("nav.homepage")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={handleCopyHomepageLink}
|
||||
>
|
||||
{homepageLinkCopied ? (
|
||||
<>
|
||||
<Check size={10} className="text-accent-brand" />
|
||||
<span className="text-accent-brand">
|
||||
{t("homepage.linkCopied")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link size={10} />
|
||||
{t("homepage.copyLink")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={() => onOpenSingletonTab("homepage")}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HomepageCanvas />
|
||||
</div>
|
||||
<secondaryView.component onOpenSingletonTab={onOpenSingletonTab} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Card } from "@/components/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
interface HomepagePreviewCardProps {
|
||||
onOpenFullscreen: () => void;
|
||||
}
|
||||
|
||||
export function HomepagePreviewCard({
|
||||
onOpenFullscreen,
|
||||
}: HomepagePreviewCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="relative overflow-hidden w-full h-full flex flex-col p-0 gap-0">
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("homepage.previewTitle")}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live pannable preview — no pointer-events blocking so panning works */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<HomepageCanvas isReadOnly={true} fitOnLoad={true} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface RegisteredDashboardCard {
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
defaultHeight?: number;
|
||||
/** Which column a preset places this card in by default. Defaults to "main". */
|
||||
defaultPanel?: "main" | "side";
|
||||
component: ComponentType<DashboardCardRenderProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import { HomepageCanvas } from "./HomepageCanvas";
|
||||
|
||||
const HomepageApp: React.FC = () => {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden">
|
||||
<HomepageCanvas fitOnLoad={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomepageApp;
|
||||
@@ -1,566 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ContextMenuState,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE, MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import {
|
||||
getHomepageItems,
|
||||
getHomepageLayout,
|
||||
createHomepageItem,
|
||||
updateHomepageItem,
|
||||
deleteHomepageItem,
|
||||
saveHomepageLayout,
|
||||
} from "@/api/homepage-api";
|
||||
import { snapToGrid } from "./canvas/snapToGrid";
|
||||
import { screenToCanvas } from "./canvas/canvasGeometry";
|
||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||
import { useBoxDrag } from "./canvas/useBoxDrag";
|
||||
import { useBoxResize } from "./canvas/useBoxResize";
|
||||
import { CanvasDotBackground } from "./canvas/CanvasDotBackground";
|
||||
import { WidgetShell } from "./widgets/WidgetShell";
|
||||
import { AddWidgetMenu } from "./dialogs/AddWidgetMenu";
|
||||
import { WidgetEditDialog } from "./dialogs/WidgetEditDialog";
|
||||
import { HomepageToolbar } from "./toolbar/HomepageToolbar";
|
||||
import { getWidgetType } from "./widgets/WidgetRegistry";
|
||||
|
||||
// Side-effect imports so widgets register themselves
|
||||
import "./widgets/ServiceLinkWidget";
|
||||
import "./widgets/ClockWidget";
|
||||
import "./widgets/NotesWidget";
|
||||
import "./widgets/BookmarkListWidget";
|
||||
import "./widgets/HostStatusWidget";
|
||||
import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
import "./widgets/TermixUptimeWidget";
|
||||
import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
import "./widgets/SearchBarWidget";
|
||||
import "./widgets/TextBannerWidget";
|
||||
import "./widgets/ImageWidget";
|
||||
import "./widgets/MarkdownNotesWidget";
|
||||
import "./widgets/CustomApiWidget";
|
||||
import "./widgets/ServiceGridWidget";
|
||||
import "./widgets/DashboardLinksWidget";
|
||||
import "./widgets/SearchLinksWidget";
|
||||
import "./widgets/LinkTreeWidget";
|
||||
|
||||
const CANVAS_SIZE = 100_000;
|
||||
const DEFAULT_PAN = { x: CANVAS_SIZE / 2 - 600, y: CANVAS_SIZE / 2 - 400 };
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
||||
interface HomepageCanvasProps {
|
||||
isReadOnly?: boolean;
|
||||
fitOnLoad?: boolean;
|
||||
onOpenFullscreen?: () => void;
|
||||
}
|
||||
|
||||
function nextZOrder(widgets: CanvasWidget[]): number {
|
||||
const nonFolders = widgets.filter((w) => w.typeId !== "folder");
|
||||
return nonFolders.length === 0
|
||||
? 1
|
||||
: Math.max(...nonFolders.map((w) => w.zOrder)) + 1;
|
||||
}
|
||||
|
||||
export function HomepageCanvas({
|
||||
isReadOnly,
|
||||
fitOnLoad,
|
||||
onOpenFullscreen,
|
||||
}: HomepageCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [widgets, setWidgets] = useState<CanvasWidget[]>([]);
|
||||
const [pan, setPan] = useState(DEFAULT_PAN);
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [isLocked, setIsLocked] = useState(
|
||||
() => localStorage.getItem("homepage.locked") === "true",
|
||||
);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [resizingId, setResizingId] = useState<number | null>(null);
|
||||
const [editingWidget, setEditingWidget] = useState<CanvasWidget | null>(null);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
canvasX: 0,
|
||||
canvasY: 0,
|
||||
});
|
||||
const [containerSize, setContainerSize] = useState({ w: 800, h: 600 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const panRef = useRef(pan);
|
||||
const zoomRef = useRef(zoom);
|
||||
const widgetsRef = useRef(widgets);
|
||||
panRef.current = pan;
|
||||
zoomRef.current = zoom;
|
||||
widgetsRef.current = widgets;
|
||||
|
||||
const hasFitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([getHomepageItems(), getHomepageLayout()])
|
||||
.then(([items, layoutRow]) => {
|
||||
if (cancelled) return;
|
||||
const layoutEntries = layoutRow?.layout?.entries ?? [];
|
||||
const merged: CanvasWidget[] = items.map((item) => {
|
||||
const entry = layoutEntries.find((e) => e.itemId === item.id);
|
||||
const typeDef = getWidgetType(item.typeId as WidgetTypeId);
|
||||
return {
|
||||
id: item.id,
|
||||
typeId: item.typeId as WidgetTypeId,
|
||||
title: item.title,
|
||||
config: JSON.parse(item.config || "{}"),
|
||||
x: entry?.x ?? snapToGrid(Math.random() * 600 + 100),
|
||||
y: entry?.y ?? snapToGrid(Math.random() * 400 + 100),
|
||||
w: entry?.w ?? typeDef?.defaultSize.w ?? GRID_SIZE * 8,
|
||||
h: entry?.h ?? typeDef?.defaultSize.h ?? GRID_SIZE * 6,
|
||||
zOrder: entry?.zOrder ?? 0,
|
||||
};
|
||||
});
|
||||
setWidgets(merged);
|
||||
if (layoutRow?.layout?.pan) setPan(layoutRow.layout.pan);
|
||||
if (layoutRow?.layout?.zoom) setZoom(layoutRow.layout.zoom);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleSave = useCallback(
|
||||
(newWidgets?: CanvasWidget[], newPan?: typeof pan, newZoom?: number) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const ws = newWidgets ?? widgetsRef.current;
|
||||
const p = newPan ?? panRef.current;
|
||||
const z = newZoom ?? zoomRef.current;
|
||||
saveHomepageLayout({
|
||||
entries: ws.map((w) => ({
|
||||
itemId: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
zOrder: w.zOrder,
|
||||
})),
|
||||
pan: p,
|
||||
zoom: z,
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitOnLoad || loading || widgets.length === 0 || hasFitRef.current)
|
||||
return;
|
||||
const el = containerRef.current;
|
||||
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||
hasFitRef.current = true;
|
||||
const vw = el.offsetWidth;
|
||||
const vh = el.offsetHeight;
|
||||
const minX = Math.min(...widgets.map((w) => w.x));
|
||||
const minY = Math.min(...widgets.map((w) => w.y));
|
||||
const maxX = Math.max(...widgets.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...widgets.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const PADDING = 48;
|
||||
const fz = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
setPan({
|
||||
x: (vw - contentW * fz) / 2 - minX * fz,
|
||||
y: (vh - contentH * fz) / 2 - minY * fz,
|
||||
});
|
||||
setZoom(fz);
|
||||
}, [fitOnLoad, loading, widgets, containerSize]);
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(newPan: typeof pan) => {
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, undefined);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(newZoom: number, newPan: typeof pan) => {
|
||||
setZoom(newZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, newZoom);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const canvasInput = useCanvasInput({
|
||||
isDraggingWidget: draggingId !== null,
|
||||
isResizing: resizingId !== null,
|
||||
isContextMenuVisible: contextMenu.visible,
|
||||
onPanChange: handlePanChange,
|
||||
onZoomChange: handleZoomChange,
|
||||
getPan: () => panRef.current,
|
||||
getZoom: () => zoomRef.current,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
const handleWidgetMove = useCallback(
|
||||
(id: number, x: number, y: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, x, y } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleWidgetResize = useCallback(
|
||||
(id: number, w: number, h: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((widget) =>
|
||||
widget.id === id ? { ...widget, w, h } : widget,
|
||||
);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(id: number) => {
|
||||
setDraggingId(id);
|
||||
setWidgets((prev) => {
|
||||
const widget = prev.find((w) => w.id === id);
|
||||
if (!widget || widget.typeId === "folder") return prev;
|
||||
const top = nextZOrder(prev);
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, zOrder: top } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const { startDrag } = useBoxDrag({
|
||||
widgets,
|
||||
zoom,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetMove: handleWidgetMove,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: () => setDraggingId(null),
|
||||
});
|
||||
|
||||
const { startResize } = useBoxResize({
|
||||
widgets,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetResize: handleWidgetResize,
|
||||
onResizeStart: setResizingId,
|
||||
onResizeEnd: () => setResizingId(null),
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocked || isReadOnly) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { x, y } = screenToCanvas(sx, sy, panRef.current, zoomRef.current);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: e.clientX,
|
||||
screenY: e.clientY,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
});
|
||||
},
|
||||
[isLocked, isReadOnly],
|
||||
);
|
||||
|
||||
const handleAddWidget = useCallback(
|
||||
async (typeId: WidgetTypeId, canvasX: number, canvasY: number) => {
|
||||
const typeDef = getWidgetType(typeId);
|
||||
if (!typeDef) return;
|
||||
try {
|
||||
const item = await createHomepageItem({
|
||||
typeId,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
});
|
||||
setWidgets((prev) => {
|
||||
const zOrder = typeId === "folder" ? 0 : nextZOrder(prev);
|
||||
const newWidget: CanvasWidget = {
|
||||
id: item.id,
|
||||
typeId,
|
||||
title: item.title,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
w: typeDef.defaultSize.w,
|
||||
h: typeDef.defaultSize.h,
|
||||
zOrder,
|
||||
};
|
||||
const next = [...prev, newWidget];
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleOpenAddMenu = useCallback(
|
||||
(anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const w = containerSize.w;
|
||||
const h = containerSize.h;
|
||||
const { x, y } = screenToCanvas(
|
||||
w / 2,
|
||||
h / 2,
|
||||
panRef.current,
|
||||
zoomRef.current,
|
||||
);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: anchorRect.left,
|
||||
screenY: anchorRect.top,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
anchorRect,
|
||||
});
|
||||
},
|
||||
[containerSize],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteHomepageItem(id);
|
||||
setWidgets((prev) => {
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: number) => {
|
||||
const w = widgets.find((x) => x.id === id);
|
||||
if (w) {
|
||||
setEditingWidget(w);
|
||||
setDialogKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await updateHomepageItem(id, { title, config });
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const ws = widgetsRef.current;
|
||||
const el = containerRef.current;
|
||||
const vw = el?.offsetWidth ?? 800;
|
||||
const vh = el?.offsetHeight ?? 600;
|
||||
|
||||
if (ws.length === 0) {
|
||||
setPan(DEFAULT_PAN);
|
||||
setZoom(DEFAULT_ZOOM);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(...ws.map((w) => w.x));
|
||||
const minY = Math.min(...ws.map((w) => w.y));
|
||||
const maxX = Math.max(...ws.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...ws.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
|
||||
const PADDING = 48;
|
||||
const fitZoom = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
|
||||
// Place the content bbox center at the viewport center
|
||||
const newPan = {
|
||||
x: (vw - contentW * fitZoom) / 2 - minX * fitZoom,
|
||||
y: (vh - contentH * fitZoom) / 2 - minY * fitZoom,
|
||||
};
|
||||
|
||||
setZoom(fitZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, fitZoom);
|
||||
}, [scheduleSave]);
|
||||
|
||||
// Folders always render first (lowest z), non-folders sorted by zOrder ascending
|
||||
const sortedWidgets = [...widgets].sort((a, b) => {
|
||||
const aFolder = a.typeId === "folder";
|
||||
const bFolder = b.typeId === "folder";
|
||||
if (aFolder && !bFolder) return -1;
|
||||
if (!aFolder && bFolder) return 1;
|
||||
return a.zOrder - b.zOrder;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-muted-foreground text-sm">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full overflow-hidden bg-background"
|
||||
onMouseDown={editingWidget ? undefined : canvasInput.handleMouseDown}
|
||||
onMouseMove={canvasInput.handleMouseMove}
|
||||
onMouseUp={canvasInput.handleMouseUp}
|
||||
onMouseLeave={canvasInput.handleMouseUp}
|
||||
onWheel={canvasInput.handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: draggingId ? "grabbing" : "default" }}
|
||||
>
|
||||
<CanvasDotBackground pan={pan} zoom={zoom} />
|
||||
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
width: CANVAS_SIZE,
|
||||
height: CANVAS_SIZE,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{sortedWidgets.map((widget) => (
|
||||
<WidgetShell
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isLocked={isLocked}
|
||||
isReadOnly={isReadOnly}
|
||||
isDragging={draggingId === widget.id}
|
||||
isResizing={resizingId === widget.id}
|
||||
onStartDrag={startDrag}
|
||||
onStartResize={startResize}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onConfigUpdate={(config) =>
|
||||
handleSaveEdit(widget.id, widget.title, config)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{widgets.length === 0 && !isReadOnly && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
||||
<div className="flex items-center gap-2 bg-card border border-border px-3 py-2 shadow-sm">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("homepage.noWidgets")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReadOnly && onOpenFullscreen && (
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 text-xs text-muted-foreground hover:text-foreground bg-card/80 border border-border px-2 py-1"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<HomepageToolbar
|
||||
zoom={zoom}
|
||||
isLocked={isLocked}
|
||||
pan={pan}
|
||||
containerSize={containerSize}
|
||||
onZoomChange={handleZoomChange}
|
||||
onLockToggle={() =>
|
||||
setIsLocked((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("homepage.locked", String(next));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onResetView={resetView}
|
||||
onAddWidget={handleOpenAddMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddWidgetMenu
|
||||
state={contextMenu}
|
||||
onAdd={handleAddWidget}
|
||||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||||
/>
|
||||
|
||||
<WidgetEditDialog
|
||||
key={dialogKey}
|
||||
widget={editingWidget}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function CanvasDotBackground({ pan, zoom }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Read dot color from CSS variable for theme support
|
||||
const dotColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-muted-foreground")
|
||||
.trim() || "#6b7280";
|
||||
|
||||
// Effective grid spacing in screen pixels
|
||||
const spacing = 30 * zoom;
|
||||
|
||||
// Skip rendering dots that are too close together or too far apart
|
||||
if (spacing < 8 || spacing > 200) return;
|
||||
|
||||
// Offset so dots align with the canvas pan position
|
||||
const offsetX = ((pan.x % spacing) + spacing) % spacing;
|
||||
const offsetY = ((pan.y % spacing) + spacing) % spacing;
|
||||
|
||||
const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2));
|
||||
|
||||
ctx.fillStyle = dotColor;
|
||||
ctx.globalAlpha = 0.35;
|
||||
|
||||
for (let x = offsetX; x < w; x += spacing) {
|
||||
for (let y = offsetY; y < h; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}, [pan, zoom]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export function screenToCanvas(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - pan.x) / zoom,
|
||||
y: (screenY - pan.y) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToScreen(
|
||||
canvasX: number,
|
||||
canvasY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasX * zoom + pan.x,
|
||||
y: canvasY * zoom + pan.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */
|
||||
export function zoomAroundPoint(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pan: { x: number; y: number },
|
||||
oldZoom: number,
|
||||
newZoom: number,
|
||||
): { x: number; y: number } {
|
||||
const gridX = (mouseX - pan.x) / oldZoom;
|
||||
const gridY = (mouseY - pan.y) / oldZoom;
|
||||
return {
|
||||
x: mouseX - gridX * newZoom,
|
||||
y: mouseY - gridY * newZoom,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
export function snapToGrid(value: number): number {
|
||||
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
export function snapToGridFloor(value: number): number {
|
||||
return Math.floor(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { CanvasWidget, DragState } from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
|
||||
interface UseBoxDragOptions {
|
||||
widgets: CanvasWidget[];
|
||||
zoom: number;
|
||||
getZoom: () => number;
|
||||
onWidgetMove: (id: number, x: number, y: number) => void;
|
||||
onDragStart: (id: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxDrag({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetMove,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: UseBoxDragOptions) {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
};
|
||||
onDragStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - dragRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - dragRef.current.startMouseY) / zoom;
|
||||
const newX = snapToGrid(dragRef.current.startWidgetX + dx);
|
||||
const newY = snapToGrid(dragRef.current.startWidgetY + dy);
|
||||
|
||||
const moving = widgets.find((w) => w.id === dragRef.current!.widgetId);
|
||||
if (!moving) return;
|
||||
|
||||
if (newX !== moving.x || newY !== moving.y) {
|
||||
onWidgetMove(moving.id, newX, newY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
onDragEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetMove, onDragStart, onDragEnd],
|
||||
);
|
||||
|
||||
return { startDrag };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type CanvasWidget,
|
||||
type ResizeState,
|
||||
} from "@/types/homepage-types";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
|
||||
interface UseBoxResizeOptions {
|
||||
widgets: CanvasWidget[];
|
||||
getZoom: () => number;
|
||||
onWidgetResize: (id: number, w: number, h: number) => void;
|
||||
onResizeStart: (id: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxResize({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetResize,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
}: UseBoxResizeOptions) {
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startW: widget.w,
|
||||
startH: widget.h,
|
||||
};
|
||||
onResizeStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom;
|
||||
const newW = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startW + dx),
|
||||
);
|
||||
const newH = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startH + dy),
|
||||
);
|
||||
|
||||
const moving = widgets.find(
|
||||
(w) => w.id === resizeRef.current!.widgetId,
|
||||
);
|
||||
if (!moving) return;
|
||||
|
||||
const typeDef = getWidgetType(moving.typeId);
|
||||
const minW = typeDef?.minSize.w ?? GRID_SIZE * 4;
|
||||
const minH = typeDef?.minSize.h ?? GRID_SIZE * 4;
|
||||
const clampedW = Math.max(minW, newW);
|
||||
const clampedH = Math.max(minH, newH);
|
||||
|
||||
if (clampedW !== moving.w || clampedH !== moving.h) {
|
||||
onWidgetResize(moving.id, clampedW, clampedH);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
resizeRef.current = null;
|
||||
onResizeEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd],
|
||||
);
|
||||
|
||||
return { startResize };
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "./canvasGeometry";
|
||||
|
||||
interface UseCanvasInputOptions {
|
||||
isDraggingWidget: boolean;
|
||||
isResizing: boolean;
|
||||
isContextMenuVisible: boolean;
|
||||
onPanChange: (pan: { x: number; y: number }) => void;
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
getPan: () => { x: number; y: number };
|
||||
getZoom: () => number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useCanvasInput({
|
||||
isDraggingWidget,
|
||||
isResizing,
|
||||
isContextMenuVisible,
|
||||
onPanChange,
|
||||
onZoomChange,
|
||||
getPan,
|
||||
getZoom,
|
||||
containerRef,
|
||||
}: UseCanvasInputOptions) {
|
||||
const isPanningRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only pan on the background (not on widgets), left button
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
if (isDraggingWidget || isResizing || isContextMenuVisible) return;
|
||||
|
||||
isPanningRef.current = true;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
},
|
||||
[isDraggingWidget, isResizing, isContextMenuVisible],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
const pan = getPan();
|
||||
onPanChange({ x: pan.x + dx, y: pan.y + dy });
|
||||
},
|
||||
[getPan, onPanChange],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoom = getZoom();
|
||||
const pan = getPan();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Multiplicative zoom so each step is proportional regardless of current zoom level.
|
||||
// Normalize deltaY to avoid trackpad sending huge values.
|
||||
const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
|
||||
const factor = 1 - rawDelta * 0.001;
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));
|
||||
if (newZoom === zoom) return;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom);
|
||||
onZoomChange(newZoom, newPan);
|
||||
},
|
||||
[getZoom, getPan, containerRef, onZoomChange],
|
||||
);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel };
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/** IANA zone names use underscores, but people type and paste spaces. */
|
||||
export function normalizeTimezone(timezone: string): string {
|
||||
return timezone.trim().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the timezone a user typed and reports whether it is usable.
|
||||
* A blank field is valid: it means "use local time".
|
||||
*/
|
||||
export function validateClockTimezone(timezone: unknown): {
|
||||
timezone?: string;
|
||||
valid: boolean;
|
||||
} {
|
||||
if (typeof timezone !== "string" || timezone.trim() === "") {
|
||||
return { timezone: undefined, valid: true };
|
||||
}
|
||||
const normalized = normalizeTimezone(timezone);
|
||||
return { timezone: normalized, valid: isValidTimezone(normalized) };
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
|
||||
|
||||
interface AddWidgetMenuProps {
|
||||
state: ContextMenuState;
|
||||
onAdd: (typeId: WidgetTypeId, canvasX: number, canvasY: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ["links", "info", "system", "monitoring"] as const;
|
||||
|
||||
const MARGIN = 8;
|
||||
|
||||
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const allTypes = useWidgetTypes();
|
||||
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
|
||||
const [selectedCat, setSelectedCat] = useState<
|
||||
(typeof CATEGORY_ORDER)[number]
|
||||
>(CATEGORY_ORDER[0]);
|
||||
const categoryLabels: Record<(typeof CATEGORY_ORDER)[number], string> = {
|
||||
links: t("homepage.categoryLinks"),
|
||||
info: t("homepage.categoryInfo"),
|
||||
system: t("homepage.categorySystem"),
|
||||
monitoring: t("homepage.categoryMonitoring"),
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state.visible || !menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = state.screenX;
|
||||
let top = state.screenY;
|
||||
|
||||
if (state.anchorRect) {
|
||||
left = state.anchorRect.right - rect.width;
|
||||
top = state.anchorRect.top - rect.height - MARGIN;
|
||||
}
|
||||
|
||||
if (left + rect.width > vw - MARGIN) left = vw - rect.width - MARGIN;
|
||||
if (left < MARGIN) left = MARGIN;
|
||||
|
||||
if (top < MARGIN) {
|
||||
top = state.anchorRect
|
||||
? state.anchorRect.bottom + MARGIN
|
||||
: state.screenY + MARGIN;
|
||||
}
|
||||
if (top + rect.height > vh - MARGIN) top = vh - rect.height - MARGIN;
|
||||
if (top < MARGIN) top = MARGIN;
|
||||
|
||||
setPos({ left, top });
|
||||
}, [state.visible, state.screenX, state.screenY, state.anchorRect]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const visibleCategories = CATEGORY_ORDER.filter((cat) =>
|
||||
allTypes.some((type) => type.category === cat),
|
||||
);
|
||||
|
||||
const selectedTypes = allTypes.filter(
|
||||
(type) => type.category === selectedCat,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-card border border-border shadow-xl min-w-[220px] flex flex-col"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: state.visible ? "visible" : "hidden",
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("homepage.addWidget")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1 shrink-0">
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCat(cat)}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
|
||||
selectedCat === cat
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Widget list */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: "240px" }}>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground/60">
|
||||
No widgets in this category
|
||||
</div>
|
||||
) : (
|
||||
selectedTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left text-xs text-foreground hover:bg-muted/60 transition-colors"
|
||||
onClick={() => {
|
||||
onAdd(type.id, state.canvasX, state.canvasY);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="text-accent-brand shrink-0">{type.icon}</span>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{type.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
BookmarkLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function BookmarkListEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<BookmarkListConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { links } = config;
|
||||
|
||||
const updateLink = (i: number, patch: Partial<BookmarkLink>) => {
|
||||
const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l));
|
||||
onChange({ ...config, links: next });
|
||||
};
|
||||
|
||||
const removeLink = (i: number) => {
|
||||
onChange({ ...config, links: links.filter((_, idx) => idx !== i) });
|
||||
};
|
||||
|
||||
const addLink = () => {
|
||||
onChange({ ...config, links: [...links, { label: "", url: "" }] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-h-60 overflow-y-auto pr-1">
|
||||
{links.map((link, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) => updateLink(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) => updateLink(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLink}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addLink")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CalendarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="UTC"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.startOnMonday}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, startOnMonday: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.startOnMonday")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { ClockConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
export function ClockEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ClockConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidTimezone = !validateClockTimezone(config.timezone).valid;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.timezone")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.timezone ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, timezone: e.target.value || undefined })
|
||||
}
|
||||
placeholder="America/New_York (leave blank for local)"
|
||||
aria-invalid={invalidTimezone}
|
||||
className={
|
||||
invalidTimezone ? "h-8 text-sm border-destructive" : "h-8 text-sm"
|
||||
}
|
||||
/>
|
||||
{invalidTimezone && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("homepage.invalidTimezone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showSeconds}
|
||||
onCheckedChange={(v) => onChange({ ...config, showSeconds: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.format12h")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.format === "12h"}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...config, format: v ? "12h" : "24h" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function CountdownEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.countdownLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label}
|
||||
onChange={(e) => onChange({ ...config, label: e.target.value })}
|
||||
placeholder={t("homepage.countdownLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.targetDate")}
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={config.targetDate}
|
||||
onChange={(e) => onChange({ ...config, targetDate: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDays}
|
||||
onChange={(e) => onChange({ ...config, showDays: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowDays")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showHours}
|
||||
onChange={(e) => onChange({ ...config, showHours: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.countdownShowHours")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
CustomApiDisplayMode,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const MODES: { id: CustomApiDisplayMode; label: string }[] = [
|
||||
{ id: "value", label: "Value" },
|
||||
{ id: "json", label: "JSON" },
|
||||
{ id: "table", label: "Table" },
|
||||
];
|
||||
|
||||
export function CustomApiEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayMode")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, displayMode: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.displayMode === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.displayMode === "value" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayField")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.displayField ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
displayField: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="data.temperature"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.label ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, label: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.customApiLabelPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-20">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customApiUnit")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.unit ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, unit: e.target.value || undefined })
|
||||
}
|
||||
placeholder="°C"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.displayMode === "table" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.jsonPath")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.jsonPath ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, jsonPath: e.target.value || undefined })
|
||||
}
|
||||
placeholder="data.items"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={86400}
|
||||
value={config.refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function DashboardLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([1, 2, 3] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.maxItems ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("homepage.noLimit")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIcons}
|
||||
onChange={(e) => onChange({ ...config, showIcons: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type { FolderConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<FolderConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.color || accent}
|
||||
onChange={(e) => onChange({ ...config, color: e.target.value })}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.color ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, color: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
|
||||
export function HostGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const next = config.hostIds.includes(id)
|
||||
? config.hostIds.filter((x) => x !== id)
|
||||
: [...config.hostIds, id];
|
||||
onChange({ ...config, hostIds: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.hostGridHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground p-1">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{h.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] font-medium border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showIp}
|
||||
onChange={(e) => onChange({ ...config, showIp: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showIp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type {
|
||||
HostMetricKey,
|
||||
HostStatusConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface SimpleHost {
|
||||
id: string;
|
||||
name: string;
|
||||
metricsEnabled: boolean;
|
||||
}
|
||||
|
||||
const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [
|
||||
{ key: "cpu", labelKey: "homepage.metricCpu" },
|
||||
{ key: "memory", labelKey: "homepage.metricMemory" },
|
||||
{ key: "disk", labelKey: "homepage.metricDisk" },
|
||||
{ key: "uptime", labelKey: "homepage.metricUptime" },
|
||||
{ key: "system", labelKey: "homepage.metricSystem" },
|
||||
{ key: "network", labelKey: "homepage.metricNetwork" },
|
||||
{ key: "processes", labelKey: "homepage.metricProcesses" },
|
||||
];
|
||||
|
||||
export function HostStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SimpleHost[]>([]);
|
||||
|
||||
const shownMetrics: HostMetricKey[] = config.shownMetrics?.length
|
||||
? config.shownMetrics
|
||||
: config.showMetrics !== false
|
||||
? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])]
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) =>
|
||||
setHosts(
|
||||
all
|
||||
.filter((x) => x.statusCheckEnabled !== false)
|
||||
.map((x) => ({
|
||||
id: String(x.id),
|
||||
name: x.name,
|
||||
metricsEnabled: true,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleMetric(key: HostMetricKey) {
|
||||
const next = shownMetrics.includes(key)
|
||||
? shownMetrics.filter((k) => k !== key)
|
||||
: [...shownMetrics, key];
|
||||
onChange({ ...config, shownMetrics: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
className="h-8 border border-border bg-background text-sm px-2"
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.displayedMetrics")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{METRIC_OPTIONS.map(({ key, labelKey }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shownMetrics.includes(key)}
|
||||
onChange={() => toggleMetric(key)}
|
||||
className="w-3.5 h-3.5 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-foreground">{t(labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type { IframeConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function IframeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<IframeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.scrolling")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.scrolling}
|
||||
onCheckedChange={(v) => onChange({ ...config, scrolling: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ImageWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl}
|
||||
onChange={(e) => onChange({ ...config, imageUrl: e.target.value })}
|
||||
placeholder="https://example.com/image.png"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageLinkUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.linkUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, linkUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageFit")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["contain", "cover", "fill"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, fit: f })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.fit === f ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.altText")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.alt ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, alt: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.altTextPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeSection,
|
||||
LinkTreeLink,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function LinkTreeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<LinkTreeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { sections, compact } = config;
|
||||
|
||||
const updateSection = (si: number, patch: Partial<LinkTreeSection>) => {
|
||||
onChange({
|
||||
...config,
|
||||
sections: sections.map((s, i) => (i === si ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeSection = (si: number) =>
|
||||
onChange({ ...config, sections: sections.filter((_, i) => i !== si) });
|
||||
const addSection = () =>
|
||||
onChange({
|
||||
...config,
|
||||
sections: [...sections, { heading: "", links: [] }],
|
||||
});
|
||||
|
||||
const updateLink = (si: number, li: number, patch: Partial<LinkTreeLink>) => {
|
||||
const newSections = sections.map((s, i) => {
|
||||
if (i !== si) return s;
|
||||
return {
|
||||
...s,
|
||||
links: s.links.map((l, j) => (j === li ? { ...l, ...patch } : l)),
|
||||
};
|
||||
});
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const removeLink = (si: number, li: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: s.links.filter((_, j) => j !== li) },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
const addLink = (si: number) => {
|
||||
const newSections = sections.map((s, i) =>
|
||||
i !== si ? s : { ...s, links: [...s.links, { label: "", url: "" }] },
|
||||
);
|
||||
onChange({ ...config, sections: newSections });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-64 overflow-y-auto pr-1">
|
||||
{sections.map((section, si) => (
|
||||
<div
|
||||
key={si}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={section.heading}
|
||||
onChange={(e) => updateSection(si, { heading: e.target.value })}
|
||||
placeholder={t("homepage.sectionHeading")}
|
||||
className="h-6 text-xs flex-1 font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeSection(si)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
{section.links.map((link, li) => (
|
||||
<div key={li} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={link.label}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { label: e.target.value })
|
||||
}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
onChange={(e) =>
|
||||
updateLink(si, li, { url: e.target.value })
|
||||
}
|
||||
placeholder="https://..."
|
||||
className="h-6 text-[10px] flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeLink(si, li)}
|
||||
className="p-0.5 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addLink(si)}
|
||||
className="flex items-center gap-0.5 text-[9px] text-accent-brand hover:opacity-80 mt-0.5 self-start"
|
||||
>
|
||||
<Plus size={9} /> {t("homepage.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addSection}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSection")}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compact}
|
||||
onChange={(e) => onChange({ ...config, compact: e.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.compactMode")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function MarkdownNotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MarkdownNotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
className="text-sm min-h-[120px] resize-none rounded-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.renderMarkdown}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, renderMarkdown: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.renderMarkdown")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type { NotesConfig, WidgetEditFormProps } from "@/types/homepage-types";
|
||||
|
||||
export function NotesEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<NotesConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.content}
|
||||
onChange={(e) => onChange({ ...config, content: e.target.value })}
|
||||
placeholder="Write your notes here..."
|
||||
className="text-sm min-h-[120px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
PingUrl,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function PingStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
|
||||
const updateUrl = (i: number, patch: Partial<PingUrl>) => {
|
||||
onChange({
|
||||
...config,
|
||||
urls: urls.map((u, idx) => (idx === i ? { ...u, ...patch } : u)),
|
||||
});
|
||||
};
|
||||
const removeUrl = (i: number) =>
|
||||
onChange({ ...config, urls: urls.filter((_, idx) => idx !== i) });
|
||||
const addUrl = () =>
|
||||
onChange({ ...config, urls: [...urls, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto pr-1">
|
||||
{urls.map((u, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={u.label}
|
||||
onChange={(e) => updateUrl(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.pingLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={u.url}
|
||||
onChange={(e) => updateUrl(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addUrl}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addPingUrl")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={refreshInterval}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
refreshInterval: Math.max(10, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLatency}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLatency: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLatency")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
export function QuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then(setHosts)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleHost = (id: number) => {
|
||||
const current = config.hostIds;
|
||||
const ids = current.includes(id)
|
||||
? current.filter((x) => x !== id)
|
||||
: [...current, id];
|
||||
onChange({ ...config, hostIds: ids });
|
||||
};
|
||||
|
||||
const toggleType = (type: QuickConnectType) => {
|
||||
const types = config.connectionTypes.includes(type)
|
||||
? config.connectionTypes.filter((x) => x !== type)
|
||||
: [...config.connectionTypes, type];
|
||||
onChange({ ...config, connectionTypes: types });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.selectHosts")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 max-h-36 overflow-y-auto border border-border/30 p-2">
|
||||
{hosts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("homepage.noHosts")}
|
||||
</span>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(h.id)}
|
||||
onChange={() => toggleHost(h.id)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
<span className="truncate">{h.name || h.ip}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.hostGridAllHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.connectionTypes")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{targets.map(({ type, labelKey }) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.connectionTypes.includes(type)}
|
||||
onChange={() => toggleType(type)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t(labelKey, type)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.layout")}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(["list", "grid"] as const).map((l) => (
|
||||
<label
|
||||
key={l}
|
||||
className="flex items-center gap-1.5 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value={l}
|
||||
checked={config.layout === l}
|
||||
onChange={() => onChange({ ...config, layout: l })}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{l === "list"
|
||||
? t("homepage.listLayout")
|
||||
: t("homepage.gridLayout")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showStatus}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showStatus: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showStatus")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { activityFilterTypes } from "../quick-connect-targets";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function RecentActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleType = (type: ActivityType) => {
|
||||
const next = config.filterTypes.includes(type)
|
||||
? config.filterTypes.filter((x) => x !== type)
|
||||
: [...config.filterTypes, type];
|
||||
onChange({ ...config, filterTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.filterActivityTypes")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activityFilterTypes().map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => toggleType(type)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${
|
||||
config.filterTypes.includes(type) ||
|
||||
config.filterTypes.length === 0
|
||||
? "bg-accent-brand border-accent-brand text-white"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{type.replace("_", " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{t("homepage.filterTypesHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showTimestamp}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showTimestamp: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showTimestamp")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
RssFeedConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function RssFeedEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RssFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.feedUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.feedUrl}
|
||||
onChange={(e) => onChange({ ...config, feedUrl: e.target.value })}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.maxItems")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.maxItems}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
maxItems: Math.max(1, Math.min(50, Number(e.target.value))),
|
||||
})
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showDescription")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showDescription}
|
||||
onCheckedChange={(v) => onChange({ ...config, showDescription: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const ENGINES: { id: SearchEngine; label: string }[] = [
|
||||
{ id: "google", label: "Google" },
|
||||
{ id: "duckduckgo", label: "DuckDuckGo" },
|
||||
{ id: "bing", label: "Bing" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function SearchBarEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchBarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchEngine")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ENGINES.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, engine: e.id })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.engine === e.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.engine === "custom" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.customSearchUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.customUrl ?? ""}
|
||||
onChange={(e) => onChange({ ...config, customUrl: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.searchPlaceholderLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, placeholder: e.target.value || undefined })
|
||||
}
|
||||
placeholder={t("homepage.searchPlaceholderHint")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.openInNewTab}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, openInNewTab: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.openInNewTab")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SearchLinksEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SearchLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { shortcuts } = config;
|
||||
|
||||
const update = (i: number, patch: Partial<SearchLinkShortcut>) => {
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: shortcuts.map((s, idx) =>
|
||||
idx === i ? { ...s, ...patch } : s,
|
||||
),
|
||||
});
|
||||
};
|
||||
const remove = (i: number) =>
|
||||
onChange({ ...config, shortcuts: shortcuts.filter((_, idx) => idx !== i) });
|
||||
const add = () =>
|
||||
onChange({
|
||||
...config,
|
||||
shortcuts: [...shortcuts, { label: "", queryTemplate: "" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 max-h-56 overflow-y-auto pr-1">
|
||||
{shortcuts.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border/40 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.icon ?? ""}
|
||||
onChange={(e) =>
|
||||
update(i, { icon: e.target.value || undefined })
|
||||
}
|
||||
placeholder="🔍"
|
||||
className="h-6 text-xs w-10 text-center"
|
||||
/>
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-6 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={s.queryTemplate}
|
||||
onChange={(e) => update(i, { queryTemplate: e.target.value })}
|
||||
placeholder="https://example.com/search?q={q}"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addSearchShortcut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function ServiceGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { services, columns, showLabels, iconSize } = config;
|
||||
|
||||
const updateItem = (i: number, patch: Partial<ServiceGridItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
services: services.map((s, idx) => (idx === i ? { ...s, ...patch } : s)),
|
||||
});
|
||||
};
|
||||
const removeItem = (i: number) =>
|
||||
onChange({ ...config, services: services.filter((_, idx) => idx !== i) });
|
||||
const addItem = () =>
|
||||
onChange({ ...config, services: [...services, { label: "", url: "" }] });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{services.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={s.label}
|
||||
onChange={(e) => updateItem(i, { label: e.target.value })}
|
||||
placeholder={t("homepage.linkLabel")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={s.url}
|
||||
onChange={(e) => updateItem(i, { url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="mt-1 h-7 text-xs gap-1"
|
||||
>
|
||||
<Plus size={11} /> {t("homepage.addService")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.columns")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{([2, 3, 4] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, columns: c })}
|
||||
className={`px-3 py-0.5 text-[10px] border transition-colors ${columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.iconSize")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["sm", "md", "lg"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, iconSize: s })}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors uppercase ${iconSize === s ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showLabels: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showLabels")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
ServiceLinkConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceLinkEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ServiceLinkConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const accent = getAccentColor();
|
||||
const showImage = config.showImage ?? true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.url")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.description")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.description ?? ""}
|
||||
onChange={(e) => onChange({ ...config, description: e.target.value })}
|
||||
placeholder="My service"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showImage")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={showImage}
|
||||
onCheckedChange={(v) => onChange({ ...config, showImage: v })}
|
||||
/>
|
||||
</div>
|
||||
{showImage && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.imageUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.imageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, imageUrl: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.imageUrlHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.color")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.accentColor || accent}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.accentColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, accentColor: e.target.value || undefined })
|
||||
}
|
||||
placeholder={accent}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface SingleHostEditFormProps {
|
||||
hostId: number;
|
||||
onChange: (hostId: number) => void;
|
||||
filter?: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
export function SingleHostEditForm({
|
||||
hostId,
|
||||
onChange,
|
||||
filter,
|
||||
}: SingleHostEditFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) => setHosts(filter ? all.filter(filter) : all))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={hostId || ""}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SshTerminalConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function SshTerminalEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SshTerminalConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.autoConnect}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, autoConnect: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.sshTerminalAutoConnect")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SystemOverviewConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function SystemOverviewEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<SystemOverviewConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showVersion}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showVersion: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewVersion")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDbHealth}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDbHealth: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewDatabase")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showUptime}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showUptime: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.overviewUptime")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
TermixUptimeConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function TermixUptimeEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TermixUptimeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showDetailed}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showDetailed: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showSeconds")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import type {
|
||||
TextBannerConfig,
|
||||
TextBannerFontSize,
|
||||
TextBannerAlign,
|
||||
TextBannerWeight,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const SIZES: { id: TextBannerFontSize; label: string }[] = [
|
||||
{ id: "sm", label: "SM" },
|
||||
{ id: "md", label: "MD" },
|
||||
{ id: "lg", label: "LG" },
|
||||
{ id: "xl", label: "XL" },
|
||||
];
|
||||
|
||||
const ALIGNS: { id: TextBannerAlign; label: string }[] = [
|
||||
{ id: "left", label: "Left" },
|
||||
{ id: "center", label: "Center" },
|
||||
{ id: "right", label: "Right" },
|
||||
];
|
||||
|
||||
const WEIGHTS: { id: TextBannerWeight; label: string }[] = [
|
||||
{ id: "normal", label: "Normal" },
|
||||
{ id: "semibold", label: "Semibold" },
|
||||
{ id: "bold", label: "Bold" },
|
||||
];
|
||||
|
||||
export function TextBannerEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<TextBannerConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ButtonGroup = <T extends string>({
|
||||
opts,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
opts: { id: T; label: string }[];
|
||||
value: T;
|
||||
onSelect: (v: T) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(o.id)}
|
||||
className={`px-2 py-0.5 text-[10px] border transition-colors ${value === o.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.content")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={config.text}
|
||||
onChange={(e) => onChange({ ...config, text: e.target.value })}
|
||||
rows={2}
|
||||
className="text-sm min-h-[60px] resize-none rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontSize")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={SIZES}
|
||||
value={config.fontSize}
|
||||
onSelect={(v) => onChange({ ...config, fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.textAlign")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={ALIGNS}
|
||||
value={config.textAlign}
|
||||
onSelect={(v) => onChange({ ...config, textAlign: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.fontWeight")}
|
||||
</label>
|
||||
<ButtonGroup
|
||||
opts={WEIGHTS}
|
||||
value={config.fontWeight}
|
||||
onSelect={(v) => onChange({ ...config, fontWeight: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.backgroundColor")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.backgroundColor ?? "#1e1e2e"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, backgroundColor: e.target.value })
|
||||
}
|
||||
className="w-8 h-8 border border-border cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={config.backgroundColor ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
backgroundColor: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="#1e1e2e"
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
{config.backgroundColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...config, backgroundColor: undefined })
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
{t("homepage.clearColor")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { Switch } from "@/components/switch";
|
||||
import type {
|
||||
WeatherConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
export function WeatherEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<WeatherConfig>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.location")}
|
||||
</label>
|
||||
<Input
|
||||
value={config.location}
|
||||
onChange={(e) => onChange({ ...config, location: e.target.value })}
|
||||
placeholder="New York, London, Tokyo..."
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.temperatureUnit")}
|
||||
</label>
|
||||
<div className="flex gap-0 border border-border">
|
||||
{(["C", "F"] as const).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, unit: u })}
|
||||
className={`flex-1 h-8 text-sm transition-colors ${
|
||||
config.unit === u
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
°{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
{t("homepage.showForecast")}
|
||||
</label>
|
||||
<Switch
|
||||
checked={config.showForecast}
|
||||
onCheckedChange={(v) => onChange({ ...config, showForecast: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ServiceLinkConfig,
|
||||
FolderConfig,
|
||||
NotesConfig,
|
||||
BookmarkListConfig,
|
||||
ClockConfig,
|
||||
HostStatusConfig,
|
||||
WeatherConfig,
|
||||
IframeConfig,
|
||||
RssFeedConfig,
|
||||
HostGridConfig,
|
||||
PingStatusConfig,
|
||||
RecentActivityConfig,
|
||||
TermixUptimeConfig,
|
||||
SystemOverviewConfig,
|
||||
SshTerminalConfig,
|
||||
QuickConnectConfig,
|
||||
CalendarConfig,
|
||||
CountdownConfig,
|
||||
SearchBarConfig,
|
||||
TextBannerConfig,
|
||||
ImageWidgetConfig,
|
||||
MarkdownNotesConfig,
|
||||
CustomApiConfig,
|
||||
ServiceGridConfig,
|
||||
DashboardLinksConfig,
|
||||
SearchLinksConfig,
|
||||
LinkTreeConfig,
|
||||
} from "@/types/homepage-types";
|
||||
import { ServiceLinkEditForm } from "./ServiceLinkEditForm";
|
||||
import { FolderEditForm } from "./FolderEditForm";
|
||||
import { NotesEditForm } from "./NotesEditForm";
|
||||
import { BookmarkListEditForm } from "./BookmarkListEditForm";
|
||||
import { ClockEditForm } from "./ClockEditForm";
|
||||
import { HostStatusEditForm } from "./HostStatusEditForm";
|
||||
import { WeatherEditForm } from "./WeatherEditForm";
|
||||
import { IframeEditForm } from "./IframeEditForm";
|
||||
import { RssFeedEditForm } from "./RssFeedEditForm";
|
||||
import { HostGridEditForm } from "./HostGridEditForm";
|
||||
import { PingStatusEditForm } from "./PingStatusEditForm";
|
||||
import { RecentActivityEditForm } from "./RecentActivityEditForm";
|
||||
import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
|
||||
import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
|
||||
import { SshTerminalEditForm } from "./SshTerminalEditForm";
|
||||
import { QuickConnectEditForm } from "./QuickConnectEditForm";
|
||||
import { CalendarEditForm } from "./CalendarEditForm";
|
||||
import { CountdownEditForm } from "./CountdownEditForm";
|
||||
import { SearchBarEditForm } from "./SearchBarEditForm";
|
||||
import { TextBannerEditForm } from "./TextBannerEditForm";
|
||||
import { ImageWidgetEditForm } from "./ImageWidgetEditForm";
|
||||
import { MarkdownNotesEditForm } from "./MarkdownNotesEditForm";
|
||||
import { CustomApiEditForm } from "./CustomApiEditForm";
|
||||
import { ServiceGridEditForm } from "./ServiceGridEditForm";
|
||||
import { DashboardLinksEditForm } from "./DashboardLinksEditForm";
|
||||
import { SearchLinksEditForm } from "./SearchLinksEditForm";
|
||||
import { LinkTreeEditForm } from "./LinkTreeEditForm";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
interface WidgetEditDialogProps {
|
||||
widget: CanvasWidget | null;
|
||||
onSave: (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WidgetEditDialog({
|
||||
widget,
|
||||
onSave,
|
||||
onClose,
|
||||
}: WidgetEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState(widget?.title ?? "");
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(
|
||||
widget?.config ?? {},
|
||||
);
|
||||
|
||||
if (!widget) return null;
|
||||
|
||||
// Configs that need checking before they can be stored. The clock keeps the
|
||||
// normalized zone rather than the raw input, so "America/New York" is filed
|
||||
// as "America/New_York"; the form flags an unusable one and Save stays off.
|
||||
const clockTimezone =
|
||||
widget.typeId === "clock" ? validateClockTimezone(config.timezone) : null;
|
||||
const configInvalid = clockTimezone ? !clockTimezone.valid : false;
|
||||
|
||||
const handleSave = () => {
|
||||
if (configInvalid) return;
|
||||
onSave(
|
||||
widget.id,
|
||||
title || null,
|
||||
clockTimezone ? { ...config, timezone: clockTimezone.timezone } : config,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (widget.typeId) {
|
||||
case "service_link":
|
||||
return (
|
||||
<ServiceLinkEditForm
|
||||
config={config as unknown as ServiceLinkConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<FolderEditForm
|
||||
config={config as unknown as FolderConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<NotesEditForm
|
||||
config={config as unknown as NotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "bookmark_list":
|
||||
return (
|
||||
<BookmarkListEditForm
|
||||
config={config as unknown as BookmarkListConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<ClockEditForm
|
||||
config={config as unknown as ClockConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_status":
|
||||
return (
|
||||
<HostStatusEditForm
|
||||
config={config as unknown as HostStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<WeatherEditForm
|
||||
config={config as unknown as WeatherConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "iframe_embed":
|
||||
return (
|
||||
<IframeEditForm
|
||||
config={config as unknown as IframeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "rss_feed":
|
||||
return (
|
||||
<RssFeedEditForm
|
||||
config={config as unknown as RssFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
config={config as unknown as HostGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ping_status":
|
||||
return (
|
||||
<PingStatusEditForm
|
||||
config={config as unknown as PingStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "recent_activity":
|
||||
return (
|
||||
<RecentActivityEditForm
|
||||
config={config as unknown as RecentActivityConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "termix_uptime":
|
||||
return (
|
||||
<TermixUptimeEditForm
|
||||
config={config as unknown as TermixUptimeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "system_overview":
|
||||
return (
|
||||
<SystemOverviewEditForm
|
||||
config={config as unknown as SystemOverviewConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ssh_terminal":
|
||||
return (
|
||||
<SshTerminalEditForm
|
||||
config={config as unknown as SshTerminalConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "quick_connect":
|
||||
return (
|
||||
<QuickConnectEditForm
|
||||
config={config as unknown as QuickConnectConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
<CalendarEditForm
|
||||
config={config as unknown as CalendarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownEditForm
|
||||
config={config as unknown as CountdownConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_bar":
|
||||
return (
|
||||
<SearchBarEditForm
|
||||
config={config as unknown as SearchBarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "text_banner":
|
||||
return (
|
||||
<TextBannerEditForm
|
||||
config={config as unknown as TextBannerConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "image_widget":
|
||||
return (
|
||||
<ImageWidgetEditForm
|
||||
config={config as unknown as ImageWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "markdown_notes":
|
||||
return (
|
||||
<MarkdownNotesEditForm
|
||||
config={config as unknown as MarkdownNotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "custom_api":
|
||||
return (
|
||||
<CustomApiEditForm
|
||||
config={config as unknown as CustomApiConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "service_grid":
|
||||
return (
|
||||
<ServiceGridEditForm
|
||||
config={config as unknown as ServiceGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "dashboard_links":
|
||||
return (
|
||||
<DashboardLinksEditForm
|
||||
config={config as unknown as DashboardLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_links":
|
||||
return (
|
||||
<SearchLinksEditForm
|
||||
config={config as unknown as SearchLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "link_tree":
|
||||
return (
|
||||
<LinkTreeEditForm
|
||||
config={config as unknown as LinkTreeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Plugin widgets bring their own form.
|
||||
const EditForm = getWidgetType(widget.typeId)?.editFormComponent;
|
||||
return EditForm ? (
|
||||
<EditForm config={config} onChange={setConfig} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!widget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("homepage.editWidget")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.title_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("homepage.widgetTitlePlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{renderForm()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("homepage.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={configInvalid}>
|
||||
{t("homepage.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import type { HostActionDef } from "@/sidebar/host-contributions";
|
||||
import { listTabTypes } from "@/shell/tab-registry";
|
||||
|
||||
export interface QuickConnectTarget {
|
||||
/** Tab type, and the value stored in a widget's config. */
|
||||
type: string;
|
||||
icon: ComponentType<{ size?: number | string; className?: string }>;
|
||||
/** i18n key, or a literal label from a plugin action. */
|
||||
labelKey: string;
|
||||
enabled: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
/** What a quick connect widget can open: the tools plugins registered. */
|
||||
export function quickConnectTargets(
|
||||
actions: HostActionDef[],
|
||||
): QuickConnectTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const fromPlugins: QuickConnectTarget[] = [];
|
||||
for (const action of actions) {
|
||||
if (!action.tabType || seen.has(action.tabType)) continue;
|
||||
seen.add(action.tabType);
|
||||
fromPlugins.push({
|
||||
type: action.tabType,
|
||||
icon: action.icon,
|
||||
labelKey: action.titleKey,
|
||||
enabled: (h) => {
|
||||
try {
|
||||
return action.when(sshHostToHost(h) as Host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return fromPlugins;
|
||||
}
|
||||
|
||||
/** Recent-activity types a filter can pick: core ones plus plugin tabs'. */
|
||||
export function activityFilterTypes(): string[] {
|
||||
const types = new Set(["file_manager"]);
|
||||
for (const def of listTabTypes()) {
|
||||
for (const type of def.activityTypes ?? []) types.add(type);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from "@/types/homepage-types";
|
||||
import { zoomAroundPoint } from "../canvas/canvasGeometry";
|
||||
|
||||
interface HomepageToolbarProps {
|
||||
zoom: number;
|
||||
isLocked: boolean;
|
||||
pan: { x: number; y: number };
|
||||
containerSize: { w: number; h: number };
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
onLockToggle: () => void;
|
||||
onResetView: () => void;
|
||||
onAddWidget: (anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageToolbar({
|
||||
zoom,
|
||||
isLocked,
|
||||
pan,
|
||||
containerSize,
|
||||
onZoomChange,
|
||||
onLockToggle,
|
||||
onResetView,
|
||||
onAddWidget,
|
||||
}: HomepageToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const cx = containerSize.w / 2;
|
||||
const cy = containerSize.h / 2;
|
||||
|
||||
const zoomIn = () => {
|
||||
const next = Math.min(MAX_ZOOM, parseFloat((zoom + ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
const zoomOut = () => {
|
||||
const next = Math.max(MIN_ZOOM, parseFloat((zoom - ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-30 flex items-center gap-0 bg-card border border-border shadow-lg">
|
||||
<ToolButton
|
||||
onClick={(e) => onAddWidget(e.currentTarget.getBoundingClientRect())}
|
||||
title={t("homepage.addWidget")}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<PlusCircle size={15} className={isLocked ? "" : "text-accent-brand"} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomOut}
|
||||
title={t("homepage.zoomOut")}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
>
|
||||
<Minus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<span className="text-[11px] font-mono text-foreground tabular-nums w-10 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomIn}
|
||||
title={t("homepage.zoomIn")}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton onClick={onResetView} title={t("homepage.resetView")}>
|
||||
<RotateCcw size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<ToolButton
|
||||
onClick={onLockToggle}
|
||||
title={isLocked ? t("homepage.unlockLayout") : t("homepage.lockLayout")}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Lock size={13} className="text-accent-brand" />
|
||||
) : (
|
||||
<LockOpen size={13} />
|
||||
)}
|
||||
</ToolButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Bookmark, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function BookmarkListWidget({
|
||||
config,
|
||||
widget,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<BookmarkListConfig>) {
|
||||
const { links } = config;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
|
||||
<Bookmark size={11} className="text-accent-brand shrink-0" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
|
||||
{links.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No bookmarks
|
||||
</span>
|
||||
)}
|
||||
{links.map((link, i) =>
|
||||
isReadOnly ? (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-foreground py-1"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{link.label || link.url}</span>
|
||||
<ExternalLink
|
||||
size={8}
|
||||
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<BookmarkListConfig>({
|
||||
id: "bookmark_list",
|
||||
name: "Bookmarks",
|
||||
description: "A list of quick links",
|
||||
category: "links",
|
||||
icon: <Bookmark size={14} />,
|
||||
defaultConfig: { links: [] },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: BookmarkListWidget,
|
||||
});
|
||||
|
||||
export { BookmarkListWidget };
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CalendarConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
||||
return { daysInMonth, offset };
|
||||
}
|
||||
|
||||
function CalendarWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { startOnMonday } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
return runVisibleInterval(() => setNow(new Date()), 60_000);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
viewYear,
|
||||
viewMonth,
|
||||
startOnMonday,
|
||||
);
|
||||
const today = now;
|
||||
const isCurrentMonth =
|
||||
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
const dayNames = startOnMonday
|
||||
? [
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
t("homepage.calSun"),
|
||||
]
|
||||
: [
|
||||
t("homepage.calSun"),
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
];
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(viewYear, viewMonth, 1));
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(offset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 shrink-0">
|
||||
{dayNames.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const isToday = isCurrentMonth && day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={`d-${day}`}
|
||||
className="flex items-center justify-center aspect-square"
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: "#fff" }
|
||||
: { color: "var(--text-primary, currentColor)" }
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CalendarConfig>({
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
description: "A month calendar with today highlighted",
|
||||
category: "info",
|
||||
icon: <CalendarDays size={14} />,
|
||||
defaultConfig: { startOnMonday: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
||||
component: CalendarWidget,
|
||||
});
|
||||
|
||||
export { CalendarWidget };
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type ClockConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { isValidTimezone } from "../clock-timezone";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { showSeconds, format } = config;
|
||||
// Configs saved before the edit dialog validated timezones can still hold an
|
||||
// unusable one (e.g. "America/New York"), which makes toLocaleTimeString throw.
|
||||
const timezone =
|
||||
config.timezone && isValidTimezone(config.timezone)
|
||||
? config.timezone
|
||||
: undefined;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// Minute-level is enough without seconds; pause when the tab is backgrounded.
|
||||
usePageVisibleInterval(
|
||||
() => setNow(new Date()),
|
||||
showSeconds ? 1_000 : 30_000,
|
||||
);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(showSeconds ? { second: "2-digit" } : {}),
|
||||
hour12: format === "12h",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const timeStr = now.toLocaleTimeString(undefined, opts);
|
||||
const dateStr = now.toLocaleDateString(undefined, dateOpts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
|
||||
<span className="text-2xl font-bold tabular-nums text-accent-brand">
|
||||
{timeStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{dateStr}</span>
|
||||
{timezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ClockConfig>({
|
||||
id: "clock",
|
||||
name: "Clock",
|
||||
description: "A live clock with configurable timezone",
|
||||
category: "info",
|
||||
icon: <Clock size={14} />,
|
||||
defaultConfig: { showSeconds: true, format: "24h" },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ClockWidget,
|
||||
});
|
||||
|
||||
export { ClockWidget };
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CountdownConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function decompose(ms: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
function UnitBlock({ value, label }: { value: number; label: string }) {
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums leading-none"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { targetDate, label, showDays, showHours } = config;
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [past, setPast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(targetDate).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining(0);
|
||||
setPast(true);
|
||||
} else {
|
||||
setRemaining(diff);
|
||||
setPast(false);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(update, 1000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
update();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
|
||||
);
|
||||
|
||||
if (!targetDate) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
|
||||
{t("homepage.countdownNoDate")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (past) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1">
|
||||
<Timer size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.countdownPast")}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, seconds } = decompose(remaining);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{showDays && (
|
||||
<UnitBlock value={days} label={t("homepage.countdownDays")} />
|
||||
)}
|
||||
{showHours && (
|
||||
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
|
||||
)}
|
||||
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
|
||||
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CountdownConfig>({
|
||||
id: "countdown",
|
||||
name: "Countdown",
|
||||
description: "Counts down to a target date and time",
|
||||
category: "info",
|
||||
icon: <Timer size={14} />,
|
||||
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CountdownWidget,
|
||||
});
|
||||
|
||||
export { CountdownWidget };
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Braces } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === "object")
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function resolveArray(
|
||||
obj: unknown,
|
||||
path: string | undefined,
|
||||
): unknown[] | null {
|
||||
if (!path) {
|
||||
if (Array.isArray(obj)) return obj as unknown[];
|
||||
return null;
|
||||
}
|
||||
const val = resolvePath(obj, path);
|
||||
return Array.isArray(val) ? (val as unknown[]) : null;
|
||||
}
|
||||
|
||||
async function fetchProxy(url: string, ttl: number): Promise<unknown> {
|
||||
const res = await homepageApi.get("/proxy", { params: { url, ttl } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function CustomApiWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
url,
|
||||
displayField,
|
||||
label,
|
||||
unit,
|
||||
refreshInterval,
|
||||
displayMode,
|
||||
jsonPath,
|
||||
} = config;
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 60);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const result = await fetchProxy(url, interval);
|
||||
setData(result);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, interval * 1000);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNoUrl")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
|
||||
{t("homepage.customApiError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
|
||||
);
|
||||
|
||||
if (displayMode === "value") {
|
||||
const val = displayField ? resolvePath(data, displayField) : data;
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="text-[10px] text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "table") {
|
||||
const arr = resolveArray(data, jsonPath);
|
||||
if (!arr) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNotArray")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<table className="w-full text-[9px]">
|
||||
<tbody>
|
||||
{arr.slice(0, 50).map((row, i) => {
|
||||
if (typeof row !== "object" || row === null) {
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-0.5 pr-2 text-foreground">
|
||||
{String(row)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/30">
|
||||
{Object.entries(row as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => (
|
||||
<td key={k} className="py-0.5 pr-2">
|
||||
<span className="text-muted-foreground">{k}: </span>
|
||||
<span className="text-foreground">
|
||||
{String(v ?? "")}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// json mode
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CustomApiConfig>({
|
||||
id: "custom_api",
|
||||
name: "Custom API",
|
||||
description: "Fetch and display data from any JSON API",
|
||||
category: "info",
|
||||
icon: <Braces size={14} />,
|
||||
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CustomApiWidget,
|
||||
});
|
||||
|
||||
export { CustomApiWidget };
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Link, ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServiceLinks, type ServiceLink } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function LinkTile({
|
||||
link,
|
||||
showIcons,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: ServiceLink;
|
||||
showIcons: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
|
||||
{showIcons &&
|
||||
(faviconUrl && !imgFailed ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{link.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showIcons, columns, maxItems } = config;
|
||||
const [links, setLinks] = useState<ServiceLink[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then((data) => setLinks(maxItems ? data.slice(0, maxItems) : data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
if (links.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDashboardLinks")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const colClass =
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
|
||||
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
|
||||
{links.map((link) => (
|
||||
<LinkTile
|
||||
key={link.id}
|
||||
link={link}
|
||||
showIcons={showIcons}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DashboardLinksConfig>({
|
||||
id: "dashboard_links",
|
||||
name: "Dashboard Links",
|
||||
description: "Shows your saved dashboard service links",
|
||||
category: "links",
|
||||
icon: <Link size={14} />,
|
||||
defaultConfig: { showIcons: true, columns: 1 },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: DashboardLinksWidget,
|
||||
});
|
||||
|
||||
export { DashboardLinksWidget };
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
FolderConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
|
||||
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
|
||||
const accentColor =
|
||||
config.color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col"
|
||||
style={{
|
||||
background: `${accentColor}10`,
|
||||
borderTop: `2px solid ${accentColor}60`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
|
||||
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{widget.title || "Folder"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FolderConfig>({
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
description: "A background card for grouping widgets visually",
|
||||
category: "links",
|
||||
icon: <Folder size={14} />,
|
||||
defaultConfig: { isExpanded: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: FolderWidget as unknown as React.ComponentType<
|
||||
WidgetComponentProps<FolderConfig>
|
||||
>,
|
||||
});
|
||||
|
||||
export { FolderWidget };
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostGridConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "reachable"
|
||||
? "#fbbf24"
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, showIp, columns } = config;
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// getSSHHosts already attaches cached server status — no second /status call.
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const allHosts = await getSSHHosts();
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
setHosts(filtered);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
// Align with global status cadence; pause when the tab is hidden.
|
||||
usePageVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden"
|
||||
>
|
||||
<StatusDot status={host.status ?? "unknown"} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{showIp && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostGridConfig>({
|
||||
id: "host_grid",
|
||||
name: "Host Grid",
|
||||
description: "Live status overview grid for all your SSH hosts",
|
||||
category: "monitoring",
|
||||
icon: <LayoutGrid size={14} />,
|
||||
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: HostGridWidget,
|
||||
});
|
||||
|
||||
export { HostGridWidget };
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostStatusConfig,
|
||||
HostMetricKey,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getServerStatusById } from "@/api/host-status-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
|
||||
|
||||
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
if (config.shownMetrics?.length) return config.shownMetrics;
|
||||
if (config.showMetrics === false) return [];
|
||||
const metrics: HostMetricKey[] = ["cpu", "memory"];
|
||||
if (config.showDisk) metrics.push("disk");
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const shownMetrics = migrateConfig(config);
|
||||
const { hostId } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [reachable, setReachable] = useState(false);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (host) setHostName(host.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await getServerStatusById(hostId);
|
||||
if (!cancelled) {
|
||||
setOnline(s.status === "online");
|
||||
setReachable(s.status === "reachable");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setOnline(false);
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void poll();
|
||||
}, 30_000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void poll();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [hostId]);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onlineColor =
|
||||
online === null
|
||||
? "#6b7280"
|
||||
: online
|
||||
? getAccentColor()
|
||||
: reachable
|
||||
? "#fbbf24"
|
||||
: "#ef4444";
|
||||
const onlineLabel =
|
||||
online === null
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: reachable
|
||||
? t("common.reachable", { defaultValue: "Reachable" })
|
||||
: t("common.offline");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate flex-1">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 shrink-0 rounded-full"
|
||||
style={{ background: onlineColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: onlineColor }}
|
||||
>
|
||||
{onlineLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plugins such as host metrics fill in the live numbers. */}
|
||||
{needsMetrics && (
|
||||
<ComponentSlot
|
||||
slotId="homepage.hostMetrics"
|
||||
props={{ hostId, shownMetrics, online: !!online }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostStatusConfig>({
|
||||
id: "host_status",
|
||||
name: "Host Status",
|
||||
description: "Shows live status and metrics for an SSH host",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: HostStatusWidget,
|
||||
});
|
||||
|
||||
export { HostStatusWidget };
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Globe } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
IframeConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function IframeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<IframeConfig>) {
|
||||
const { url, scrolling } = config;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Globe size={24} />
|
||||
<span className="text-xs">Configure a URL in widget settings</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
|
||||
<div className="relative flex-1">
|
||||
<iframe
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
scrolling={scrolling ? "yes" : "no"}
|
||||
className="w-full h-full border-0"
|
||||
title="Embedded content"
|
||||
/>
|
||||
{/* Block interaction in read-only mode so the canvas can still pan */}
|
||||
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<IframeConfig>({
|
||||
id: "iframe_embed",
|
||||
name: "iFrame Embed",
|
||||
description: "Embed any URL in an iframe",
|
||||
category: "info",
|
||||
icon: <Globe size={14} />,
|
||||
defaultConfig: { url: "", scrolling: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: IframeWidget,
|
||||
});
|
||||
|
||||
export { IframeWidget };
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ImageWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const FIT_MAP = {
|
||||
contain: "object-contain",
|
||||
cover: "object-cover",
|
||||
fill: "object-fill",
|
||||
};
|
||||
|
||||
function ImageWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { imageUrl, fit, alt, linkUrl } = config;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const titleBar = widget.title ? (
|
||||
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{imageUrl && !failed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? ""}
|
||||
className={`w-full h-full ${FIT_MAP[fit]}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-[10px]">{t("homepage.noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !linkUrl) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ImageWidgetConfig>({
|
||||
id: "image_widget",
|
||||
name: "Image",
|
||||
description: "Display any image from a URL",
|
||||
category: "info",
|
||||
icon: <ImageIcon size={14} />,
|
||||
defaultConfig: { imageUrl: "", fit: "contain" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ImageWidget,
|
||||
});
|
||||
|
||||
export { ImageWidget };
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { List, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeLink,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function TreeLink({
|
||||
link,
|
||||
compact,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: LinkTreeLink;
|
||||
compact: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
{!compact && link.description && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{link.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTreeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<LinkTreeConfig>) {
|
||||
const { sections, compact } = config;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
No sections configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<List size={11} />} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sections.map((section, si) => (
|
||||
<div key={si}>
|
||||
{section.heading && (
|
||||
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{section.heading}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.links.map((link, li) => (
|
||||
<TreeLink
|
||||
key={li}
|
||||
link={link}
|
||||
compact={compact}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<LinkTreeConfig>({
|
||||
id: "link_tree",
|
||||
name: "Link Tree",
|
||||
description: "Grouped links organized into labeled sections",
|
||||
category: "links",
|
||||
icon: <List size={14} />,
|
||||
defaultConfig: { sections: [], compact: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: LinkTreeWidget,
|
||||
});
|
||||
|
||||
export { LinkTreeWidget };
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MarkdownNotesConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { updateHomepageItem } from "@/api/homepage-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
return src
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^### /.test(line))
|
||||
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
|
||||
if (/^## /.test(line))
|
||||
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
|
||||
if (/^# /.test(line))
|
||||
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
|
||||
if (/^- /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
|
||||
if (/^\d+\. /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineFormat(s: string): string {
|
||||
return esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownNotesWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
onConfigUpdate,
|
||||
}: WidgetComponentProps<MarkdownNotesConfig>) {
|
||||
const { content, backgroundColor, renderMarkdown: doRender } = config;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(content);
|
||||
|
||||
const commitEdit = useCallback(async () => {
|
||||
setEditing(false);
|
||||
if (draft === content) return;
|
||||
try {
|
||||
await updateHomepageItem(widget.id, {
|
||||
config: { ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [draft, content, widget.id, config, onConfigUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-2 relative">
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
|
||||
style={{ minHeight: "100%" }}
|
||||
/>
|
||||
) : doRender ? (
|
||||
<div
|
||||
className="text-[10px] leading-relaxed cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MarkdownNotesConfig>({
|
||||
id: "markdown_notes",
|
||||
name: "Markdown Notes",
|
||||
description: "Notes with optional Markdown rendering and inline editing",
|
||||
category: "info",
|
||||
icon: <FileText size={14} />,
|
||||
defaultConfig: { content: "", renderMarkdown: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
|
||||
component: MarkdownNotesWidget,
|
||||
});
|
||||
|
||||
export { MarkdownNotesWidget };
|
||||
@@ -1,42 +0,0 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type NotesConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
const { content, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={{ background: backgroundColor || undefined }}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
|
||||
{content || (
|
||||
<span className="text-muted-foreground italic">Empty note...</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<NotesConfig>({
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
description: "A text notes widget",
|
||||
category: "info",
|
||||
icon: <StickyNote size={14} />,
|
||||
defaultConfig: { content: "" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: NotesWidget,
|
||||
});
|
||||
|
||||
export { NotesWidget };
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
interface PingResult {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
async function pingUrl(url: string): Promise<PingResult> {
|
||||
const res = await homepageApi.get("/ping", { params: { url } });
|
||||
return res.data as PingResult;
|
||||
}
|
||||
|
||||
function StatusDot({ ok, pending }: { ok: boolean | null; pending: boolean }) {
|
||||
if (pending)
|
||||
return (
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0 inline-block" />
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0 inline-block"
|
||||
style={{ background: ok ? "var(--accent-brand, #f59145)" : "#ef4444" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PingStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
const [results, setResults] = useState<Record<string, PingResult | null>>({});
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchAll = async () => {
|
||||
if (urls.length === 0) return;
|
||||
const newPending: Record<string, boolean> = {};
|
||||
urls.forEach((u) => {
|
||||
newPending[u.url] = true;
|
||||
});
|
||||
setPending(newPending);
|
||||
|
||||
await Promise.all(
|
||||
urls.map(async (u) => {
|
||||
try {
|
||||
const r = await pingUrl(u.url);
|
||||
setResults((prev) => ({ ...prev, [u.url]: r }));
|
||||
} catch {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[u.url]: { ok: false, statusCode: null, latencyMs: 0 },
|
||||
}));
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [u.url]: false }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 30);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchAll();
|
||||
}, interval * 1000);
|
||||
}, [JSON.stringify(urls), interval]);
|
||||
|
||||
if (urls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noPingUrls")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetPingStatusName")}
|
||||
icon={<Wifi size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{urls.map((u) => {
|
||||
const r = results[u.url];
|
||||
const isPending = pending[u.url] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={u.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<StatusDot ok={r?.ok ?? null} pending={isPending} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{u.label || u.url}
|
||||
</span>
|
||||
{r && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{r.statusCode ?? (r.ok ? "OK" : "ERR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showLatency && r && r.latencyMs > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{r.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<PingStatusConfig>({
|
||||
id: "ping_status",
|
||||
name: "Ping Status",
|
||||
description: "Check if URLs or services are reachable",
|
||||
category: "monitoring",
|
||||
icon: <Wifi size={14} />,
|
||||
defaultConfig: { urls: [], refreshInterval: 30, showLatency: true },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: PingStatusWidget,
|
||||
});
|
||||
|
||||
export { PingStatusWidget };
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
function statusDotClass(host: SSHHostWithStatus): string {
|
||||
if (host.status === "online") return "bg-green-500";
|
||||
if (host.status === "offline") return "bg-red-500";
|
||||
return "bg-muted-foreground/30";
|
||||
}
|
||||
|
||||
function openTab(host: SSHHostWithStatus, type: QuickConnectType) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", {
|
||||
detail: { hostId: String(host.id), type: type as TabType },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function QuickConnectWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
// An empty list means the first tools plugins offer, in their order.
|
||||
const types =
|
||||
config.connectionTypes.length > 0
|
||||
? config.connectionTypes
|
||||
: (targets
|
||||
.slice(0, 2)
|
||||
.map((target) => target.type) as QuickConnectType[]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((all) => {
|
||||
const filtered =
|
||||
config.hostIds.length > 0
|
||||
? all.filter((h) => config.hostIds.includes(h.id))
|
||||
: all;
|
||||
setHosts(filtered);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostIds]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Zap size={18} />
|
||||
<span className="text-xs">{t("homepage.noHosts")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
|
||||
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
|
||||
{hosts.map((host) => {
|
||||
const availableTypes = types.flatMap((type) => {
|
||||
const target = targets.find((item) => item.type === type);
|
||||
return target && target.enabled(host) ? [target] : [];
|
||||
});
|
||||
if (availableTypes.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center gap-2 py-1.5 border-b border-border/20 last:border-0 ${config.layout === "grid" ? "flex-col items-start" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
{config.showStatus && (
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${statusDotClass(host)}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 flex-wrap">
|
||||
{availableTypes.map((target) => (
|
||||
<button
|
||||
key={target.type}
|
||||
title={t(target.labelKey, target.type)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTab(host, target.type);
|
||||
}}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
|
||||
>
|
||||
<target.icon size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<QuickConnectConfig>({
|
||||
id: "quick_connect",
|
||||
name: "Quick Connect",
|
||||
description:
|
||||
"Launch terminal, files, docker, tunnels, and more for your hosts",
|
||||
category: "system",
|
||||
icon: <Zap size={14} />,
|
||||
defaultConfig: {
|
||||
hostIds: [],
|
||||
connectionTypes: [],
|
||||
showStatus: true,
|
||||
layout: "list",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: QuickConnectWidget,
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getRecentActivity,
|
||||
type RecentActivityItem,
|
||||
} from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function ActivityIcon({ type }: { type: string }) {
|
||||
const Icon = activityTarget(type)?.icon ?? Activity;
|
||||
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
|
||||
}
|
||||
|
||||
function RecentActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, filterTypes, showTimestamp } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 3);
|
||||
const filtered =
|
||||
filterTypes.length > 0
|
||||
? data.filter((i) => filterTypes.includes(i.type as ActivityType))
|
||||
: data;
|
||||
setItems(filtered.slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 60_000);
|
||||
}, [maxItems, filterTypes.join(",")]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetRecentActivityName")}
|
||||
icon={<Activity size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<ActivityIcon type={item.type} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground capitalize">
|
||||
{item.type.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{showTimestamp && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<RecentActivityConfig>({
|
||||
id: "recent_activity",
|
||||
name: "Recent Activity",
|
||||
description: "Shows recent terminal, Docker, file, and tunnel activity",
|
||||
category: "system",
|
||||
icon: <Activity size={14} />,
|
||||
defaultConfig: { maxItems: 10, filterTypes: [], showTimestamp: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: RecentActivityWidget,
|
||||
});
|
||||
|
||||
export { RecentActivityWidget };
|
||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user