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:
68
dist/assets/index-Ciepux24.js
vendored
68
dist/assets/index-Ciepux24.js
vendored
File diff suppressed because one or more lines are too long
68
dist/assets/index-DDjGuoSe.js
vendored
Normal file
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
2
dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Siti Plugin Repo</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CFgb3VsA.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
210
server/index.js
210
server/index.js
@@ -18,6 +18,15 @@ import {
|
|||||||
listLicensesByUser,
|
listLicensesByUser,
|
||||||
touchLicenseHostname
|
touchLicenseHostname
|
||||||
} from "./lib/licenseService.js";
|
} 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 { HOST, PATHS, PORT } from "./lib/config.js";
|
||||||
import { ensureSchema } from "./lib/schema.js";
|
import { ensureSchema } from "./lib/schema.js";
|
||||||
import { authenticateUser, registerUser, adminCreateUser, listUsers, getUserById } from "./lib/userService.js";
|
import { authenticateUser, registerUser, adminCreateUser, listUsers, getUserById } from "./lib/userService.js";
|
||||||
@@ -29,6 +38,7 @@ app.use(express.json());
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
|
await seedLegacyRepos();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Kon database schema niet initialiseren:", error);
|
console.error("Kon database schema niet initialiseren:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -50,12 +60,18 @@ async function resolveRepoMetaFromRequest(body = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = (body.provider || body.repoProvider || "github").toLowerCase();
|
const requestedProvider = (body.provider || body.repoProvider || "").toLowerCase() || null;
|
||||||
const baseUrl = body.baseUrl || body.repoBaseUrl || (provider === "github" ? "https://github.com" : 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(
|
const repoEntry = normalizeRepoInput(
|
||||||
{ repo: `${owner}/${repository}`, provider, baseUrl },
|
{ repo: ownerRepo, provider, baseUrl },
|
||||||
{ repo: `${owner}/${repository}`, provider, baseUrl }
|
{ repo: ownerRepo, provider, baseUrl }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!repoEntry) {
|
if (!repoEntry) {
|
||||||
@@ -68,9 +84,10 @@ async function resolveRepoMetaFromRequest(body = {}) {
|
|||||||
const releases = await fetchReleases(repoEntry).catch(() => []);
|
const releases = await fetchReleases(repoEntry).catch(() => []);
|
||||||
const latestRelease = releases[0];
|
const latestRelease = releases[0];
|
||||||
const version = manifest?.version || latestRelease?.tag || latestRelease?.name || info.defaultBranch || null;
|
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 {
|
return {
|
||||||
|
repoId: existing?.id || null,
|
||||||
repoEntry,
|
repoEntry,
|
||||||
pluginName,
|
pluginName,
|
||||||
version
|
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) => {
|
app.post("/api/auth/register", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, name, email, password } = req.body || {};
|
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) => {
|
app.get("/api/plugins", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const repos = await readRepos(PATHS.reposFile);
|
const repos = await listRepos();
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
repos.map(async (repo) => {
|
repos.map(async (repo) => {
|
||||||
|
const entry = {
|
||||||
|
provider: repo.provider,
|
||||||
|
repo: repo.ownerRepo,
|
||||||
|
baseUrl: repo.baseUrl
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const info = await fetchRepo(repo);
|
const info = await fetchRepo(entry);
|
||||||
const manifest = await fetchManifest(repo, info.defaultBranch || info.default_branch);
|
const manifest = await fetchManifest(entry, info.defaultBranch || info.default_branch);
|
||||||
return { ...info, manifest };
|
return {
|
||||||
|
...info,
|
||||||
|
manifest,
|
||||||
|
provider: repo.provider,
|
||||||
|
baseUrl: repo.baseUrl,
|
||||||
|
repoId: repo.id,
|
||||||
|
label: repo.label,
|
||||||
|
ownerRepo: repo.ownerRepo
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const parsed = parseRepoEntry(repo);
|
const parsed = parseRepoEntry(entry);
|
||||||
return {
|
return {
|
||||||
fullName: parsed.ownerRepo,
|
fullName: parsed.ownerRepo,
|
||||||
name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo,
|
name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo,
|
||||||
description: "Kon gegevens niet ophalen.",
|
description: "Kon gegevens niet ophalen.",
|
||||||
repoUrl:
|
repoUrl: buildRepoUrl({ provider: repo.provider, baseUrl: repo.baseUrl, repo: repo.ownerRepo }),
|
||||||
parsed.provider === "gitea"
|
|
||||||
? `${parsed.baseUrl.replace(/\/$/, "")}/${parsed.ownerRepo}`
|
|
||||||
: `https://github.com/${parsed.ownerRepo}`,
|
|
||||||
stars: 0,
|
stars: 0,
|
||||||
forks: 0,
|
forks: 0,
|
||||||
issues: 0,
|
issues: 0,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
topics: [],
|
topics: [],
|
||||||
manifest: null,
|
manifest: null,
|
||||||
provider: parsed.provider,
|
provider: repo.provider,
|
||||||
ownerRepo: parsed.ownerRepo,
|
ownerRepo: repo.ownerRepo,
|
||||||
baseUrl: parsed.baseUrl
|
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) => {
|
app.get("/api/plugins/:owner/:repo", async (req, res) => {
|
||||||
const ownerRepo = `${req.params.owner}/${req.params.repo}`;
|
const ownerRepo = `${req.params.owner}/${req.params.repo}`;
|
||||||
try {
|
try {
|
||||||
const provider = (req.query.provider || "github").toLowerCase();
|
const repoIdQuery = Number(req.query.repoId);
|
||||||
const baseUrlQuery = req.query.baseUrl || (provider === "github" ? "https://github.com" : "");
|
let repoRecord = null;
|
||||||
const entry = provider === "github" ? ownerRepo : { provider, repo: ownerRepo, baseUrl: baseUrlQuery };
|
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 normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery });
|
||||||
|
|
||||||
const info = await fetchRepo(entry);
|
const info = await fetchRepo(entry);
|
||||||
@@ -289,6 +447,8 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...info,
|
...info,
|
||||||
|
repoId: repoRecord?.id || null,
|
||||||
|
label: repoRecord?.label || null,
|
||||||
version: resolvedVersion,
|
version: resolvedVersion,
|
||||||
versionSource,
|
versionSource,
|
||||||
manifest,
|
manifest,
|
||||||
@@ -379,6 +539,10 @@ app.get("/api/licenses", requireAuth, async (req, res) => {
|
|||||||
app.post("/api/licenses", requireAuth, async (req, res) => {
|
app.post("/api/licenses", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body || {};
|
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 =
|
const repoInput =
|
||||||
typeof body.repo === "object"
|
typeof body.repo === "object"
|
||||||
? body.repo
|
? body.repo
|
||||||
@@ -392,7 +556,7 @@ app.post("/api/licenses", requireAuth, async (req, res) => {
|
|||||||
baseUrl: body.baseUrl
|
baseUrl: body.baseUrl
|
||||||
}) || null;
|
}) || null;
|
||||||
|
|
||||||
if (!repoEntry) {
|
if (!repoId && !repoEntry) {
|
||||||
return res.status(400).json({ error: "Kies een plugin om de licentie aan te koppelen." });
|
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, {
|
const payload = await createLicense(ownerUserId, {
|
||||||
label: body.label?.trim(),
|
label: body.label?.trim(),
|
||||||
note: body.note?.trim(),
|
note: body.note?.trim(),
|
||||||
repo: repoEntry
|
repo: repoEntry,
|
||||||
|
repoId: repoId || undefined
|
||||||
});
|
});
|
||||||
res.status(201).json(payload);
|
res.status(201).json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -443,6 +608,7 @@ app.post("/api/licenses/verify", async (req, res) => {
|
|||||||
pluginVersion: repoMeta.version,
|
pluginVersion: repoMeta.version,
|
||||||
repoFullName: repoMeta.repoEntry.repo,
|
repoFullName: repoMeta.repoEntry.repo,
|
||||||
repoUrl: buildRepoUrl(repoMeta.repoEntry),
|
repoUrl: buildRepoUrl(repoMeta.repoEntry),
|
||||||
|
repoId: repoMeta.repoId || null,
|
||||||
repo: repoMeta.repoEntry
|
repo: repoMeta.repoEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
|
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
|
||||||
|
import { getRepoById } from "./repoService.js";
|
||||||
|
|
||||||
function toIso(value) {
|
function toIso(value) {
|
||||||
return value ? new Date(value).toISOString() : null;
|
return value ? new Date(value).toISOString() : null;
|
||||||
@@ -31,13 +32,30 @@ export async function findLicenseByKey(key) {
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLicense(userId, { label, note, repo }) {
|
export async function createLicense(userId, { label, note, repo, repoId }) {
|
||||||
const repoEntry = normalizeRepoInput(repo);
|
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) {
|
if (!repoEntry) {
|
||||||
const error = new Error("Ongeldige plugin referentie.");
|
const error = new Error("Ongeldige plugin referentie.");
|
||||||
error.meta = "INVALID_REPO";
|
error.meta = "INVALID_REPO";
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let licenseId = null;
|
let licenseId = null;
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
@@ -47,9 +65,9 @@ export async function createLicense(userId, { label, note, repo }) {
|
|||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO licenses (
|
`INSERT INTO licenses (
|
||||||
id, user_id, license_key, label, note,
|
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
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -58,7 +76,8 @@ export async function createLicense(userId, { label, note, repo }) {
|
|||||||
note || null,
|
note || null,
|
||||||
repoEntry.provider || "github",
|
repoEntry.provider || "github",
|
||||||
repoEntry.repo,
|
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;
|
licenseId = id;
|
||||||
@@ -80,7 +99,20 @@ export async function createLicense(userId, { label, note, repo }) {
|
|||||||
|
|
||||||
export async function buildLicensePayload(row) {
|
export async function buildLicensePayload(row) {
|
||||||
if (!row) return null;
|
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,
|
repo: row.repo_name,
|
||||||
provider: row.repo_provider,
|
provider: row.repo_provider,
|
||||||
baseUrl: row.repo_base_url
|
baseUrl: row.repo_base_url
|
||||||
@@ -112,6 +144,8 @@ export async function buildLicensePayload(row) {
|
|||||||
primaryHostnameNormalized: row.primary_hostname_normalized,
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
repoFullName: row.repo_name,
|
repoFullName: row.repo_name,
|
||||||
repoUrl: null,
|
repoUrl: null,
|
||||||
|
repoId: repoRow?.id || null,
|
||||||
|
repoLabel: repoRow?.label || null,
|
||||||
pluginName: row.label,
|
pluginName: row.label,
|
||||||
pluginVersion: row.last_used_version || null,
|
pluginVersion: row.last_used_version || null,
|
||||||
lastUsedVersion: row.last_used_version,
|
lastUsedVersion: row.last_used_version,
|
||||||
@@ -134,7 +168,9 @@ export async function buildLicensePayload(row) {
|
|||||||
primaryHostnameNormalized: row.primary_hostname_normalized,
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
repoFullName: info.fullName,
|
repoFullName: info.fullName,
|
||||||
repoUrl: info.repoUrl,
|
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,
|
pluginVersion: manifest?.version || row.last_used_version || null,
|
||||||
lastUsedVersion: row.last_used_version,
|
lastUsedVersion: row.last_used_version,
|
||||||
repo: repoEntry,
|
repo: repoEntry,
|
||||||
@@ -151,9 +187,14 @@ export async function buildLicensePayload(row) {
|
|||||||
lastVersionCheckAt: toIso(row.last_version_check_at),
|
lastVersionCheckAt: toIso(row.last_version_check_at),
|
||||||
primaryHostname: row.primary_hostname,
|
primaryHostname: row.primary_hostname,
|
||||||
primaryHostnameNormalized: row.primary_hostname_normalized,
|
primaryHostnameNormalized: row.primary_hostname_normalized,
|
||||||
repoFullName: row.repo_name,
|
repoFullName: repoRow?.ownerRepo || row.repo_name,
|
||||||
repoUrl: repoEntry?.baseUrl ? `${repoEntry.baseUrl.replace(/\/$/, "")}/${row.repo_name}` : null,
|
repoUrl:
|
||||||
pluginName: row.label,
|
(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,
|
pluginVersion: row.last_used_version || null,
|
||||||
lastUsedVersion: row.last_used_version,
|
lastUsedVersion: row.last_used_version,
|
||||||
repo: repoEntry,
|
repo: repoEntry,
|
||||||
|
|||||||
141
server/lib/repoService.js
Normal file
141
server/lib/repoService.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ async function createLicensesTable() {
|
|||||||
repo_provider VARCHAR(32) NOT NULL,
|
repo_provider VARCHAR(32) NOT NULL,
|
||||||
repo_name VARCHAR(255) NOT NULL,
|
repo_name VARCHAR(255) NOT NULL,
|
||||||
repo_base_url VARCHAR(255),
|
repo_base_url VARCHAR(255),
|
||||||
|
repo_id INT UNSIGNED NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
last_version_check_at DATETIME NULL,
|
last_version_check_at DATETIME NULL,
|
||||||
@@ -52,6 +53,21 @@ async function createLicensesTable() {
|
|||||||
"last_used_version",
|
"last_used_version",
|
||||||
"last_used_version VARCHAR(64) NULL AFTER last_version_check_at"
|
"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() {
|
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() {
|
export async function ensureSchema() {
|
||||||
await createUsersTable();
|
await createUsersTable();
|
||||||
|
await createReposTable();
|
||||||
await createLicensesTable();
|
await createLicensesTable();
|
||||||
await createLicenseHostnamesTable();
|
await createLicenseHostnamesTable();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SiteNav from "./components/SiteNav.jsx";
|
|||||||
import Home from "./pages/Home.jsx";
|
import Home from "./pages/Home.jsx";
|
||||||
import PluginDetail from "./pages/PluginDetail.jsx";
|
import PluginDetail from "./pages/PluginDetail.jsx";
|
||||||
import LicenseManager from "./pages/LicenseManager.jsx";
|
import LicenseManager from "./pages/LicenseManager.jsx";
|
||||||
|
import RepoManager from "./pages/RepoManager.jsx";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -13,6 +14,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
|
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
|
||||||
<Route path="/licenses" element={<LicenseManager />} />
|
<Route path="/licenses" element={<LicenseManager />} />
|
||||||
|
<Route path="/repos" element={<RepoManager />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function SiteNav() {
|
|||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/" end className={linkClass}>Plugins</NavLink>
|
<NavLink to="/" end className={linkClass}>Plugins</NavLink>
|
||||||
<NavLink to="/licenses" className={linkClass}>Licenties</NavLink>
|
<NavLink to="/licenses" className={linkClass}>Licenties</NavLink>
|
||||||
|
{user?.isAdmin && <NavLink to="/repos" className={linkClass}>Repos</NavLink>}
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-user">
|
<div className="nav-user">
|
||||||
{user ? (
|
{user ? (
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function Home() {
|
|||||||
<div className="state">Geen repositories gevonden.</div>
|
<div className="state">Geen repositories gevonden.</div>
|
||||||
)}
|
)}
|
||||||
{plugins.map((plugin) => {
|
{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 description = plugin.manifest?.description || plugin.description;
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (plugin.provider) {
|
if (plugin.provider) {
|
||||||
@@ -56,6 +56,9 @@ export default function Home() {
|
|||||||
if (plugin.baseUrl) {
|
if (plugin.baseUrl) {
|
||||||
searchParams.set("baseUrl", plugin.baseUrl);
|
searchParams.set("baseUrl", plugin.baseUrl);
|
||||||
}
|
}
|
||||||
|
if (plugin.repoId) {
|
||||||
|
searchParams.set("repoId", String(plugin.repoId));
|
||||||
|
}
|
||||||
const repoLabel = plugin.provider === "gitea" ? "Gitea" : "GitHub";
|
const repoLabel = plugin.provider === "gitea" ? "Gitea" : "GitHub";
|
||||||
const detailUrl =
|
const detailUrl =
|
||||||
`/plugin/${plugin.fullName}` + (searchParams.toString() ? `?${searchParams.toString()}` : "");
|
`/plugin/${plugin.fullName}` + (searchParams.toString() ? `?${searchParams.toString()}` : "");
|
||||||
|
|||||||
@@ -191,16 +191,21 @@ export default function LicenseManager() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
label:
|
label:
|
||||||
label.trim() ||
|
label.trim() ||
|
||||||
|
selectedPlugin.label ||
|
||||||
selectedPlugin.manifest?.plugin_name ||
|
selectedPlugin.manifest?.plugin_name ||
|
||||||
selectedPlugin.name ||
|
selectedPlugin.name ||
|
||||||
selectedPlugin.fullName,
|
selectedPlugin.fullName,
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined
|
||||||
repo: {
|
};
|
||||||
|
if (selectedPlugin.repoId) {
|
||||||
|
payload.repoId = selectedPlugin.repoId;
|
||||||
|
} else {
|
||||||
|
payload.repo = {
|
||||||
repo: selectedPlugin.ownerRepo || selectedPlugin.fullName,
|
repo: selectedPlugin.ownerRepo || selectedPlugin.fullName,
|
||||||
provider: selectedPlugin.provider || "github",
|
provider: selectedPlugin.provider || "github",
|
||||||
baseUrl: selectedPlugin.baseUrl
|
baseUrl: selectedPlugin.baseUrl
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
...payload,
|
...payload,
|
||||||
userId: user?.isAdmin ? Number(selectedOwnerId || user.id) : undefined
|
userId: user?.isAdmin ? Number(selectedOwnerId || user.id) : undefined
|
||||||
@@ -371,9 +376,14 @@ export default function LicenseManager() {
|
|||||||
>
|
>
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => {
|
||||||
const id = plugin.ownerRepo || plugin.fullName;
|
const id = plugin.ownerRepo || plugin.fullName;
|
||||||
|
const optionLabel =
|
||||||
|
plugin.label ||
|
||||||
|
plugin.manifest?.plugin_name ||
|
||||||
|
plugin.name ||
|
||||||
|
plugin.fullName;
|
||||||
return (
|
return (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
{plugin.manifest?.plugin_name || plugin.name} ({plugin.fullName})
|
{optionLabel} ({plugin.ownerRepo || plugin.fullName})
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function PluginDetail() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const provider = (searchParams.get("provider") || "github").toLowerCase();
|
const provider = (searchParams.get("provider") || "github").toLowerCase();
|
||||||
const baseUrl = searchParams.get("baseUrl") || "";
|
const baseUrl = searchParams.get("baseUrl") || "";
|
||||||
|
const repoId = searchParams.get("repoId") || "";
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -20,6 +21,9 @@ export default function PluginDetail() {
|
|||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
query.set("baseUrl", baseUrl);
|
query.set("baseUrl", baseUrl);
|
||||||
}
|
}
|
||||||
|
if (repoId) {
|
||||||
|
query.set("repoId", repoId);
|
||||||
|
}
|
||||||
const search = query.toString();
|
const search = query.toString();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/plugins/${owner}/${repo}${search.length > 0 ? `?${search}` : ""}`
|
`/api/plugins/${owner}/${repo}${search.length > 0 ? `?${search}` : ""}`
|
||||||
@@ -40,7 +44,7 @@ export default function PluginDetail() {
|
|||||||
}, [owner, repo, provider, baseUrl]);
|
}, [owner, repo, provider, baseUrl]);
|
||||||
|
|
||||||
const manifest = data?.manifest;
|
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 description = manifest?.description || data?.description;
|
||||||
const author = manifest?.author || "-";
|
const author = manifest?.author || "-";
|
||||||
const version = manifest?.version || "-";
|
const version = manifest?.version || "-";
|
||||||
|
|||||||
339
src/pages/RepoManager.jsx
Normal file
339
src/pages/RepoManager.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user