dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes
This commit is contained in:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -2011,6 +2011,16 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/adm-zip": {
|
||||||
|
"version": "0.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
||||||
|
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2247,6 +2257,15 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||||
|
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -7444,6 +7463,7 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"@simplewebauthn/server": "^11.0.0",
|
"@simplewebauthn/server": "^11.0.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"altcha-lib": "^0.5.0",
|
"altcha-lib": "^0.5.0",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
@@ -7455,6 +7475,7 @@
|
|||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.0",
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"@simplewebauthn/server": "^11.0.0",
|
"@simplewebauthn/server": "^11.0.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"altcha-lib": "^0.5.0",
|
"altcha-lib": "^0.5.0",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.0",
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -459,3 +459,34 @@ model EditHistory {
|
|||||||
@@index([postId, createdAt])
|
@@index([postId, createdAt])
|
||||||
@@index([commentId, createdAt])
|
@@index([commentId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Plugin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
version String
|
||||||
|
description String?
|
||||||
|
author String?
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
dirPath String
|
||||||
|
entryPoint String @default("index.js")
|
||||||
|
config Json @default("{}")
|
||||||
|
configSchema Json @default("[]")
|
||||||
|
installedAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
data PluginData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PluginData {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pluginId String
|
||||||
|
key String
|
||||||
|
value Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([pluginId, key])
|
||||||
|
@@index([pluginId])
|
||||||
|
}
|
||||||
|
|||||||
36
packages/api/src/plugins/context.ts
Normal file
36
packages/api/src/plugins/context.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "../lib/prisma.js";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { PluginContext, PluginStore } from "./types.js";
|
||||||
|
|
||||||
|
function createStore(pluginId: string): PluginStore {
|
||||||
|
return {
|
||||||
|
async get(key) {
|
||||||
|
const row = await prisma.pluginData.findUnique({ where: { pluginId_key: { pluginId, key } } });
|
||||||
|
return row ? row.value : null;
|
||||||
|
},
|
||||||
|
async set(key, value) {
|
||||||
|
await prisma.pluginData.upsert({
|
||||||
|
where: { pluginId_key: { pluginId, key } },
|
||||||
|
create: { pluginId, key, value: value as any },
|
||||||
|
update: { value: value as any },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async delete(key) {
|
||||||
|
await prisma.pluginData.deleteMany({ where: { pluginId, key } });
|
||||||
|
},
|
||||||
|
async list() {
|
||||||
|
const rows = await prisma.pluginData.findMany({ where: { pluginId }, select: { key: true, value: true } });
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginContext(pluginId: string, config: Record<string, unknown>, logger: FastifyBaseLogger): PluginContext {
|
||||||
|
return {
|
||||||
|
prisma: prisma as any,
|
||||||
|
store: createStore(pluginId),
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
pluginId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { PluginManifest, EchoboardPlugin } from "./types.js";
|
import { PluginManifest, EchoboardPlugin } from "./types.js";
|
||||||
|
import { getDynamicPluginInfo, getDynamicPluginAdminRoutes } from "./registry.js";
|
||||||
|
|
||||||
const loadedPlugins: EchoboardPlugin[] = [];
|
const loadedPlugins: EchoboardPlugin[] = [];
|
||||||
|
|
||||||
@@ -93,7 +94,8 @@ export function getPluginCronJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginAdminRoutes() {
|
export function getPluginAdminRoutes() {
|
||||||
return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
|
const staticRoutes = loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
|
||||||
|
return staticRoutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginComponents() {
|
export function getPluginComponents() {
|
||||||
|
|||||||
93
packages/api/src/plugins/registry.ts
Normal file
93
packages/api/src/plugins/registry.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import prisma from "../lib/prisma.js";
|
||||||
|
import { createPluginContext } from "./context.js";
|
||||||
|
import type { LoadedDynamicPlugin, PluginDescriptor, PluginContext } from "./types.js";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
const registry = new Map<string, LoadedDynamicPlugin>();
|
||||||
|
let appLogger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
export function setLogger(logger: FastifyBaseLogger) {
|
||||||
|
appLogger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDynamicPlugin(plugin: { id: string; name: string; dirPath: string; entryPoint: string; config: any; enabled: boolean }): Promise<boolean> {
|
||||||
|
if (!plugin.enabled) return false;
|
||||||
|
try {
|
||||||
|
const entryPath = `${plugin.dirPath}/${plugin.entryPoint}`;
|
||||||
|
const url = pathToFileURL(entryPath).href + `?v=${Date.now()}`;
|
||||||
|
const mod = await import(url);
|
||||||
|
const descriptor: PluginDescriptor = mod.default ?? mod;
|
||||||
|
const ctx = createPluginContext(plugin.id, plugin.config ?? {}, appLogger);
|
||||||
|
if (descriptor.onEnable) await descriptor.onEnable(ctx);
|
||||||
|
registry.set(plugin.id, { id: plugin.id, name: plugin.name, descriptor, context: ctx });
|
||||||
|
appLogger?.info({ pluginId: plugin.id, name: plugin.name }, "dynamic plugin loaded");
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
appLogger?.error({ pluginId: plugin.id, error: err.message }, "failed to load dynamic plugin");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unloadDynamicPlugin(pluginId: string): Promise<void> {
|
||||||
|
const loaded = registry.get(pluginId);
|
||||||
|
if (!loaded) return;
|
||||||
|
try {
|
||||||
|
if (loaded.descriptor.onDisable) await loaded.descriptor.onDisable(loaded.context);
|
||||||
|
} catch (err: any) {
|
||||||
|
appLogger?.warn({ pluginId, error: err.message }, "error during plugin disable");
|
||||||
|
}
|
||||||
|
registry.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDynamicPlugins(): Promise<void> {
|
||||||
|
const plugins = await prisma.plugin.findMany({ where: { enabled: true } });
|
||||||
|
for (const p of plugins) {
|
||||||
|
await loadDynamicPlugin(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownDynamicPlugins(): Promise<void> {
|
||||||
|
for (const [id] of registry) {
|
||||||
|
await unloadDynamicPlugin(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPluginRoute(pluginId: string, method: string, path: string) {
|
||||||
|
const loaded = registry.get(pluginId);
|
||||||
|
if (!loaded) return null;
|
||||||
|
return loaded.descriptor.routes?.find(
|
||||||
|
(r) => r.method.toUpperCase() === method.toUpperCase() && r.path === path
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginEventHandlers(event: string): Array<{ handler: (data: any, ctx: PluginContext) => void | Promise<void>; ctx: PluginContext }> {
|
||||||
|
const handlers: Array<{ handler: any; ctx: PluginContext }> = [];
|
||||||
|
for (const [, loaded] of registry) {
|
||||||
|
const handler = loaded.descriptor.events?.[event];
|
||||||
|
if (handler) handlers.push({ handler, ctx: loaded.context });
|
||||||
|
}
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDynamicPluginInfo() {
|
||||||
|
return [...registry.values()].map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
id: p.id,
|
||||||
|
adminRoutes: p.descriptor.adminPages ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDynamicPluginAdminRoutes() {
|
||||||
|
const routes: Array<{ path: string; label: string }> = [];
|
||||||
|
for (const [, loaded] of registry) {
|
||||||
|
for (const page of loaded.descriptor.adminPages ?? []) {
|
||||||
|
routes.push({ path: `/plugins/${loaded.id}${page.path}`, label: page.label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoadedPlugin(pluginId: string) {
|
||||||
|
return registry.get(pluginId) ?? null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
export interface AdminRoute {
|
export interface AdminRoute {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -64,3 +66,52 @@ export interface PluginConfig {
|
|||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
plugins: PluginConfig[];
|
plugins: PluginConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginRouteDefinition {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
handler: (req: any, reply: any, ctx: PluginContext) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAdminPage {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDescriptor {
|
||||||
|
routes?: PluginRouteDefinition[];
|
||||||
|
events?: Record<string, (data: any, ctx: PluginContext) => void | Promise<void>>;
|
||||||
|
adminPages?: PluginAdminPage[];
|
||||||
|
onEnable?: (ctx: PluginContext) => Promise<void>;
|
||||||
|
onDisable?: (ctx: PluginContext) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginStore {
|
||||||
|
get(key: string): Promise<unknown | null>;
|
||||||
|
set(key: string, value: unknown): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
list(): Promise<Array<{ key: string; value: unknown }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
store: PluginStore;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
logger: FastifyBaseLogger;
|
||||||
|
pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicPluginManifest {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
entryPoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedDynamicPlugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
descriptor: PluginDescriptor;
|
||||||
|
context: PluginContext;
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,24 +115,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
"/admin/boards/:id",
|
"/admin/boards/:id",
|
||||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const board = await prisma.board.findUnique({
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||||
where: { id: req.params.id },
|
|
||||||
include: { _count: { select: { posts: true } } },
|
|
||||||
});
|
|
||||||
if (!board) {
|
if (!board) {
|
||||||
reply.status(404).send({ error: "Board not found" });
|
reply.status(404).send({ error: "Board not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (board._count.posts > 0) {
|
|
||||||
reply.status(409).send({
|
|
||||||
error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.board.delete({ where: { id: board.id } });
|
await prisma.board.delete({ where: { id: board.id } });
|
||||||
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
|
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
|
||||||
reply.status(204).send();
|
reply.status(204).send();
|
||||||
|
|||||||
267
packages/api/src/routes/admin/plugins.ts
Normal file
267
packages/api/src/routes/admin/plugins.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { resolve, sep, extname } from "node:path";
|
||||||
|
import { existsSync, mkdirSync, createReadStream } from "node:fs";
|
||||||
|
import { rm, realpath } from "node:fs/promises";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
import { loadDynamicPlugin, unloadDynamicPlugin, getLoadedPlugin } from "../../plugins/registry.js";
|
||||||
|
|
||||||
|
const PLUGINS_DIR = resolve(process.cwd(), "plugins-installed");
|
||||||
|
const MAX_ZIP_SIZE = 50 * 1024 * 1024;
|
||||||
|
const SAFE_ASSET_EXTS = new Set([".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot", ".json"]);
|
||||||
|
|
||||||
|
const configFieldSchema = z.object({
|
||||||
|
key: z.string().min(1).max(50),
|
||||||
|
type: z.enum(["text", "password", "number", "boolean", "select"]),
|
||||||
|
label: z.string().min(1).max(100),
|
||||||
|
placeholder: z.string().max(200).optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, "lowercase alphanumeric with dashes"),
|
||||||
|
version: z.string().min(1).max(30),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
author: z.string().max(100).optional(),
|
||||||
|
entryPoint: z.string().max(100).optional(),
|
||||||
|
configSchema: z.array(configFieldSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existsSync(PLUGINS_DIR)) mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
export default async function adminPluginRoutes(app: FastifyInstance) {
|
||||||
|
// list plugins
|
||||||
|
app.get(
|
||||||
|
"/admin/plugins",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const plugins = await prisma.plugin.findMany({ orderBy: { installedAt: "desc" } });
|
||||||
|
reply.send({ plugins });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// upload plugin zip
|
||||||
|
app.post(
|
||||||
|
"/admin/plugins/upload",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const data = await req.file();
|
||||||
|
if (!data) {
|
||||||
|
reply.status(400).send({ error: "No file uploaded" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of data.file) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_ZIP_SIZE) {
|
||||||
|
reply.status(400).send({ error: "File too large (max 50MB)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// validate zip magic bytes
|
||||||
|
if (buffer[0] !== 0x50 || buffer[1] !== 0x4B || buffer[2] !== 0x03 || buffer[3] !== 0x04) {
|
||||||
|
reply.status(400).send({ error: "Not a valid zip file" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zip: AdmZip;
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(buffer);
|
||||||
|
} catch {
|
||||||
|
reply.status(400).send({ error: "Failed to read zip file" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find manifest
|
||||||
|
const manifestEntry = zip.getEntry("manifest.json");
|
||||||
|
if (!manifestEntry) {
|
||||||
|
reply.status(400).send({ error: "Missing manifest.json in zip root" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = manifestSchema.parse(JSON.parse(manifestEntry.getData().toString("utf8")));
|
||||||
|
} catch {
|
||||||
|
reply.status(400).send({ error: "Invalid manifest.json" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPoint = manifest.entryPoint || "index.js";
|
||||||
|
const entryEntry = zip.getEntry(entryPoint);
|
||||||
|
if (!entryEntry) {
|
||||||
|
reply.status(400).send({ error: `Entry point ${entryPoint} not found in zip` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for duplicate name
|
||||||
|
const existing = await prisma.plugin.findUnique({ where: { name: manifest.name } });
|
||||||
|
if (existing) {
|
||||||
|
reply.status(409).send({ error: `Plugin "${manifest.name}" is already installed` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract
|
||||||
|
const pluginId = randomBytes(8).toString("hex");
|
||||||
|
const pluginDir = resolve(PLUGINS_DIR, pluginId);
|
||||||
|
mkdirSync(pluginDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
zip.extractAllTo(pluginDir, true);
|
||||||
|
} catch {
|
||||||
|
await rm(pluginDir, { recursive: true, force: true });
|
||||||
|
reply.status(500).send({ error: "Failed to extract plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await prisma.plugin.create({
|
||||||
|
data: {
|
||||||
|
name: manifest.name,
|
||||||
|
version: manifest.version,
|
||||||
|
description: manifest.description ?? null,
|
||||||
|
author: manifest.author ?? null,
|
||||||
|
dirPath: pluginDir,
|
||||||
|
entryPoint,
|
||||||
|
enabled: false,
|
||||||
|
configSchema: (manifest.configSchema ?? []) as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ pluginId: plugin.id, name: plugin.name }, "plugin installed");
|
||||||
|
reply.status(201).send(plugin);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// enable
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/plugins/:id/enable",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||||
|
if (plugin.enabled) { reply.send(plugin); return; }
|
||||||
|
|
||||||
|
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: true } });
|
||||||
|
const ok = await loadDynamicPlugin({ ...plugin, enabled: true });
|
||||||
|
if (!ok) {
|
||||||
|
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: false } });
|
||||||
|
reply.status(500).send({ error: "Failed to load plugin" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({ ...plugin, enabled: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// disable
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/plugins/:id/disable",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||||
|
|
||||||
|
await unloadDynamicPlugin(plugin.id);
|
||||||
|
await prisma.plugin.update({ where: { id: plugin.id }, data: { enabled: false } });
|
||||||
|
reply.send({ ...plugin, enabled: false });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// delete
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/plugins/:id",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||||
|
|
||||||
|
await unloadDynamicPlugin(plugin.id);
|
||||||
|
await prisma.plugin.delete({ where: { id: plugin.id } });
|
||||||
|
|
||||||
|
if (plugin.dirPath && existsSync(plugin.dirPath)) {
|
||||||
|
await rm(plugin.dirPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.log.info({ pluginId: plugin.id, name: plugin.name }, "plugin deleted");
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// update config
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/plugins/:id/config",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!plugin) { reply.status(404).send({ error: "Plugin not found" }); return; }
|
||||||
|
|
||||||
|
const config = req.body as Record<string, unknown>;
|
||||||
|
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||||
|
reply.status(400).send({ error: "Config must be a JSON object" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.plugin.update({ where: { id: plugin.id }, data: { config: config as any } });
|
||||||
|
|
||||||
|
// reload if enabled
|
||||||
|
if (plugin.enabled) {
|
||||||
|
await unloadDynamicPlugin(plugin.id);
|
||||||
|
await loadDynamicPlugin({ ...plugin, config, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// serve plugin assets
|
||||||
|
app.get<{ Params: { id: string; '*': string } }>(
|
||||||
|
"/admin/plugins/:id/assets/*",
|
||||||
|
{ config: { rateLimit: { max: 100, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const plugin = await prisma.plugin.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!plugin) { reply.status(404).send({ error: "Not found" }); return; }
|
||||||
|
|
||||||
|
const assetPath = req.params['*'];
|
||||||
|
if (!assetPath) { reply.status(400).send({ error: "No asset path" }); return; }
|
||||||
|
|
||||||
|
const ext = extname(assetPath).toLowerCase();
|
||||||
|
if (!SAFE_ASSET_EXTS.has(ext)) { reply.status(403).send({ error: "File type not allowed" }); return; }
|
||||||
|
|
||||||
|
const fullPath = resolve(plugin.dirPath, "assets", assetPath);
|
||||||
|
try {
|
||||||
|
const realFile = await realpath(fullPath);
|
||||||
|
const realPluginDir = await realpath(plugin.dirPath);
|
||||||
|
if (!realFile.startsWith(realPluginDir + sep)) {
|
||||||
|
reply.status(404).send({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reply.status(404).send({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
".css": "text/css", ".js": "application/javascript",
|
||||||
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
||||||
|
".woff": "font/woff", ".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf", ".eot": "application/vnd.ms-fontobject",
|
||||||
|
".json": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
reply
|
||||||
|
.header("Content-Type", mimeMap[ext] || "application/octet-stream")
|
||||||
|
.header("Cache-Control", "public, max-age=86400")
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.send(createReadStream(fullPath));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { notifyPostSubscribers } from "../../services/push.js";
|
import { notifyPostSubscribers } from "../../services/push.js";
|
||||||
import { fireWebhook } from "../../services/webhooks.js";
|
import { firePluginEvent } from "../../services/webhooks.js";
|
||||||
import { decrypt } from "../../services/encryption.js";
|
import { decrypt } from "../../services/encryption.js";
|
||||||
import { masterKey } from "../../config.js";
|
import { masterKey } from "../../config.js";
|
||||||
import { prisma } from "../../lib/prisma.js";
|
import { prisma } from "../../lib/prisma.js";
|
||||||
@@ -215,7 +215,7 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
|||||||
tag: `status-${post.id}`,
|
tag: `status-${post.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
fireWebhook("status_changed", {
|
firePluginEvent("status_changed", {
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
boardId: post.boardId,
|
boardId: post.boardId,
|
||||||
@@ -360,7 +360,7 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fireWebhook("post_created", {
|
firePluginEvent("post_created", {
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
type: post.type,
|
type: post.type,
|
||||||
|
|||||||
32
packages/api/src/routes/plugins-api.ts
Normal file
32
packages/api/src/routes/plugins-api.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { findPluginRoute, getLoadedPlugin } from "../plugins/registry.js";
|
||||||
|
|
||||||
|
export default async function pluginApiRoutes(app: FastifyInstance) {
|
||||||
|
app.all<{ Params: { pluginId: string; '*': string } }>(
|
||||||
|
"/plugins/:pluginId/*",
|
||||||
|
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
const subPath = "/" + (req.params['*'] || "");
|
||||||
|
|
||||||
|
const loaded = getLoadedPlugin(pluginId);
|
||||||
|
if (!loaded) {
|
||||||
|
reply.status(404).send({ error: "Plugin not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = findPluginRoute(pluginId, req.method, subPath);
|
||||||
|
if (!route) {
|
||||||
|
reply.status(404).send({ error: "Route not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await route.handler(req, reply, loaded.context);
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error({ pluginId, error: err.message }, "plugin route error");
|
||||||
|
reply.status(500).send({ error: "Plugin error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { unlink } from "node:fs/promises";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import prisma from "../lib/prisma.js";
|
import prisma from "../lib/prisma.js";
|
||||||
import { verifyChallenge } from "../services/altcha.js";
|
import { verifyChallenge } from "../services/altcha.js";
|
||||||
import { fireWebhook } from "../services/webhooks.js";
|
import { firePluginEvent } from "../services/webhooks.js";
|
||||||
import { decrypt } from "../services/encryption.js";
|
import { decrypt } from "../services/encryption.js";
|
||||||
import { masterKey } from "../config.js";
|
import { masterKey } from "../config.js";
|
||||||
import { shouldCount } from "../lib/view-tracker.js";
|
import { shouldCount } from "../lib/view-tracker.js";
|
||||||
@@ -542,7 +542,7 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fireWebhook("post_created", {
|
firePluginEvent("post_created", {
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
type: post.type,
|
type: post.type,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import prisma from "./lib/prisma.js";
|
|||||||
import securityPlugin from "./middleware/security.js";
|
import securityPlugin from "./middleware/security.js";
|
||||||
import authPlugin from "./middleware/auth.js";
|
import authPlugin from "./middleware/auth.js";
|
||||||
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
|
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
|
||||||
|
import { setLogger, initDynamicPlugins, shutdownDynamicPlugins, getDynamicPluginInfo, getDynamicPluginAdminRoutes } from "./plugins/registry.js";
|
||||||
import { seedAllBoardTemplates } from "./lib/default-templates.js";
|
import { seedAllBoardTemplates } from "./lib/default-templates.js";
|
||||||
|
|
||||||
import boardRoutes from "./routes/boards.js";
|
import boardRoutes from "./routes/boards.js";
|
||||||
@@ -50,6 +51,8 @@ import avatarRoutes from "./routes/avatars.js";
|
|||||||
import recoveryRoutes from "./routes/recovery.js";
|
import recoveryRoutes from "./routes/recovery.js";
|
||||||
import settingsRoutes from "./routes/admin/settings.js";
|
import settingsRoutes from "./routes/admin/settings.js";
|
||||||
import adminTeamRoutes from "./routes/admin/team.js";
|
import adminTeamRoutes from "./routes/admin/team.js";
|
||||||
|
import adminPluginRoutes from "./routes/admin/plugins.js";
|
||||||
|
import pluginApiRoutes from "./routes/plugins-api.js";
|
||||||
|
|
||||||
export async function createServer() {
|
export async function createServer() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -160,6 +163,8 @@ export async function createServer() {
|
|||||||
await api.register(recoveryRoutes);
|
await api.register(recoveryRoutes);
|
||||||
await api.register(settingsRoutes);
|
await api.register(settingsRoutes);
|
||||||
await api.register(adminTeamRoutes);
|
await api.register(adminTeamRoutes);
|
||||||
|
await api.register(adminPluginRoutes);
|
||||||
|
await api.register(pluginApiRoutes);
|
||||||
}, { prefix: "/api/v1" });
|
}, { prefix: "/api/v1" });
|
||||||
|
|
||||||
// serve static frontend build in production
|
// serve static frontend build in production
|
||||||
@@ -177,6 +182,8 @@ export async function createServer() {
|
|||||||
|
|
||||||
await loadPlugins(app);
|
await loadPlugins(app);
|
||||||
await startupPlugins();
|
await startupPlugins();
|
||||||
|
setLogger(app.log);
|
||||||
|
await initDynamicPlugins();
|
||||||
|
|
||||||
// seed default templates for boards that have none
|
// seed default templates for boards that have none
|
||||||
await seedAllBoardTemplates(prisma);
|
await seedAllBoardTemplates(prisma);
|
||||||
@@ -185,7 +192,7 @@ export async function createServer() {
|
|||||||
await app.register(async (api) => {
|
await app.register(async (api) => {
|
||||||
api.get("/plugins/active", {
|
api.get("/plugins/active", {
|
||||||
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
|
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
|
||||||
}, async () => getActivePluginInfo());
|
}, async () => [...getActivePluginInfo(), ...getDynamicPluginInfo()]);
|
||||||
|
|
||||||
// register plugin-provided admin routes
|
// register plugin-provided admin routes
|
||||||
for (const route of getPluginAdminRoutes()) {
|
for (const route of getPluginAdminRoutes()) {
|
||||||
@@ -194,10 +201,18 @@ export async function createServer() {
|
|||||||
component: route.component,
|
component: route.component,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// register dynamic plugin admin routes
|
||||||
|
for (const route of getDynamicPluginAdminRoutes()) {
|
||||||
|
api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({
|
||||||
|
label: route.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}, { prefix: "/api/v1" });
|
}, { prefix: "/api/v1" });
|
||||||
|
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
await shutdownPlugins();
|
await shutdownPlugins();
|
||||||
|
await shutdownDynamicPlugins();
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -102,3 +102,17 @@ export async function fireWebhook(event: string, payload: Record<string, unknown
|
|||||||
req.end();
|
req.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { getPluginEventHandlers } from "../plugins/registry.js";
|
||||||
|
|
||||||
|
export async function firePluginEvent(event: string, payload: Record<string, unknown>) {
|
||||||
|
fireWebhook(event, payload);
|
||||||
|
const handlers = getPluginEventHandlers(event);
|
||||||
|
for (const { handler, ctx } of handlers) {
|
||||||
|
try {
|
||||||
|
await handler(payload, ctx);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(`plugin event handler failed for ${event}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ThemeProvider, useThemeState } from './hooks/useTheme'
|
|||||||
import { TranslationProvider, useTranslationState } from './i18n'
|
import { TranslationProvider, useTranslationState } from './i18n'
|
||||||
import { BrandingProvider } from './hooks/useBranding'
|
import { BrandingProvider } from './hooks/useBranding'
|
||||||
import { ConfirmProvider } from './hooks/useConfirm'
|
import { ConfirmProvider } from './hooks/useConfirm'
|
||||||
|
import { ToastProvider } from './hooks/useToast'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import AdminSidebar from './components/AdminSidebar'
|
import AdminSidebar from './components/AdminSidebar'
|
||||||
import MobileNav from './components/MobileNav'
|
import MobileNav from './components/MobileNav'
|
||||||
@@ -38,6 +39,7 @@ import AdminExport from './pages/admin/AdminExport'
|
|||||||
import AdminTemplates from './pages/admin/AdminTemplates'
|
import AdminTemplates from './pages/admin/AdminTemplates'
|
||||||
import AdminSettings from './pages/admin/AdminSettings'
|
import AdminSettings from './pages/admin/AdminSettings'
|
||||||
import AdminTeam from './pages/admin/AdminTeam'
|
import AdminTeam from './pages/admin/AdminTeam'
|
||||||
|
import AdminPlugins from './pages/admin/AdminPlugins'
|
||||||
import AdminJoin from './pages/admin/AdminJoin'
|
import AdminJoin from './pages/admin/AdminJoin'
|
||||||
import ProfilePage from './pages/ProfilePage'
|
import ProfilePage from './pages/ProfilePage'
|
||||||
import RecoverPage from './pages/RecoverPage'
|
import RecoverPage from './pages/RecoverPage'
|
||||||
@@ -507,6 +509,7 @@ function Layout() {
|
|||||||
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
||||||
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
||||||
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
||||||
|
<Route path="/admin/plugins" element={<RequireAdmin><AdminPlugins /></RequireAdmin>} />
|
||||||
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
@@ -543,6 +546,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<TranslationProvider value={i18n}>
|
<TranslationProvider value={i18n}>
|
||||||
<ThemeProvider value={theme}>
|
<ThemeProvider value={theme}>
|
||||||
@@ -559,6 +563,7 @@ export default function App() {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
|
</ToastProvider>
|
||||||
</BrandingProvider>
|
</BrandingProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,7 @@
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
.spin { animation: spin 0.8s linear infinite; }
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -562,6 +563,22 @@
|
|||||||
from { transform: scale(0.97); opacity: 0; }
|
from { transform: scale(0.97); opacity: 0; }
|
||||||
to { transform: scale(1); opacity: 1; }
|
to { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
.expand-in {
|
||||||
|
animation: expandIn var(--duration-normal) var(--ease-out);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.expand-out {
|
||||||
|
animation: expandOut 200ms var(--ease-out) forwards;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@keyframes expandIn {
|
||||||
|
from { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; }
|
||||||
|
to { max-height: 500px; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes expandOut {
|
||||||
|
from { max-height: 500px; opacity: 1; }
|
||||||
|
to { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; }
|
||||||
|
}
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from { transform: translateY(-8px); opacity: 0; }
|
from { transform: translateY(-8px); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
@@ -601,6 +618,15 @@
|
|||||||
.count-tick {
|
.count-tick {
|
||||||
animation: countTick 300ms var(--ease-spring);
|
animation: countTick 300ms var(--ease-spring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { transform: translateY(-12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes toastOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
|
|||||||
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
||||||
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
||||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
||||||
|
{ to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 },
|
||||||
{ to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 },
|
{ to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 },
|
||||||
{ to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 },
|
{ to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 },
|
||||||
{ to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 },
|
{ to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react'
|
import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,6 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function PasskeyModal({ mode, open, onClose }: Props) {
|
export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
const trapRef = useFocusTrap(open)
|
const trapRef = useFocusTrap(open)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
@@ -88,6 +90,7 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
|
|||||||
await api.post('/auth/passkey/register/verify', { username, response: attestation })
|
await api.post('/auth/passkey/register/verify', { username, response: attestation })
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
|
toast.success('Passkey registered')
|
||||||
setTimeout(onClose, 2000)
|
setTimeout(onClose, 2000)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Registration failed. Please try again.')
|
setError(e?.message || 'Registration failed. Please try again.')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { IconShieldCheck, IconCopy, IconCheck, IconRefresh } from '@tabler/icons
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +12,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function RecoveryCodeModal({ open, onClose }: Props) {
|
export default function RecoveryCodeModal({ open, onClose }: Props) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
const trapRef = useFocusTrap(open)
|
const trapRef = useFocusTrap(open)
|
||||||
const [phrase, setPhrase] = useState<string | null>(null)
|
const [phrase, setPhrase] = useState<string | null>(null)
|
||||||
const [expiresAt, setExpiresAt] = useState<string | null>(null)
|
const [expiresAt, setExpiresAt] = useState<string | null>(null)
|
||||||
@@ -28,6 +30,7 @@ export default function RecoveryCodeModal({ open, onClose }: Props) {
|
|||||||
setPhrase(res.phrase)
|
setPhrase(res.phrase)
|
||||||
setExpiresAt(res.expiresAt)
|
setExpiresAt(res.expiresAt)
|
||||||
auth.refresh()
|
auth.refresh()
|
||||||
|
toast.success('Recovery code generated')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to generate recovery code')
|
setError('Failed to generate recovery code')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
174
packages/web/src/hooks/useToast.tsx
Normal file
174
packages/web/src/hooks/useToast.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { IconCheck, IconX, IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
type: ToastType
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastAPI {
|
||||||
|
success: (msg: string, duration?: number) => void
|
||||||
|
error: (msg: string, duration?: number) => void
|
||||||
|
info: (msg: string, duration?: number) => void
|
||||||
|
warning: (msg: string, duration?: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastAPI>({
|
||||||
|
success: () => {},
|
||||||
|
error: () => {},
|
||||||
|
info: () => {},
|
||||||
|
warning: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// global emitter for non-React code (api.ts etc)
|
||||||
|
type ToastFn = (msg: string, type?: string) => void
|
||||||
|
let globalToast: ToastFn | null = null
|
||||||
|
export function setGlobalToast(fn: ToastFn) { globalToast = fn }
|
||||||
|
export function emitToast(msg: string, type: ToastType = 'info') {
|
||||||
|
if (globalToast) globalToast(msg, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
let idCounter = 0
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<ToastType, string> = {
|
||||||
|
success: 'var(--success)',
|
||||||
|
error: 'var(--error)',
|
||||||
|
warning: 'var(--warning)',
|
||||||
|
info: 'var(--info)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<ToastType, typeof IconCheck> = {
|
||||||
|
success: IconCheck,
|
||||||
|
error: IconX,
|
||||||
|
warning: IconAlertTriangle,
|
||||||
|
info: IconInfoCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 5
|
||||||
|
|
||||||
|
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||||
|
const [exiting, setExiting] = useState(false)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
const Icon = TYPE_ICONS[toast.type]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setExiting(true)
|
||||||
|
setTimeout(() => onDismiss(toast.id), 200)
|
||||||
|
}, toast.duration)
|
||||||
|
return () => clearTimeout(timerRef.current)
|
||||||
|
}, [toast.id, toast.duration, onDismiss])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
setExiting(true)
|
||||||
|
setTimeout(() => onDismiss(toast.id), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: `3px solid ${TYPE_COLORS[toast.type]}`,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
maxWidth: 400,
|
||||||
|
minWidth: 220,
|
||||||
|
animation: exiting ? 'toastOut 200ms ease-out forwards' : 'toastIn 250ms var(--ease-out)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={16}
|
||||||
|
stroke={2.5}
|
||||||
|
style={{ color: TYPE_COLORS[toast.type], flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, lineHeight: 1.4 }}>{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 2,
|
||||||
|
lineHeight: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconX size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const add = useCallback((message: string, type: ToastType, duration = 4000) => {
|
||||||
|
const id = `toast-${++idCounter}`
|
||||||
|
setToasts((prev) => {
|
||||||
|
const next = [...prev, { id, message, type, duration }]
|
||||||
|
return next.slice(-MAX_VISIBLE)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const api: ToastAPI = {
|
||||||
|
success: useCallback((msg: string, dur?: number) => add(msg, 'success', dur), [add]),
|
||||||
|
error: useCallback((msg: string, dur?: number) => add(msg, 'error', dur), [add]),
|
||||||
|
info: useCallback((msg: string, dur?: number) => add(msg, 'info', dur), [add]),
|
||||||
|
warning: useCallback((msg: string, dur?: number) => add(msg, 'warning', dur), [add]),
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalToast((msg, type) => add(msg, (type as ToastType) || 'info'))
|
||||||
|
return () => { globalToast = null }
|
||||||
|
}, [add])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={api}>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Notifications"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 16,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { emitToast } from '../hooks/useToast'
|
||||||
|
|
||||||
const BASE = '/api/v1'
|
const BASE = '/api/v1'
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
@@ -29,6 +31,8 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|||||||
} catch {
|
} catch {
|
||||||
body = await res.text()
|
body = await res.text()
|
||||||
}
|
}
|
||||||
|
if (res.status === 429) emitToast('Too many requests, try again in a moment', 'warning')
|
||||||
|
if (res.status >= 500) emitToast('Something went wrong', 'error')
|
||||||
throw new ApiError(res.status, body)
|
throw new ApiError(res.status, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { IconExternalLink, IconRss, IconX, IconFilter, IconChevronDown, IconPlus
|
|||||||
import BoardIcon from '../components/BoardIcon'
|
import BoardIcon from '../components/BoardIcon'
|
||||||
import Dropdown from '../components/Dropdown'
|
import Dropdown from '../components/Dropdown'
|
||||||
import { solveAltcha } from '../lib/altcha'
|
import { solveAltcha } from '../lib/altcha'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: string
|
id: string
|
||||||
@@ -100,6 +101,7 @@ export default function BoardFeed() {
|
|||||||
const [formOpen, setFormOpen] = useState(false)
|
const [formOpen, setFormOpen] = useState(false)
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||||
|
const toast = useToast()
|
||||||
const [subscribed, setSubscribed] = useState(false)
|
const [subscribed, setSubscribed] = useState(false)
|
||||||
const [subLoading, setSubLoading] = useState(false)
|
const [subLoading, setSubLoading] = useState(false)
|
||||||
|
|
||||||
@@ -157,6 +159,7 @@ export default function BoardFeed() {
|
|||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
await api.delete(`/boards/${boardSlug}/subscribe`)
|
await api.delete(`/boards/${boardSlug}/subscribe`)
|
||||||
setSubscribed(false)
|
setSubscribed(false)
|
||||||
|
toast.info('Unsubscribed from notifications')
|
||||||
} else {
|
} else {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
alert('Push notifications are not supported in this browser')
|
alert('Push notifications are not supported in this browser')
|
||||||
@@ -176,8 +179,11 @@ export default function BoardFeed() {
|
|||||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||||
})
|
})
|
||||||
setSubscribed(true)
|
setSubscribed(true)
|
||||||
|
toast.success('Subscribed to board notifications')
|
||||||
}
|
}
|
||||||
} catch {} finally {
|
} catch {
|
||||||
|
toast.error('Failed to update subscription')
|
||||||
|
} finally {
|
||||||
setSubLoading(false)
|
setSubLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuth } from '../hooks/useAuth'
|
|||||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { solveAltcha } from '../lib/altcha'
|
import { solveAltcha } from '../lib/altcha'
|
||||||
import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react'
|
import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react'
|
||||||
@@ -20,6 +21,7 @@ interface PasskeyInfo {
|
|||||||
export default function IdentitySettings() {
|
export default function IdentitySettings() {
|
||||||
useDocumentTitle('Settings')
|
useDocumentTitle('Settings')
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const [name, setName] = useState(auth.displayName)
|
const [name, setName] = useState(auth.displayName)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -90,8 +92,11 @@ export default function IdentitySettings() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||||
|
toast.success('Avatar updated')
|
||||||
}
|
}
|
||||||
} catch {} finally {
|
} catch {
|
||||||
|
toast.error('Failed to upload avatar')
|
||||||
|
} finally {
|
||||||
setUploadingAvatar(false)
|
setUploadingAvatar(false)
|
||||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
@@ -101,7 +106,10 @@ export default function IdentitySettings() {
|
|||||||
try {
|
try {
|
||||||
await api.delete('/me/avatar')
|
await api.delete('/me/avatar')
|
||||||
setAvatarUrl(null)
|
setAvatarUrl(null)
|
||||||
} catch {}
|
toast.success('Avatar removed')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove avatar')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
@@ -112,7 +120,10 @@ export default function IdentitySettings() {
|
|||||||
await auth.updateProfile({ displayName: name, altcha })
|
await auth.updateProfile({ displayName: name, altcha })
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
} catch {} finally {
|
toast.success('Display name saved')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save name')
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +141,7 @@ export default function IdentitySettings() {
|
|||||||
await api.post('/auth/recover', { phrase: clean, altcha })
|
await api.post('/auth/recover', { phrase: clean, altcha })
|
||||||
setRedeemSuccess(true)
|
setRedeemSuccess(true)
|
||||||
auth.refresh()
|
auth.refresh()
|
||||||
|
toast.success('Identity recovered')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowRedeemInput(false)
|
setShowRedeemInput(false)
|
||||||
setRedeemPhrase('')
|
setRedeemPhrase('')
|
||||||
@@ -160,8 +172,11 @@ export default function IdentitySettings() {
|
|||||||
try {
|
try {
|
||||||
const altcha = await solveAltcha()
|
const altcha = await solveAltcha()
|
||||||
await auth.deleteIdentity(altcha)
|
await auth.deleteIdentity(altcha)
|
||||||
|
toast.info('Identity deleted')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
} catch {} finally {
|
} catch {
|
||||||
|
toast.error('Failed to delete identity')
|
||||||
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import MarkdownEditor from '../components/MarkdownEditor'
|
|||||||
import FileUpload from '../components/FileUpload'
|
import FileUpload from '../components/FileUpload'
|
||||||
import { solveAltcha } from '../lib/altcha'
|
import { solveAltcha } from '../lib/altcha'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
import { useToast } from '../hooks/useToast'
|
||||||
import { IMPORTANCE_OPTIONS } from '../components/PostCard'
|
import { IMPORTANCE_OPTIONS } from '../components/PostCard'
|
||||||
|
|
||||||
interface ImportanceCounts {
|
interface ImportanceCounts {
|
||||||
@@ -181,6 +182,7 @@ export default function PostDetail() {
|
|||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const admin = useAdmin()
|
const admin = useAdmin()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [post, setPost] = useState<Post | null>(null)
|
const [post, setPost] = useState<Post | null>(null)
|
||||||
useDocumentTitle(post?.title)
|
useDocumentTitle(post?.title)
|
||||||
const [boardName, setBoardName] = useState('')
|
const [boardName, setBoardName] = useState('')
|
||||||
@@ -236,12 +238,15 @@ export default function PostDetail() {
|
|||||||
try {
|
try {
|
||||||
if (wasVoted) {
|
if (wasVoted) {
|
||||||
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
||||||
|
toast.success('Vote removed')
|
||||||
} else {
|
} else {
|
||||||
const altcha = await solveAltcha('light')
|
const altcha = await solveAltcha('light')
|
||||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||||
|
toast.success('Vote added')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setPost({ ...post, voted: wasVoted, voteCount: post.voteCount })
|
setPost({ ...post, voted: wasVoted, voteCount: post.voteCount })
|
||||||
|
toast.error('Failed to vote')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +283,10 @@ export default function PostDetail() {
|
|||||||
setReplyTo(null)
|
setReplyTo(null)
|
||||||
setCommentAttachments([])
|
setCommentAttachments([])
|
||||||
fetchPost()
|
fetchPost()
|
||||||
} catch {} finally {
|
toast.success('Comment posted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to post comment')
|
||||||
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +321,10 @@ export default function PostDetail() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.put<{ isEditLocked: boolean }>(`/admin/posts/${postId}/lock-edits`)
|
const res = await api.put<{ isEditLocked: boolean }>(`/admin/posts/${postId}/lock-edits`)
|
||||||
setPost({ ...post, isEditLocked: res.isEditLocked })
|
setPost({ ...post, isEditLocked: res.isEditLocked })
|
||||||
} catch {}
|
toast.success(res.isEditLocked ? 'Post edits locked' : 'Post edits unlocked')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to toggle edit lock')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleThreadLock = async (lockVoting?: boolean) => {
|
const toggleThreadLock = async (lockVoting?: boolean) => {
|
||||||
@@ -324,7 +335,10 @@ export default function PostDetail() {
|
|||||||
{ lockVoting }
|
{ lockVoting }
|
||||||
)
|
)
|
||||||
setPost({ ...post, isThreadLocked: res.isThreadLocked, isVotingLocked: res.isVotingLocked })
|
setPost({ ...post, isThreadLocked: res.isThreadLocked, isVotingLocked: res.isVotingLocked })
|
||||||
} catch {}
|
toast.success(res.isThreadLocked ? 'Thread locked' : 'Thread unlocked')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to toggle thread lock')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCommentEditLock = async (commentId: string) => {
|
const toggleCommentEditLock = async (commentId: string) => {
|
||||||
@@ -366,7 +380,10 @@ export default function PostDetail() {
|
|||||||
}
|
}
|
||||||
setHistoryModal(null)
|
setHistoryModal(null)
|
||||||
fetchPost()
|
fetchPost()
|
||||||
} catch {}
|
toast.success('Version restored')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to restore version')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
@@ -386,7 +403,10 @@ export default function PostDetail() {
|
|||||||
})
|
})
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
fetchPost()
|
fetchPost()
|
||||||
} catch {} finally {
|
toast.success('Post updated')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update post')
|
||||||
|
} finally {
|
||||||
setEditSaving(false)
|
setEditSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,8 +645,11 @@ export default function PostDetail() {
|
|||||||
if (!await confirm('Delete this post?')) return
|
if (!await confirm('Delete this post?')) return
|
||||||
try {
|
try {
|
||||||
await api.delete(`/boards/${boardSlug}/posts/${postId}`)
|
await api.delete(`/boards/${boardSlug}/posts/${postId}`)
|
||||||
|
toast.success('Post deleted')
|
||||||
navigate(`/b/${boardSlug}`)
|
navigate(`/b/${boardSlug}`)
|
||||||
} catch {}
|
} catch {
|
||||||
|
toast.error('Failed to delete post')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 px-2.5 action-btn"
|
className="inline-flex items-center gap-1 px-2.5 action-btn"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Link } from 'react-router-dom'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
|
import { useAdmin } from '../../hooks/useAdmin'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import Dropdown from '../../components/Dropdown'
|
import Dropdown from '../../components/Dropdown'
|
||||||
import NumberInput from '../../components/NumberInput'
|
import NumberInput from '../../components/NumberInput'
|
||||||
import IconPicker from '../../components/IconPicker'
|
import IconPicker from '../../components/IconPicker'
|
||||||
@@ -26,6 +29,9 @@ interface Board {
|
|||||||
|
|
||||||
export default function AdminBoards() {
|
export default function AdminBoards() {
|
||||||
useDocumentTitle('Manage Boards')
|
useDocumentTitle('Manage Boards')
|
||||||
|
const { isSuperAdmin } = useAdmin()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [boards, setBoards] = useState<Board[]>([])
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||||
@@ -84,12 +90,16 @@ export default function AdminBoards() {
|
|||||||
try {
|
try {
|
||||||
if (editBoard) {
|
if (editBoard) {
|
||||||
await api.put(`/admin/boards/${editBoard.id}`, form)
|
await api.put(`/admin/boards/${editBoard.id}`, form)
|
||||||
|
toast.success('Board updated')
|
||||||
} else {
|
} else {
|
||||||
await api.post('/admin/boards', form)
|
await api.post('/admin/boards', form)
|
||||||
|
toast.success('Board created')
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
fetchBoards()
|
fetchBoards()
|
||||||
} catch {} finally {
|
} catch {
|
||||||
|
toast.error('Failed to save board')
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +108,24 @@ export default function AdminBoards() {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
|
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
|
||||||
fetchBoards()
|
fetchBoards()
|
||||||
} catch {}
|
toast.info(isArchived ? 'Board restored' : 'Board archived')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update board')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (board: Board) => {
|
||||||
|
const ok = await confirm(
|
||||||
|
`Permanently delete "${board.name}" and all its posts, comments, and votes? This cannot be undone.`
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/boards/${board.id}`)
|
||||||
|
fetchBoards()
|
||||||
|
toast.success('Board deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete board')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
@@ -165,6 +192,15 @@ export default function AdminBoards() {
|
|||||||
>
|
>
|
||||||
{board.isArchived ? 'Restore' : 'Archive'}
|
{board.isArchived ? 'Restore' : 'Archive'}
|
||||||
</button>
|
</button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(board)}
|
||||||
|
className="action-btn text-xs px-2"
|
||||||
|
style={{ minHeight: 44, color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -291,7 +327,7 @@ export default function AdminBoards() {
|
|||||||
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={200}
|
max={200}
|
||||||
style={{ width: 100 }}
|
style={{ minWidth: 120 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +11,7 @@ interface Category {
|
|||||||
|
|
||||||
export default function AdminCategories() {
|
export default function AdminCategories() {
|
||||||
useDocumentTitle('Categories')
|
useDocumentTitle('Categories')
|
||||||
|
const toast = useToast()
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
@@ -33,8 +35,10 @@ export default function AdminCategories() {
|
|||||||
setName('')
|
setName('')
|
||||||
setSlug('')
|
setSlug('')
|
||||||
fetch()
|
fetch()
|
||||||
|
toast.success('Category created')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to create category')
|
setError('Failed to create category')
|
||||||
|
toast.error('Failed to create category')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +46,10 @@ export default function AdminCategories() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/categories/${id}`)
|
await api.delete(`/admin/categories/${id}`)
|
||||||
fetch()
|
fetch()
|
||||||
} catch {}
|
toast.success('Category deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete category')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconPlus, IconTrash, IconPencil } from '@tabler/icons-react'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import MarkdownEditor from '../../components/MarkdownEditor'
|
import MarkdownEditor from '../../components/MarkdownEditor'
|
||||||
import Dropdown from '../../components/Dropdown'
|
import Dropdown from '../../components/Dropdown'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ interface Entry {
|
|||||||
export default function AdminChangelog() {
|
export default function AdminChangelog() {
|
||||||
useDocumentTitle('Manage Changelog')
|
useDocumentTitle('Manage Changelog')
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [entries, setEntries] = useState<Entry[]>([])
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
const [boards, setBoards] = useState<Board[]>([])
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -73,8 +75,10 @@ export default function AdminChangelog() {
|
|||||||
setEditId(null)
|
setEditId(null)
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
fetchEntries()
|
fetchEntries()
|
||||||
|
toast.success('Changelog entry saved')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to save entry')
|
setError('Failed to save entry')
|
||||||
|
toast.error('Failed to save entry')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +87,10 @@ export default function AdminChangelog() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/changelog/${id}`)
|
await api.delete(`/admin/changelog/${id}`)
|
||||||
fetchEntries()
|
fetchEntries()
|
||||||
} catch {}
|
toast.success('Entry deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete entry')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (entry: Entry) => {
|
const startEdit = (entry: Entry) => {
|
||||||
|
|||||||
472
packages/web/src/pages/admin/AdminPlugins.tsx
Normal file
472
packages/web/src/pages/admin/AdminPlugins.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { IconPlug, IconUpload, IconTrash, IconSettings, IconAlertTriangle, IconRefresh } from '@tabler/icons-react'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
|
||||||
|
interface ConfigField {
|
||||||
|
key: string
|
||||||
|
type: 'text' | 'password' | 'number' | 'boolean' | 'select'
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
options?: Array<{ value: string; label: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRecord {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string | null
|
||||||
|
author: string | null
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
configSchema: ConfigField[]
|
||||||
|
installedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPlugins() {
|
||||||
|
useDocumentTitle('Plugins')
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [plugins, setPlugins] = useState<PluginRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [configEditing, setConfigEditing] = useState<string | null>(null)
|
||||||
|
const [configClosing, setConfigClosing] = useState<string | null>(null)
|
||||||
|
const [configValues, setConfigValues] = useState<Record<string, unknown>>({})
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const fetchPlugins = useCallback(() => {
|
||||||
|
api.get<{ plugins: PluginRecord[] }>('/admin/plugins')
|
||||||
|
.then((r) => setPlugins(r.plugins))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchPlugins() }, [fetchPlugins])
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
if (!file.name.endsWith('.zip')) {
|
||||||
|
setError('Only .zip files are allowed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const r = await fetch('/api/v1/admin/plugins/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
throw new Error((body as { error?: string }).error || 'Upload failed')
|
||||||
|
}
|
||||||
|
setShowUpload(false)
|
||||||
|
fetchPlugins()
|
||||||
|
toast.success('Plugin installed')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed')
|
||||||
|
toast.error('Failed to install plugin')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePlugin = async (id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/plugins/${id}/${enabled ? 'disable' : 'enable'}`)
|
||||||
|
fetchPlugins()
|
||||||
|
toast.success(enabled ? 'Plugin disabled' : 'Plugin enabled')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to toggle plugin')
|
||||||
|
toast.error('Failed to toggle plugin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePlugin = async (id: string, name: string) => {
|
||||||
|
if (!await confirm(`Delete plugin "${name}"? This removes all plugin data.`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/plugins/${id}`)
|
||||||
|
fetchPlugins()
|
||||||
|
toast.success('Plugin deleted')
|
||||||
|
} catch {
|
||||||
|
setError('Failed to delete plugin')
|
||||||
|
toast.error('Failed to delete plugin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [syncing, setSyncing] = useState<string | null>(null)
|
||||||
|
const [syncResult, setSyncResult] = useState('')
|
||||||
|
|
||||||
|
const triggerSync = async (pluginId: string) => {
|
||||||
|
setSyncing(pluginId)
|
||||||
|
setSyncResult('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/plugins/${pluginId}/sync`, { method: 'POST', credentials: 'include' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Sync failed')
|
||||||
|
setSyncResult(`Synced ${data.synced ?? 0} repos (${data.created ?? 0} created, ${data.updated ?? 0} updated)`)
|
||||||
|
toast.success(`Sync complete - ${data.synced ?? 0} repos`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setSyncResult(e instanceof Error ? e.message : 'Sync failed')
|
||||||
|
} finally {
|
||||||
|
setSyncing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConfig = () => {
|
||||||
|
if (!configEditing) return
|
||||||
|
setConfigClosing(configEditing)
|
||||||
|
setTimeout(() => { setConfigEditing(null); setConfigClosing(null) }, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveConfig = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/plugins/${id}/config`, configValues)
|
||||||
|
closeConfig()
|
||||||
|
fetchPlugins()
|
||||||
|
toast.success('Config saved')
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save config')
|
||||||
|
toast.error('Failed to save config')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openConfig = (plugin: PluginRecord) => {
|
||||||
|
if (configEditing === plugin.id) {
|
||||||
|
closeConfig()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfigClosing(null)
|
||||||
|
setConfigEditing(plugin.id)
|
||||||
|
setConfigValues({ ...plugin.config })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setConfigField = (key: string, value: unknown) => {
|
||||||
|
setConfigValues((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) uploadFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso: string) => {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1
|
||||||
|
className="font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||||
|
>
|
||||||
|
Plugins
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(!showUpload)}
|
||||||
|
className="btn btn-admin flex items-center gap-1"
|
||||||
|
aria-expanded={showUpload}
|
||||||
|
style={{ fontSize: 'var(--text-sm)' }}
|
||||||
|
>
|
||||||
|
<IconUpload size={14} stroke={2} />
|
||||||
|
Upload Plugin
|
||||||
|
</button>
|
||||||
|
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning banner */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg mb-5"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(245, 158, 11, 0.08)',
|
||||||
|
border: '1px solid rgba(245, 158, 11, 0.25)',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconAlertTriangle size={18} stroke={2} style={{ flexShrink: 0 }} />
|
||||||
|
Plugins run with full server access. Only install plugins you trust.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs mb-4" style={{ color: 'var(--error)' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload section */}
|
||||||
|
{showUpload && (
|
||||||
|
<div className="card p-4 mb-6">
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
border: `2px dashed ${dragOver ? 'var(--admin-accent)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '32px 24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: uploading ? 'wait' : 'pointer',
|
||||||
|
background: dragOver ? 'var(--admin-subtle)' : 'transparent',
|
||||||
|
transition: 'all var(--duration-fast) ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) uploadFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="progress-bar mb-3" />
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Uploading...</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconUpload size={28} stroke={1.5} style={{ color: 'var(--text-tertiary)', margin: '0 auto 8px' }} />
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||||
|
Drop a .zip file here or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Only .zip plugin packages are accepted
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button onClick={() => setShowUpload(false)} className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin list */}
|
||||||
|
{loading ? (
|
||||||
|
<div>
|
||||||
|
<div className="progress-bar mb-4" />
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
||||||
|
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||||
|
<div className="skeleton h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : plugins.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center py-12" style={{ textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center mb-4"
|
||||||
|
style={{
|
||||||
|
width: 56, height: 56,
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
background: 'var(--surface-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconPlug size={24} stroke={1.5} style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
|
||||||
|
No plugins installed
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
Upload a plugin package to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<div key={plugin.id}>
|
||||||
|
<div
|
||||||
|
className="card p-4"
|
||||||
|
style={{ opacity: plugin.enabled ? 1 : 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
{plugin.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
v{plugin.version}
|
||||||
|
</span>
|
||||||
|
{!plugin.enabled && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{ background: 'rgba(245, 158, 11, 0.1)', color: 'var(--warning)' }}
|
||||||
|
>
|
||||||
|
disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{plugin.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{plugin.author && <span>by {plugin.author}</span>}
|
||||||
|
<span>Installed {formatDate(plugin.installedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openConfig(plugin)}
|
||||||
|
className="action-btn"
|
||||||
|
aria-label={`Configure ${plugin.name}`}
|
||||||
|
aria-expanded={configEditing === plugin.id}
|
||||||
|
style={{ minHeight: 44, color: 'var(--text-tertiary)', background: 'none', border: 'none', cursor: 'pointer', padding: '0 8px' }}
|
||||||
|
>
|
||||||
|
<IconSettings size={16} stroke={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePlugin(plugin.id, plugin.enabled)}
|
||||||
|
className="action-btn text-xs px-2 rounded"
|
||||||
|
style={{ minHeight: 44, color: plugin.enabled ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||||
|
className="action-btn"
|
||||||
|
aria-label={`Delete ${plugin.name}`}
|
||||||
|
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer', padding: '0 8px' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config editor */}
|
||||||
|
{configEditing === plugin.id && (
|
||||||
|
<div className={configClosing === plugin.id ? 'expand-out' : 'expand-in'} style={{ borderTop: '1px solid var(--border)', padding: '16px', marginTop: 12 }}>
|
||||||
|
{plugin.configSchema && plugin.configSchema.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{plugin.configSchema.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label
|
||||||
|
htmlFor={`cfg-${plugin.id}-${field.key}`}
|
||||||
|
style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span style={{ color: 'var(--error)' }}> *</span>}
|
||||||
|
</label>
|
||||||
|
{field.type === 'boolean' ? (
|
||||||
|
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!configValues[field.key]}
|
||||||
|
onChange={(e) => setConfigField(field.key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{field.placeholder || 'Enabled'}
|
||||||
|
</label>
|
||||||
|
) : field.type === 'select' && field.options ? (
|
||||||
|
<select
|
||||||
|
id={`cfg-${plugin.id}-${field.key}`}
|
||||||
|
className="input w-full"
|
||||||
|
value={String(configValues[field.key] ?? '')}
|
||||||
|
onChange={(e) => setConfigField(field.key, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={`cfg-${plugin.id}-${field.key}`}
|
||||||
|
className="input w-full"
|
||||||
|
type={field.type === 'number' ? 'number' : field.type === 'password' ? 'password' : 'text'}
|
||||||
|
value={String(configValues[field.key] ?? '')}
|
||||||
|
onChange={(e) => setConfigField(field.key, field.type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={`config-${plugin.id}`}
|
||||||
|
className="text-xs font-medium block mb-2"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
Plugin configuration (JSON)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`config-${plugin.id}`}
|
||||||
|
className="input w-full"
|
||||||
|
rows={6}
|
||||||
|
value={JSON.stringify(configValues, null, 2)}
|
||||||
|
onChange={(e) => { try { setConfigValues(JSON.parse(e.target.value)) } catch {} }}
|
||||||
|
style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 'var(--text-xs)', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
<button onClick={() => saveConfig(plugin.id)} className="btn btn-admin" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{plugin.enabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => triggerSync(plugin.id)}
|
||||||
|
disabled={syncing === plugin.id}
|
||||||
|
className="btn btn-secondary flex items-center gap-1.5"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', opacity: syncing === plugin.id ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} stroke={2} className={syncing === plugin.id ? 'spin' : ''} />
|
||||||
|
{syncing === plugin.id ? 'Syncing...' : 'Sync now'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={closeConfig} className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{syncResult && (
|
||||||
|
<p className="mt-2" style={{ fontSize: 'var(--text-xs)', color: syncResult.includes('fail') || syncResult.includes('error') || syncResult.includes('missing') ? 'var(--error)' : 'var(--success)' }}>
|
||||||
|
{syncResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { api } from '../../lib/api'
|
|||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import { IconPin, IconPinnedOff, IconNote, IconTrash, IconGitMerge, IconX, IconTag, IconArrowsExchange, IconUserPlus } from '@tabler/icons-react'
|
import { IconPin, IconPinnedOff, IconNote, IconTrash, IconGitMerge, IconX, IconTag, IconArrowsExchange, IconUserPlus } from '@tabler/icons-react'
|
||||||
import MarkdownEditor from '../../components/MarkdownEditor'
|
import MarkdownEditor from '../../components/MarkdownEditor'
|
||||||
import StatusBadge from '../../components/StatusBadge'
|
import StatusBadge from '../../components/StatusBadge'
|
||||||
@@ -54,6 +55,7 @@ interface StatusOption {
|
|||||||
export default function AdminPosts() {
|
export default function AdminPosts() {
|
||||||
useDocumentTitle('Manage Posts')
|
useDocumentTitle('Manage Posts')
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [posts, setPosts] = useState<Post[]>([])
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [sortBy, setSortBy] = useState<SortField>('createdAt')
|
const [sortBy, setSortBy] = useState<SortField>('createdAt')
|
||||||
@@ -124,10 +126,14 @@ export default function AdminPosts() {
|
|||||||
setBulkBusy(true)
|
setBulkBusy(true)
|
||||||
setBulkStatusOpen(false)
|
setBulkStatusOpen(false)
|
||||||
try {
|
try {
|
||||||
|
const count = selected.size
|
||||||
await api.post('/admin/posts/bulk-status', { postIds: [...selected], status })
|
await api.post('/admin/posts/bulk-status', { postIds: [...selected], status })
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {} finally {
|
toast.success(`${count} post${count > 1 ? 's' : ''} updated`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update posts')
|
||||||
|
} finally {
|
||||||
setBulkBusy(false)
|
setBulkBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,10 +142,14 @@ export default function AdminPosts() {
|
|||||||
setBulkBusy(true)
|
setBulkBusy(true)
|
||||||
setBulkTagOpen(false)
|
setBulkTagOpen(false)
|
||||||
try {
|
try {
|
||||||
|
const count = selected.size
|
||||||
await api.post('/admin/posts/bulk-tag', { postIds: [...selected], tagId, action: 'add' })
|
await api.post('/admin/posts/bulk-tag', { postIds: [...selected], tagId, action: 'add' })
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {} finally {
|
toast.success(`Tag added to ${count} post${count > 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to add tag')
|
||||||
|
} finally {
|
||||||
setBulkBusy(false)
|
setBulkBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,10 +158,14 @@ export default function AdminPosts() {
|
|||||||
if (!await confirm(`Delete ${selected.size} post${selected.size > 1 ? 's' : ''}? This cannot be undone.`)) return
|
if (!await confirm(`Delete ${selected.size} post${selected.size > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||||
setBulkBusy(true)
|
setBulkBusy(true)
|
||||||
try {
|
try {
|
||||||
|
const count = selected.size
|
||||||
await api.post('/admin/posts/bulk-delete', { postIds: [...selected] })
|
await api.post('/admin/posts/bulk-delete', { postIds: [...selected] })
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {} finally {
|
toast.success(`${count} post${count > 1 ? 's' : ''} deleted`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete posts')
|
||||||
|
} finally {
|
||||||
setBulkBusy(false)
|
setBulkBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +218,10 @@ export default function AdminPosts() {
|
|||||||
setResponse('')
|
setResponse('')
|
||||||
setSelectedTagIds([])
|
setSelectedTagIds([])
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {} finally {
|
toast.success('Status updated')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update status')
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +261,10 @@ export default function AdminPosts() {
|
|||||||
setActionPost(null)
|
setActionPost(null)
|
||||||
setMergeTarget('')
|
setMergeTarget('')
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {} finally {
|
toast.success('Posts merged')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to merge posts')
|
||||||
|
} finally {
|
||||||
setMerging(false)
|
setMerging(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +273,9 @@ export default function AdminPosts() {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/admin/posts/${id}/pin`)
|
await api.put(`/admin/posts/${id}/pin`)
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {}
|
} catch {
|
||||||
|
toast.error('Failed to pin post')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
@@ -261,7 +283,10 @@ export default function AdminPosts() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/posts/${id}`)
|
await api.delete(`/admin/posts/${id}`)
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
} catch {}
|
toast.success('Post deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete post')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openProxyModal = async () => {
|
const openProxyModal = async () => {
|
||||||
@@ -298,8 +323,10 @@ export default function AdminPosts() {
|
|||||||
})
|
})
|
||||||
resetProxy()
|
resetProxy()
|
||||||
fetchPosts()
|
fetchPosts()
|
||||||
|
toast.success('Post created')
|
||||||
} catch {
|
} catch {
|
||||||
setProxyError('Failed to submit. Check required fields.')
|
setProxyError('Failed to submit. Check required fields.')
|
||||||
|
toast.error('Failed to create post')
|
||||||
} finally {
|
} finally {
|
||||||
setProxySaving(false)
|
setProxySaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
|
||||||
interface SiteSettings {
|
interface SiteSettings {
|
||||||
appName: string
|
appName: string
|
||||||
@@ -29,6 +30,7 @@ export default function AdminSettings() {
|
|||||||
useDocumentTitle('Branding')
|
useDocumentTitle('Branding')
|
||||||
const [form, setForm] = useState<SiteSettings>(defaults)
|
const [form, setForm] = useState<SiteSettings>(defaults)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const toast = useToast()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
@@ -46,7 +48,10 @@ export default function AdminSettings() {
|
|||||||
await api.put('/admin/site-settings', form)
|
await api.put('/admin/site-settings', form)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
} catch {} finally {
|
toast.success('Branding saved')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save branding')
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import { IconPalette, IconGripVertical, IconCheck, IconPlus, IconTrash } from '@tabler/icons-react'
|
import { IconPalette, IconGripVertical, IconCheck, IconPlus, IconTrash } from '@tabler/icons-react'
|
||||||
import Dropdown from '../../components/Dropdown'
|
import Dropdown from '../../components/Dropdown'
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export default function AdminStatuses() {
|
|||||||
const [boards, setBoards] = useState<Board[]>([])
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||||
const [statuses, setStatuses] = useState<StatusEntry[]>([])
|
const [statuses, setStatuses] = useState<StatusEntry[]>([])
|
||||||
|
const toast = useToast()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -210,6 +212,7 @@ export default function AdminStatuses() {
|
|||||||
await api.put(`/admin/boards/${selectedBoardId}/statuses`, { statuses: payload })
|
await api.put(`/admin/boards/${selectedBoardId}/statuses`, { statuses: payload })
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
toast.success('Statuses saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e && typeof e === 'object' && 'body' in e
|
const msg = e && typeof e === 'object' && 'body' in e
|
||||||
? ((e as { body: { error?: string } }).body?.error || 'Save failed')
|
? ((e as { body: { error?: string } }).body?.error || 'Save failed')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
id: string
|
id: string
|
||||||
@@ -19,6 +20,7 @@ const PRESET_COLORS = [
|
|||||||
export default function AdminTags() {
|
export default function AdminTags() {
|
||||||
useDocumentTitle('Tags')
|
useDocumentTitle('Tags')
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [color, setColor] = useState('#6366F1')
|
const [color, setColor] = useState('#6366F1')
|
||||||
@@ -44,8 +46,10 @@ export default function AdminTags() {
|
|||||||
await api.post('/admin/tags', { name: name.trim(), color })
|
await api.post('/admin/tags', { name: name.trim(), color })
|
||||||
setName('')
|
setName('')
|
||||||
fetchTags()
|
fetchTags()
|
||||||
|
toast.success('Tag created')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to create tag')
|
setError('Failed to create tag')
|
||||||
|
toast.error('Failed to create tag')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +59,10 @@ export default function AdminTags() {
|
|||||||
await api.put(`/admin/tags/${editId}`, { name: editName.trim(), color: editColor })
|
await api.put(`/admin/tags/${editId}`, { name: editName.trim(), color: editColor })
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
fetchTags()
|
fetchTags()
|
||||||
|
toast.success('Tag updated')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to update tag')
|
setError('Failed to update tag')
|
||||||
|
toast.error('Failed to update tag')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +71,10 @@ export default function AdminTags() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/tags/${id}`)
|
await api.delete(`/admin/tags/${id}`)
|
||||||
fetchTags()
|
fetchTags()
|
||||||
} catch {}
|
toast.success('Tag deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete tag')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../../lib/api'
|
|||||||
import { useAdmin } from '../../hooks/useAdmin'
|
import { useAdmin } from '../../hooks/useAdmin'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import Dropdown from '../../components/Dropdown'
|
import Dropdown from '../../components/Dropdown'
|
||||||
import { IconCopy, IconCheck, IconTrash, IconKey, IconPlus, IconInfoCircle, IconX, IconCamera } from '@tabler/icons-react'
|
import { IconCopy, IconCheck, IconTrash, IconKey, IconPlus, IconInfoCircle, IconX, IconCamera } from '@tabler/icons-react'
|
||||||
import Avatar from '../../components/Avatar'
|
import Avatar from '../../components/Avatar'
|
||||||
@@ -84,6 +85,7 @@ export default function AdminTeam() {
|
|||||||
const admin = useAdmin()
|
const admin = useAdmin()
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const [members, setMembers] = useState<TeamMember[]>([])
|
const [members, setMembers] = useState<TeamMember[]>([])
|
||||||
const [invites, setInvites] = useState<PendingInvite[]>([])
|
const [invites, setInvites] = useState<PendingInvite[]>([])
|
||||||
@@ -152,8 +154,11 @@ export default function AdminTeam() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||||
|
toast.success('Avatar updated')
|
||||||
}
|
}
|
||||||
} catch {} finally {
|
} catch {
|
||||||
|
toast.error('Failed to upload avatar')
|
||||||
|
} finally {
|
||||||
setUploadingAvatar(false)
|
setUploadingAvatar(false)
|
||||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
@@ -163,7 +168,10 @@ export default function AdminTeam() {
|
|||||||
try {
|
try {
|
||||||
await api.delete('/me/avatar')
|
await api.delete('/me/avatar')
|
||||||
setAvatarUrl(null)
|
setAvatarUrl(null)
|
||||||
} catch {}
|
toast.success('Avatar removed')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove avatar')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeMember = async (id: string, name: string | null) => {
|
const removeMember = async (id: string, name: string | null) => {
|
||||||
@@ -171,8 +179,10 @@ export default function AdminTeam() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/team/${id}`)
|
await api.delete(`/admin/team/${id}`)
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
|
toast.success('Member removed')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to remove member')
|
setError('Failed to remove member')
|
||||||
|
toast.error('Failed to remove member')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,8 +191,10 @@ export default function AdminTeam() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/team/invites/${id}`)
|
await api.delete(`/admin/team/invites/${id}`)
|
||||||
fetchInvites()
|
fetchInvites()
|
||||||
|
toast.success('Invite revoked')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to revoke invite')
|
setError('Failed to revoke invite')
|
||||||
|
toast.error('Failed to revoke invite')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +203,10 @@ export default function AdminTeam() {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post<{ recoveryPhrase: string }>(`/admin/team/${id}/recovery`)
|
const res = await api.post<{ recoveryPhrase: string }>(`/admin/team/${id}/recovery`)
|
||||||
alert(`New recovery phrase:\n\n${res.recoveryPhrase}\n\nSave this - it won't be shown again.`)
|
alert(`New recovery phrase:\n\n${res.recoveryPhrase}\n\nSave this - it won't be shown again.`)
|
||||||
|
toast.info('Recovery phrase generated')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to regenerate recovery phrase')
|
setError('Failed to regenerate recovery phrase')
|
||||||
|
toast.error('Failed to regenerate recovery phrase')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,8 +221,10 @@ export default function AdminTeam() {
|
|||||||
generateRecovery: inviteRecovery,
|
generateRecovery: inviteRecovery,
|
||||||
})
|
})
|
||||||
setInviteResult(res)
|
setInviteResult(res)
|
||||||
|
toast.success('Invite created')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to create invite')
|
setError('Failed to create invite')
|
||||||
|
toast.error('Failed to create invite')
|
||||||
} finally {
|
} finally {
|
||||||
setInviteLoading(false)
|
setInviteLoading(false)
|
||||||
}
|
}
|
||||||
@@ -230,8 +246,10 @@ export default function AdminTeam() {
|
|||||||
admin.refresh()
|
admin.refresh()
|
||||||
setProfileSaved(true)
|
setProfileSaved(true)
|
||||||
setTimeout(() => setProfileSaved(false), 2000)
|
setTimeout(() => setProfileSaved(false), 2000)
|
||||||
|
toast.success('Profile saved')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to update profile')
|
setError('Failed to update profile')
|
||||||
|
toast.error('Failed to save profile')
|
||||||
} finally {
|
} finally {
|
||||||
setProfileSaving(false)
|
setProfileSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
import {
|
import {
|
||||||
IconTemplate, IconGripVertical, IconCheck, IconPlus, IconTrash,
|
IconTemplate, IconGripVertical, IconCheck, IconPlus, IconTrash,
|
||||||
IconPencil, IconX, IconStar, IconChevronDown, IconChevronUp,
|
IconPencil, IconX, IconStar, IconChevronDown, IconChevronUp,
|
||||||
@@ -211,6 +212,7 @@ export default function AdminTemplates() {
|
|||||||
const [boards, setBoards] = useState<Board[]>([])
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const toast = useToast()
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
// drag state
|
// drag state
|
||||||
@@ -309,8 +311,10 @@ export default function AdminTemplates() {
|
|||||||
}
|
}
|
||||||
fetchTemplates()
|
fetchTemplates()
|
||||||
closeModal()
|
closeModal()
|
||||||
|
toast.success('Template saved')
|
||||||
} catch {
|
} catch {
|
||||||
setModalError('Failed to save template')
|
setModalError('Failed to save template')
|
||||||
|
toast.error('Failed to save template')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -320,8 +324,10 @@ export default function AdminTemplates() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/templates/${id}`)
|
await api.delete(`/admin/templates/${id}`)
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
toast.success('Template deleted')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to delete template')
|
setError('Failed to delete template')
|
||||||
|
toast.error('Failed to delete template')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconPlus, IconTrash } from '@tabler/icons-react'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
|
||||||
interface Webhook {
|
interface Webhook {
|
||||||
id: string
|
id: string
|
||||||
@@ -23,6 +24,7 @@ const ALL_EVENTS = [
|
|||||||
export default function AdminWebhooks() {
|
export default function AdminWebhooks() {
|
||||||
useDocumentTitle('Webhooks')
|
useDocumentTitle('Webhooks')
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -49,8 +51,10 @@ export default function AdminWebhooks() {
|
|||||||
setUrl('')
|
setUrl('')
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
fetchWebhooks()
|
fetchWebhooks()
|
||||||
|
toast.success('Webhook created')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to create webhook')
|
setError('Failed to create webhook')
|
||||||
|
toast.error('Failed to create webhook')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +62,10 @@ export default function AdminWebhooks() {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/admin/webhooks/${id}`, { active: !active })
|
await api.put(`/admin/webhooks/${id}`, { active: !active })
|
||||||
fetchWebhooks()
|
fetchWebhooks()
|
||||||
} catch {}
|
toast.success(active ? 'Webhook disabled' : 'Webhook enabled')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update webhook')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -66,7 +73,10 @@ export default function AdminWebhooks() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/webhooks/${id}`)
|
await api.delete(`/admin/webhooks/${id}`)
|
||||||
fetchWebhooks()
|
fetchWebhooks()
|
||||||
} catch {}
|
toast.success('Webhook deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete webhook')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
169
plugins/gitea-sync/index.js
Normal file
169
plugins/gitea-sync/index.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// gitea-sync plugin
|
||||||
|
// config: { profileUrl: "https://git.example.com/username", token: "optional" }
|
||||||
|
|
||||||
|
const ICONS = [
|
||||||
|
"IconCode", "IconBrandGit", "IconTerminal2", "IconServer", "IconDatabase",
|
||||||
|
"IconCloud", "IconRocket", "IconPuzzle", "IconCpu", "IconBug",
|
||||||
|
"IconFileCode", "IconGitBranch", "IconPackage", "IconApi", "IconBolt",
|
||||||
|
"IconBrowser", "IconDevices", "IconLock", "IconWorld", "IconSettings",
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#F59E0B", "#EF4444", "#3B82F6", "#10B981", "#8B5CF6",
|
||||||
|
"#EC4899", "#06B6D4", "#F97316", "#14B8A6", "#6366F1",
|
||||||
|
];
|
||||||
|
|
||||||
|
function titleCase(str) {
|
||||||
|
return str
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
|
.split(" ")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom(arr, seed) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
|
||||||
|
return arr[Math.abs(hash) % arr.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicRepos(url, user, token) {
|
||||||
|
const repos = [];
|
||||||
|
let page = 1;
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers["Authorization"] = `token ${token}`;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${url}/api/v1/repos/search?owner=${encodeURIComponent(user)}&limit=50&page=${page}&sort=updated`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (!res.ok) break;
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const data = body.data || [];
|
||||||
|
if (!data.length) break;
|
||||||
|
|
||||||
|
for (const repo of data) {
|
||||||
|
if (!repo.private && !repo.fork) {
|
||||||
|
repos.push({
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description || null,
|
||||||
|
htmlUrl: repo.html_url,
|
||||||
|
updatedAt: repo.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length < 50) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProfileUrl(raw) {
|
||||||
|
const clean = String(raw).replace(/\/+$/, "");
|
||||||
|
const parts = clean.split("/");
|
||||||
|
const user = parts.pop();
|
||||||
|
const url = parts.join("/");
|
||||||
|
return { url, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRepos(ctx) {
|
||||||
|
const { profileUrl, token } = ctx.config;
|
||||||
|
if (!profileUrl) {
|
||||||
|
ctx.logger.warn("gitea-sync: missing profileUrl in config");
|
||||||
|
return { synced: 0, error: "missing profile URL in config" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, user } = parseProfileUrl(profileUrl);
|
||||||
|
if (!url || !user) {
|
||||||
|
ctx.logger.warn("gitea-sync: could not parse profile URL");
|
||||||
|
return { synced: 0, error: "invalid profile URL - expected https://instance/username" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = await fetchPublicRepos(url, user, token ? String(token) : null);
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
|
||||||
|
const existing = await ctx.prisma.board.findUnique({ where: { slug } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.prisma.board.update({
|
||||||
|
where: { slug },
|
||||||
|
data: { externalUrl: repo.htmlUrl, description: repo.description || existing.description },
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
const name = titleCase(repo.name);
|
||||||
|
const iconName = pickRandom(ICONS, repo.name);
|
||||||
|
const iconColor = pickRandom(COLORS, repo.name + "color");
|
||||||
|
|
||||||
|
await ctx.prisma.board.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description: repo.description || `Feedback for ${name}`,
|
||||||
|
externalUrl: repo.htmlUrl,
|
||||||
|
iconName,
|
||||||
|
iconColor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSync = new Date().toISOString();
|
||||||
|
await ctx.store.set("lastSync", lastSync);
|
||||||
|
await ctx.store.set("repoCount", repos.length);
|
||||||
|
ctx.logger.info({ created, updated, total: repos.length }, "gitea-sync completed");
|
||||||
|
|
||||||
|
return { synced: repos.length, created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/sync",
|
||||||
|
async handler(req, reply, ctx) {
|
||||||
|
const result = await syncRepos(ctx);
|
||||||
|
reply.send(result);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/status",
|
||||||
|
async handler(_req, reply, ctx) {
|
||||||
|
const lastSync = await ctx.store.get("lastSync");
|
||||||
|
const repoCount = await ctx.store.get("repoCount");
|
||||||
|
reply.send({ lastSync, repoCount, profileUrl: ctx.config.profileUrl });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
events: {
|
||||||
|
async post_created(data, ctx) {
|
||||||
|
ctx.logger.info({ postId: data.postId, board: data.boardSlug }, "gitea-sync: new post created");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async onEnable(ctx) {
|
||||||
|
ctx.logger.info("gitea-sync plugin enabled");
|
||||||
|
if (ctx.config.profileUrl) {
|
||||||
|
try {
|
||||||
|
await syncRepos(ctx);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ error: err.message }, "gitea-sync: initial sync failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onDisable(ctx) {
|
||||||
|
ctx.logger.info("gitea-sync plugin disabled");
|
||||||
|
},
|
||||||
|
}
|
||||||
22
plugins/gitea-sync/manifest.json
Normal file
22
plugins/gitea-sync/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-sync",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Syncs public repositories from a Gitea instance as feedback boards",
|
||||||
|
"author": "echoboard",
|
||||||
|
"entryPoint": "index.js",
|
||||||
|
"configSchema": [
|
||||||
|
{
|
||||||
|
"key": "profileUrl",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Gitea profile URL",
|
||||||
|
"placeholder": "https://git.example.com/username",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"type": "password",
|
||||||
|
"label": "API token",
|
||||||
|
"placeholder": "Leave empty for public repos only"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user