Add repository management functionality with CRUD operations

- Implemented repoService for database interactions including count, list, get, create, update, and delete operations.
- Created RepoManager component for managing repositories with a user interface.
- Added forms for creating and editing repositories, including validation and error handling.
- Integrated API calls for fetching, creating, updating, and deleting repositories.
- Enhanced user experience with loading states and action feedback messages.
This commit is contained in:
2026-02-01 04:30:17 +00:00
parent f1ed790cab
commit 29cd473190
13 changed files with 855 additions and 116 deletions

File diff suppressed because one or more lines are too long

68
dist/assets/index-DDjGuoSe.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Siti Plugin Repo</title>
<script type="module" crossorigin src="/assets/index-Ciepux24.js"></script>
<script type="module" crossorigin src="/assets/index-DDjGuoSe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CFgb3VsA.css">
</head>

View File

@@ -18,6 +18,15 @@ import {
listLicensesByUser,
touchLicenseHostname
} from "./lib/licenseService.js";
import {
countRepos,
createRepo as createRepoRecord,
deleteRepo as deleteRepoRecord,
findRepoByOwnerRepo,
getRepoById,
listRepos,
updateRepo as updateRepoRecord
} from "./lib/repoService.js";
import { HOST, PATHS, PORT } from "./lib/config.js";
import { ensureSchema } from "./lib/schema.js";
import { authenticateUser, registerUser, adminCreateUser, listUsers, getUserById } from "./lib/userService.js";
@@ -29,6 +38,7 @@ app.use(express.json());
try {
await ensureSchema();
await seedLegacyRepos();
} catch (error) {
console.error("Kon database schema niet initialiseren:", error);
process.exit(1);
@@ -50,12 +60,18 @@ async function resolveRepoMetaFromRequest(body = {}) {
return null;
}
const provider = (body.provider || body.repoProvider || "github").toLowerCase();
const baseUrl = body.baseUrl || body.repoBaseUrl || (provider === "github" ? "https://github.com" : null);
const requestedProvider = (body.provider || body.repoProvider || "").toLowerCase() || null;
const requestedBaseUrl = body.baseUrl || body.repoBaseUrl || null;
const ownerRepo = `${owner}/${repository}`;
const existing = await findRepoByOwnerRepo(ownerRepo, requestedProvider || undefined);
const provider = existing?.provider || requestedProvider || "github";
const baseUrl =
existing?.baseUrl || requestedBaseUrl || (provider === "github" ? "https://github.com" : null);
const repoEntry = normalizeRepoInput(
{ repo: `${owner}/${repository}`, provider, baseUrl },
{ repo: `${owner}/${repository}`, provider, baseUrl }
{ repo: ownerRepo, provider, baseUrl },
{ repo: ownerRepo, provider, baseUrl }
);
if (!repoEntry) {
@@ -68,9 +84,10 @@ async function resolveRepoMetaFromRequest(body = {}) {
const releases = await fetchReleases(repoEntry).catch(() => []);
const latestRelease = releases[0];
const version = manifest?.version || latestRelease?.tag || latestRelease?.name || info.defaultBranch || null;
const pluginName = manifest?.plugin_name || info.name || repository;
const pluginName = manifest?.plugin_name || existing?.label || info.name || repository;
return {
repoId: existing?.id || null,
repoEntry,
pluginName,
version
@@ -80,6 +97,30 @@ async function resolveRepoMetaFromRequest(body = {}) {
}
}
async function seedLegacyRepos() {
try {
const repoCount = await countRepos();
if (repoCount > 0) {
return;
}
const legacy = await readRepos(PATHS.reposFile);
if (!Array.isArray(legacy) || legacy.length === 0) {
return;
}
for (const entry of legacy) {
try {
await createRepoRecord(entry);
} catch (error) {
if (error?.meta !== "DUPLICATE") {
console.warn("Kon legacy repo niet importeren:", error.message);
}
}
}
} catch (error) {
console.warn("Legacy repos importeren mislukt:", error.message);
}
}
app.post("/api/auth/register", async (req, res) => {
try {
const { username, name, email, password } = req.body || {};
@@ -158,34 +199,136 @@ app.post("/api/admin/users", requireAuth, requireAdmin, async (req, res) => {
}
});
function resolveOwnerRepoFromBody(body = {}) {
if (body.ownerRepo) {
return String(body.ownerRepo).trim();
}
const owner = typeof body.owner === "string" ? body.owner.trim() : "";
const repo = typeof body.repo === "string" ? body.repo.trim() : "";
if (owner && repo) {
return `${owner}/${repo}`;
}
return "";
}
app.get("/api/repos", requireAuth, requireAdmin, async (_req, res) => {
try {
const repos = await listRepos();
res.json({ count: repos.length, items: repos });
} catch (error) {
res.status(500).json({ error: "Kon repos niet laden." });
}
});
app.post("/api/repos", requireAuth, requireAdmin, async (req, res) => {
try {
const ownerRepo = resolveOwnerRepoFromBody(req.body);
if (!ownerRepo) {
return res.status(400).json({ error: "Geef een owner en repo op." });
}
const provider = (req.body?.provider || "github").toLowerCase();
const baseUrl = req.body?.baseUrl || null;
const label = req.body?.label || null;
const repo = await createRepoRecord({
provider,
ownerRepo,
baseUrl,
label
});
res.status(201).json({ repo });
} catch (error) {
if (error?.meta === "DUPLICATE") {
return res.status(409).json({ error: error.message });
}
res.status(500).json({ error: "Kon repo niet opslaan." });
}
});
app.patch("/api/repos/:id", requireAuth, requireAdmin, async (req, res) => {
try {
const repoId = Number(req.params.id);
if (Number.isNaN(repoId)) {
return res.status(400).json({ error: "Ongeldige repo id." });
}
const ownerRepo = resolveOwnerRepoFromBody(req.body);
const payload = {
provider: req.body?.provider,
ownerRepo: ownerRepo || undefined,
baseUrl: req.body?.baseUrl,
label: req.body?.label
};
const repo = await updateRepoRecord(repoId, payload);
res.json({ repo });
} catch (error) {
if (error?.meta === "NOT_FOUND") {
return res.status(404).json({ error: error.message });
}
if (error?.meta === "DUPLICATE") {
return res.status(409).json({ error: error.message });
}
res.status(500).json({ error: "Kon repo niet bijwerken." });
}
});
app.delete("/api/repos/:id", requireAuth, requireAdmin, async (req, res) => {
try {
const repoId = Number(req.params.id);
if (Number.isNaN(repoId)) {
return res.status(400).json({ error: "Ongeldige repo id." });
}
await deleteRepoRecord(repoId);
res.status(204).end();
} catch (error) {
if (error?.meta === "NOT_FOUND") {
return res.status(404).json({ error: error.message });
}
if (error?.meta === "IN_USE") {
return res.status(409).json({ error: error.message });
}
res.status(500).json({ error: "Kon repo niet verwijderen." });
}
});
app.get("/api/plugins", async (_req, res) => {
try {
const repos = await readRepos(PATHS.reposFile);
const repos = await listRepos();
const results = await Promise.all(
repos.map(async (repo) => {
const entry = {
provider: repo.provider,
repo: repo.ownerRepo,
baseUrl: repo.baseUrl
};
try {
const info = await fetchRepo(repo);
const manifest = await fetchManifest(repo, info.defaultBranch || info.default_branch);
return { ...info, manifest };
const info = await fetchRepo(entry);
const manifest = await fetchManifest(entry, info.defaultBranch || info.default_branch);
return {
...info,
manifest,
provider: repo.provider,
baseUrl: repo.baseUrl,
repoId: repo.id,
label: repo.label,
ownerRepo: repo.ownerRepo
};
} catch {
const parsed = parseRepoEntry(repo);
const parsed = parseRepoEntry(entry);
return {
fullName: parsed.ownerRepo,
name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo,
description: "Kon gegevens niet ophalen.",
repoUrl:
parsed.provider === "gitea"
? `${parsed.baseUrl.replace(/\/$/, "")}/${parsed.ownerRepo}`
: `https://github.com/${parsed.ownerRepo}`,
repoUrl: buildRepoUrl({ provider: repo.provider, baseUrl: repo.baseUrl, repo: repo.ownerRepo }),
stars: 0,
forks: 0,
issues: 0,
updatedAt: null,
topics: [],
manifest: null,
provider: parsed.provider,
ownerRepo: parsed.ownerRepo,
baseUrl: parsed.baseUrl
provider: repo.provider,
ownerRepo: repo.ownerRepo,
baseUrl: repo.baseUrl,
repoId: repo.id,
label: repo.label
};
}
})
@@ -223,9 +366,24 @@ function buildPluginDownloadEndpoint(ownerRepo, { provider, baseUrl, version } =
app.get("/api/plugins/:owner/:repo", async (req, res) => {
const ownerRepo = `${req.params.owner}/${req.params.repo}`;
try {
const provider = (req.query.provider || "github").toLowerCase();
const baseUrlQuery = req.query.baseUrl || (provider === "github" ? "https://github.com" : "");
const entry = provider === "github" ? ownerRepo : { provider, repo: ownerRepo, baseUrl: baseUrlQuery };
const repoIdQuery = Number(req.query.repoId);
let repoRecord = null;
if (!Number.isNaN(repoIdQuery)) {
repoRecord = await getRepoById(repoIdQuery);
}
if (!repoRecord) {
repoRecord = await findRepoByOwnerRepo(ownerRepo);
}
const provider = (req.query.provider || repoRecord?.provider || "github").toLowerCase();
const baseUrlQuery =
req.query.baseUrl || repoRecord?.baseUrl || (provider === "github" ? "https://github.com" : "");
const entry =
repoRecord && repoRecord.ownerRepo
? { provider: repoRecord.provider, repo: repoRecord.ownerRepo, baseUrl: repoRecord.baseUrl }
: provider === "github"
? ownerRepo
: { provider, repo: ownerRepo, baseUrl: baseUrlQuery };
const normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery });
const info = await fetchRepo(entry);
@@ -289,6 +447,8 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
res.json({
...info,
repoId: repoRecord?.id || null,
label: repoRecord?.label || null,
version: resolvedVersion,
versionSource,
manifest,
@@ -379,6 +539,10 @@ app.get("/api/licenses", requireAuth, async (req, res) => {
app.post("/api/licenses", requireAuth, async (req, res) => {
try {
const body = req.body || {};
const repoId = body.repoId ? Number(body.repoId) : null;
if (body.repoId && Number.isNaN(repoId)) {
return res.status(400).json({ error: "Ongeldig repo id." });
}
const repoInput =
typeof body.repo === "object"
? body.repo
@@ -392,7 +556,7 @@ app.post("/api/licenses", requireAuth, async (req, res) => {
baseUrl: body.baseUrl
}) || null;
if (!repoEntry) {
if (!repoId && !repoEntry) {
return res.status(400).json({ error: "Kies een plugin om de licentie aan te koppelen." });
}
@@ -418,7 +582,8 @@ app.post("/api/licenses", requireAuth, async (req, res) => {
const payload = await createLicense(ownerUserId, {
label: body.label?.trim(),
note: body.note?.trim(),
repo: repoEntry
repo: repoEntry,
repoId: repoId || undefined
});
res.status(201).json(payload);
} catch (error) {
@@ -443,6 +608,7 @@ app.post("/api/licenses/verify", async (req, res) => {
pluginVersion: repoMeta.version,
repoFullName: repoMeta.repoEntry.repo,
repoUrl: buildRepoUrl(repoMeta.repoEntry),
repoId: repoMeta.repoId || null,
repo: repoMeta.repoEntry
}
}

View File

@@ -1,6 +1,7 @@
import crypto from "crypto";
import db from "./db.js";
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
import { getRepoById } from "./repoService.js";
function toIso(value) {
return value ? new Date(value).toISOString() : null;
@@ -31,13 +32,30 @@ export async function findLicenseByKey(key) {
return rows[0] || null;
}
export async function createLicense(userId, { label, note, repo }) {
const repoEntry = normalizeRepoInput(repo);
export async function createLicense(userId, { label, note, repo, repoId }) {
let repoEntry = null;
let repoRow = null;
if (repoId) {
repoRow = await getRepoById(repoId);
if (!repoRow) {
const error = new Error("Onbekende repository.");
error.meta = "INVALID_REPO";
throw error;
}
repoEntry = {
repo: repoRow.ownerRepo,
provider: repoRow.provider,
baseUrl: repoRow.baseUrl
};
} else {
repoEntry = normalizeRepoInput(repo);
if (!repoEntry) {
const error = new Error("Ongeldige plugin referentie.");
error.meta = "INVALID_REPO";
throw error;
}
}
let licenseId = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
@@ -47,9 +65,9 @@ export async function createLicense(userId, { label, note, repo }) {
await db.query(
`INSERT INTO licenses (
id, user_id, license_key, label, note,
repo_provider, repo_name, repo_base_url,
repo_provider, repo_name, repo_base_url, repo_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
id,
userId,
@@ -58,7 +76,8 @@ export async function createLicense(userId, { label, note, repo }) {
note || null,
repoEntry.provider || "github",
repoEntry.repo,
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null)
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null),
repoRow?.id || null
]
);
licenseId = id;
@@ -80,7 +99,20 @@ export async function createLicense(userId, { label, note, repo }) {
export async function buildLicensePayload(row) {
if (!row) return null;
const repoEntry = normalizeRepoInput({
let repoRow = null;
if (row.repo_id) {
repoRow = await getRepoById(row.repo_id);
}
const repoEntry =
repoRow && repoRow.ownerRepo
? {
repo: repoRow.ownerRepo,
provider: repoRow.provider,
baseUrl: repoRow.baseUrl
}
: normalizeRepoInput({
repo: row.repo_name,
provider: row.repo_provider,
baseUrl: row.repo_base_url
@@ -112,6 +144,8 @@ export async function buildLicensePayload(row) {
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: null,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: row.label,
pluginVersion: row.last_used_version || null,
lastUsedVersion: row.last_used_version,
@@ -134,7 +168,9 @@ export async function buildLicensePayload(row) {
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: info.fullName,
repoUrl: info.repoUrl,
pluginName: manifest?.plugin_name || info.name || row.label,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: manifest?.plugin_name || repoRow?.label || info.name || row.label,
pluginVersion: manifest?.version || row.last_used_version || null,
lastUsedVersion: row.last_used_version,
repo: repoEntry,
@@ -151,9 +187,14 @@ export async function buildLicensePayload(row) {
lastVersionCheckAt: toIso(row.last_version_check_at),
primaryHostname: row.primary_hostname,
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: repoEntry?.baseUrl ? `${repoEntry.baseUrl.replace(/\/$/, "")}/${row.repo_name}` : null,
pluginName: row.label,
repoFullName: repoRow?.ownerRepo || row.repo_name,
repoUrl:
(repoRow?.baseUrl || repoEntry?.baseUrl)
? `${(repoRow?.baseUrl || repoEntry.baseUrl).replace(/\/$/, "")}/${repoRow?.ownerRepo || row.repo_name}`
: null,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: repoRow?.label || row.label,
pluginVersion: row.last_used_version || null,
lastUsedVersion: row.last_used_version,
repo: repoEntry,

141
server/lib/repoService.js Normal file
View File

@@ -0,0 +1,141 @@
import db from "./db.js";
function toIso(value) {
return value ? new Date(value).toISOString() : null;
}
function serializeRepo(row) {
if (!row) return null;
return {
id: row.id,
provider: row.provider,
ownerRepo: row.owner_repo,
baseUrl: row.base_url,
label: row.label,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at)
};
}
export async function countRepos() {
const [[{ count }]] = await db.query(`SELECT COUNT(*) AS count FROM repos`);
return Number(count) || 0;
}
export async function listRepos() {
const [rows] = await db.query(`SELECT * FROM repos ORDER BY created_at ASC, id ASC`);
return rows.map(serializeRepo);
}
export async function getRepoById(id) {
if (!id) return null;
const [rows] = await db.query(`SELECT * FROM repos WHERE id = ? LIMIT 1`, [id]);
return serializeRepo(rows[0]);
}
export async function findRepoByOwnerRepo(ownerRepo, provider = null) {
if (!ownerRepo) return null;
if (provider) {
const [rows] = await db.query(
`SELECT * FROM repos WHERE owner_repo = ? AND provider = ? LIMIT 1`,
[ownerRepo, provider.toLowerCase()]
);
return serializeRepo(rows[0]);
}
const [rows] = await db.query(`SELECT * FROM repos WHERE owner_repo = ? LIMIT 1`, [ownerRepo]);
return serializeRepo(rows[0]);
}
export async function createRepo({ provider = "github", ownerRepo, baseUrl = null, label = null }) {
if (!ownerRepo) {
const error = new Error("Repo ontbreekt.");
error.meta = "INVALID_REPO";
throw error;
}
const normalizedProvider = String(provider || "github").toLowerCase();
const trimmedOwnerRepo = ownerRepo.trim();
const sanitizedBaseUrl = baseUrl ? baseUrl.trim() : null;
const safeLabel = label ? label.trim() : null;
try {
const [result] = await db.query(
`INSERT INTO repos (provider, owner_repo, base_url, label)
VALUES (?, ?, ?, ?)`,
[normalizedProvider, trimmedOwnerRepo, sanitizedBaseUrl, safeLabel]
);
return await getRepoById(result.insertId);
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
const dup = new Error("Deze repository bestaat al.");
dup.meta = "DUPLICATE";
throw dup;
}
throw error;
}
}
export async function updateRepo(id, { provider, ownerRepo, baseUrl, label }) {
const repo = await getRepoById(id);
if (!repo) {
const error = new Error("Repo niet gevonden.");
error.meta = "NOT_FOUND";
throw error;
}
const fields = [];
const values = [];
if (provider) {
fields.push("provider = ?");
values.push(String(provider).toLowerCase());
}
if (ownerRepo) {
fields.push("owner_repo = ?");
values.push(ownerRepo.trim());
}
if (typeof baseUrl !== "undefined") {
fields.push("base_url = ?");
values.push(baseUrl ? baseUrl.trim() : null);
}
if (typeof label !== "undefined") {
fields.push("label = ?");
values.push(label ? label.trim() : null);
}
if (fields.length === 0) {
return repo;
}
try {
await db.query(`UPDATE repos SET ${fields.join(", ")}, updated_at = NOW() WHERE id = ?`, [...values, id]);
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
const dup = new Error("Deze repository bestaat al.");
dup.meta = "DUPLICATE";
throw dup;
}
throw error;
}
return await getRepoById(id);
}
export async function deleteRepo(id) {
const repo = await getRepoById(id);
if (!repo) {
const error = new Error("Repo niet gevonden.");
error.meta = "NOT_FOUND";
throw error;
}
const [[{ count }]] = await db.query(`SELECT COUNT(*) AS count FROM licenses WHERE repo_id = ?`, [id]);
if (Number(count) > 0) {
const error = new Error("Repo is gekoppeld aan bestaande licenties.");
error.meta = "IN_USE";
throw error;
}
await db.query(`DELETE FROM repos WHERE id = ? LIMIT 1`, [id]);
return repo;
}

View File

@@ -37,6 +37,7 @@ async function createLicensesTable() {
repo_provider VARCHAR(32) NOT NULL,
repo_name VARCHAR(255) NOT NULL,
repo_base_url VARCHAR(255),
repo_id INT UNSIGNED NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_version_check_at DATETIME NULL,
@@ -52,6 +53,21 @@ async function createLicensesTable() {
"last_used_version",
"last_used_version VARCHAR(64) NULL AFTER last_version_check_at"
);
await ensureColumn(
"licenses",
"repo_id",
"repo_id INT UNSIGNED NULL AFTER repo_base_url"
);
await db
.query(
`ALTER TABLE licenses
ADD CONSTRAINT fk_licenses_repo_id
FOREIGN KEY (repo_id) REFERENCES repos(id)
ON DELETE SET NULL`
)
.catch(() => {});
}
async function createLicenseHostnamesTable() {
@@ -70,8 +86,24 @@ async function createLicenseHostnamesTable() {
`);
}
async function createReposTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS repos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'github',
owner_repo VARCHAR(255) NOT NULL,
base_url VARCHAR(255),
label VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_repo_provider (provider, owner_repo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
export async function ensureSchema() {
await createUsersTable();
await createReposTable();
await createLicensesTable();
await createLicenseHostnamesTable();
}

View File

@@ -3,6 +3,7 @@ import SiteNav from "./components/SiteNav.jsx";
import Home from "./pages/Home.jsx";
import PluginDetail from "./pages/PluginDetail.jsx";
import LicenseManager from "./pages/LicenseManager.jsx";
import RepoManager from "./pages/RepoManager.jsx";
import "./App.css";
export default function App() {
@@ -13,6 +14,7 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
<Route path="/licenses" element={<LicenseManager />} />
<Route path="/repos" element={<RepoManager />} />
</Routes>
</div>
);

View File

@@ -11,6 +11,7 @@ export default function SiteNav() {
<div className="nav-links">
<NavLink to="/" end className={linkClass}>Plugins</NavLink>
<NavLink to="/licenses" className={linkClass}>Licenties</NavLink>
{user?.isAdmin && <NavLink to="/repos" className={linkClass}>Repos</NavLink>}
</div>
<div className="nav-user">
{user ? (

View File

@@ -47,7 +47,7 @@ export default function Home() {
<div className="state">Geen repositories gevonden.</div>
)}
{plugins.map((plugin) => {
const displayName = plugin.manifest?.plugin_name || plugin.name;
const displayName = plugin.label || plugin.manifest?.plugin_name || plugin.name;
const description = plugin.manifest?.description || plugin.description;
const searchParams = new URLSearchParams();
if (plugin.provider) {
@@ -56,6 +56,9 @@ export default function Home() {
if (plugin.baseUrl) {
searchParams.set("baseUrl", plugin.baseUrl);
}
if (plugin.repoId) {
searchParams.set("repoId", String(plugin.repoId));
}
const repoLabel = plugin.provider === "gitea" ? "Gitea" : "GitHub";
const detailUrl =
`/plugin/${plugin.fullName}` + (searchParams.toString() ? `?${searchParams.toString()}` : "");

View File

@@ -191,16 +191,21 @@ export default function LicenseManager() {
const payload = {
label:
label.trim() ||
selectedPlugin.label ||
selectedPlugin.manifest?.plugin_name ||
selectedPlugin.name ||
selectedPlugin.fullName,
note: note.trim() || undefined,
repo: {
note: note.trim() || undefined
};
if (selectedPlugin.repoId) {
payload.repoId = selectedPlugin.repoId;
} else {
payload.repo = {
repo: selectedPlugin.ownerRepo || selectedPlugin.fullName,
provider: selectedPlugin.provider || "github",
baseUrl: selectedPlugin.baseUrl
}
};
}
const requestBody = {
...payload,
userId: user?.isAdmin ? Number(selectedOwnerId || user.id) : undefined
@@ -371,9 +376,14 @@ export default function LicenseManager() {
>
{plugins.map((plugin) => {
const id = plugin.ownerRepo || plugin.fullName;
const optionLabel =
plugin.label ||
plugin.manifest?.plugin_name ||
plugin.name ||
plugin.fullName;
return (
<option key={id} value={id}>
{plugin.manifest?.plugin_name || plugin.name} ({plugin.fullName})
{optionLabel} ({plugin.ownerRepo || plugin.fullName})
</option>
);
})}

View File

@@ -6,6 +6,7 @@ export default function PluginDetail() {
const [searchParams] = useSearchParams();
const provider = (searchParams.get("provider") || "github").toLowerCase();
const baseUrl = searchParams.get("baseUrl") || "";
const repoId = searchParams.get("repoId") || "";
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -20,6 +21,9 @@ export default function PluginDetail() {
if (baseUrl) {
query.set("baseUrl", baseUrl);
}
if (repoId) {
query.set("repoId", repoId);
}
const search = query.toString();
const response = await fetch(
`/api/plugins/${owner}/${repo}${search.length > 0 ? `?${search}` : ""}`
@@ -40,7 +44,7 @@ export default function PluginDetail() {
}, [owner, repo, provider, baseUrl]);
const manifest = data?.manifest;
const displayName = manifest?.plugin_name || data?.name || repo;
const displayName = data?.label || manifest?.plugin_name || data?.name || repo;
const description = manifest?.description || data?.description;
const author = manifest?.author || "-";
const version = manifest?.version || "-";

339
src/pages/RepoManager.jsx Normal file
View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "../context/AuthContext.jsx";
const PROVIDERS = [
{ value: "github", label: "GitHub" },
{ value: "gitea", label: "Gitea" }
];
export default function RepoManager() {
const { user, authFetch } = useAuth();
const isAdmin = Boolean(user?.isAdmin);
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [form, setForm] = useState({
ownerRepo: "",
provider: "github",
baseUrl: "",
label: ""
});
const [saving, setSaving] = useState(false);
const [actionState, setActionState] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({
ownerRepo: "",
provider: "github",
baseUrl: "",
label: ""
});
const [deletingId, setDeletingId] = useState(null);
const canManage = Boolean(user && isAdmin);
const loadRepos = useCallback(async () => {
if (!canManage) {
setRepos([]);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await authFetch("/api/repos");
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Kon repos niet laden.");
}
setRepos(data.items || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [authFetch, canManage]);
useEffect(() => {
loadRepos();
}, [loadRepos]);
const sortedRepos = useMemo(() => {
return [...repos].sort((a, b) => a.ownerRepo.localeCompare(b.ownerRepo));
}, [repos]);
if (!user) {
return (
<div className="page">
<div className="state">Log in om repositories te beheren.</div>
</div>
);
}
if (!isAdmin) {
return (
<div className="page">
<div className="state error">Alleen admins kunnen repositories beheren.</div>
</div>
);
}
async function handleCreate(event) {
event.preventDefault();
if (!form.ownerRepo.trim()) {
setActionState({ variant: "error", message: "Vul het owner/repo veld in." });
return;
}
setSaving(true);
setActionState(null);
try {
const response = await authFetch("/api/repos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Repo aanmaken mislukt.");
}
setRepos((prev) => [...prev, data.repo]);
setForm({
ownerRepo: "",
provider: "github",
baseUrl: "",
label: ""
});
setActionState({ variant: "success", message: "Repo toegevoegd." });
} catch (err) {
setActionState({ variant: "error", message: err.message });
} finally {
setSaving(false);
}
}
function startEdit(repo) {
setEditingId(repo.id);
setEditForm({
ownerRepo: repo.ownerRepo || "",
provider: repo.provider || "github",
baseUrl: repo.baseUrl || "",
label: repo.label || ""
});
setActionState(null);
}
async function handleUpdate(event) {
event.preventDefault();
if (!editingId) return;
try {
const response = await authFetch(`/api/repos/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Repo bijwerken mislukt.");
}
setRepos((prev) => prev.map((repo) => (repo.id === editingId ? data.repo : repo)));
setEditingId(null);
setActionState({ variant: "success", message: "Repo bijgewerkt." });
} catch (err) {
setActionState({ variant: "error", message: err.message });
}
}
async function handleDelete(id) {
if (!window.confirm("Weet je zeker dat je deze repo wilt verwijderen?")) {
return;
}
setDeletingId(id);
setActionState(null);
try {
const response = await authFetch(`/api/repos/${id}`, {
method: "DELETE"
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Verwijderen mislukt.");
}
setRepos((prev) => prev.filter((repo) => repo.id !== id));
if (editingId === id) {
setEditingId(null);
}
setActionState({ variant: "success", message: "Repo verwijderd." });
} catch (err) {
setActionState({ variant: "error", message: err.message });
} finally {
setDeletingId(null);
}
}
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">Repository beheer</p>
<h1>Repos</h1>
<p className="subtitle">Voeg nieuwe plugin repositories toe of werk bestaande entries bij.</p>
</div>
</header>
<section className="license-forms">
<article className="card">
<h2>Nieuwe repo</h2>
<form className="form-grid" onSubmit={handleCreate}>
<label className="field">
<span>Owner/Repo</span>
<input
value={form.ownerRepo}
onChange={(event) => setForm((prev) => ({ ...prev, ownerRepo: event.target.value }))}
placeholder="bijv. roberto/siti-ai-product-content-generator"
/>
</label>
<label className="field">
<span>Provider</span>
<select
value={form.provider}
onChange={(event) => setForm((prev) => ({ ...prev, provider: event.target.value }))}
>
{PROVIDERS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="field">
<span>Base URL (optioneel)</span>
<input
value={form.baseUrl}
onChange={(event) => setForm((prev) => ({ ...prev, baseUrl: event.target.value }))}
placeholder="Alleen nodig voor Gitea"
/>
</label>
<label className="field">
<span>Label (optioneel)</span>
<input
value={form.label}
onChange={(event) => setForm((prev) => ({ ...prev, label: event.target.value }))}
placeholder="Weergavenaam"
/>
</label>
<button className="cta" type="submit" disabled={saving}>
{saving ? "Opslaan…" : "Repo toevoegen"}
</button>
</form>
{actionState && <div className={`state inline ${actionState.variant === "error" ? "error" : "success"}`}>{actionState.message}</div>}
</article>
</section>
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!loading && !error && (
<section className="grid">
{sortedRepos.length === 0 && <div className="state">Nog geen repositories toegevoegd.</div>}
{sortedRepos.map((repo) => {
const isEditing = editingId === repo.id;
return (
<article className="card" key={repo.id}>
<div className="card-header">
<h3>{repo.label || repo.ownerRepo}</h3>
<span className="pill">#{repo.id}</span>
</div>
{!isEditing && (
<>
<p>{repo.ownerRepo}</p>
<div className="meta">
<span>{repo.provider}</span>
<span>{repo.baseUrl || (repo.provider === "github" ? "github.com" : "n.v.t.")}</span>
</div>
<div className="actions">
<button className="ghost" type="button" onClick={() => startEdit(repo)}>
Bewerk
</button>
<button
className="ghost"
type="button"
onClick={() => handleDelete(repo.id)}
disabled={deletingId === repo.id}
>
{deletingId === repo.id ? "Verwijderen…" : "Verwijder"}
</button>
</div>
</>
)}
{isEditing && (
<form className="form-grid" onSubmit={handleUpdate}>
<label className="field">
<span>Owner/Repo</span>
<input
value={editForm.ownerRepo}
onChange={(event) =>
setEditForm((prev) => ({ ...prev, ownerRepo: event.target.value }))
}
/>
</label>
<label className="field">
<span>Provider</span>
<select
value={editForm.provider}
onChange={(event) =>
setEditForm((prev) => ({ ...prev, provider: event.target.value }))
}
>
{PROVIDERS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="field">
<span>Base URL</span>
<input
value={editForm.baseUrl}
onChange={(event) =>
setEditForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
/>
</label>
<label className="field">
<span>Label</span>
<input
value={editForm.label}
onChange={(event) =>
setEditForm((prev) => ({ ...prev, label: event.target.value }))
}
/>
</label>
<div className="actions">
<button className="cta" type="submit">
Opslaan
</button>
<button
className="ghost"
type="button"
onClick={() => {
setEditingId(null);
setEditForm({
ownerRepo: "",
provider: "github",
baseUrl: "",
label: ""
});
}}
>
Annuleer
</button>
</div>
</form>
)}
</article>
);
})}
</section>
)}
</div>
);
}