Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-02-01 15:43:35 +00:00
parent 3cf7e76d21
commit d306267d58
16 changed files with 286 additions and 42 deletions

View File

@@ -8,3 +8,10 @@ DB_NAME=siti_plugin_repo
# Authentication # Authentication
JWT_SECRET=please-change-me JWT_SECRET=please-change-me
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
# Gitea tokens
# GITEA_TOKEN wordt gebruikt als default token voor alle Gitea servers.
GITEA_TOKEN=
# Optioneel: override per host via GITEA_TOKENS (JSON of comma-separated baseUrl=token)
# Voorbeeld: GITEA_TOKENS=git.robert.ooo=my-token,https://git.example.com=another-token
GITEA_TOKENS=

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/node_modules /node_modules
.env

1
dist/assets/index-BOPMLc5y.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<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-DDjGuoSe.js"></script> <script type="module" crossorigin src="/assets/index-CfHTe5gv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CFgb3VsA.css"> <link rel="stylesheet" crossorigin href="/assets/index-BOPMLc5y.css">
</head> </head>
<body> <body>

View File

@@ -17,4 +17,5 @@ services:
DB_NAME: "${DB_NAME:-siti_plugin_repo}" DB_NAME: "${DB_NAME:-siti_plugin_repo}"
JWT_SECRET: "${JWT_SECRET:-change-me}" JWT_SECRET: "${JWT_SECRET:-change-me}"
JWT_EXPIRES_IN: "${JWT_EXPIRES_IN:-7d}" JWT_EXPIRES_IN: "${JWT_EXPIRES_IN:-7d}"
GITEA_TOKEN: "${GITEA_TOKEN:-change-me}"
restart: unless-stopped restart: unless-stopped

12
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2", "mysql2": "^3.16.2",
@@ -1354,6 +1355,17 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2", "mysql2": "^3.16.2",

View File

@@ -1,3 +1,4 @@
import "dotenv/config";
import express from "express"; import express from "express";
import path from "path"; import path from "path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
@@ -328,7 +329,8 @@ app.get("/api/plugins", async (_req, res) => {
ownerRepo: repo.ownerRepo, ownerRepo: repo.ownerRepo,
baseUrl: repo.baseUrl, baseUrl: repo.baseUrl,
repoId: repo.id, repoId: repo.id,
label: repo.label label: repo.label,
isPrivate: false
}; };
} }
}) })
@@ -375,23 +377,77 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
repoRecord = await findRepoByOwnerRepo(ownerRepo); repoRecord = await findRepoByOwnerRepo(ownerRepo);
} }
const provider = (req.query.provider || repoRecord?.provider || "github").toLowerCase(); let provider = (req.query.provider || repoRecord?.provider || "github").toLowerCase();
const baseUrlQuery = if (repoRecord?.provider) {
req.query.baseUrl || repoRecord?.baseUrl || (provider === "github" ? "https://github.com" : ""); provider = repoRecord.provider;
}
const baseUrlFallback = provider === "github" ? "https://github.com" : "";
let baseUrlQuery = req.query.baseUrl || repoRecord?.baseUrl || baseUrlFallback;
if (repoRecord?.baseUrl) {
baseUrlQuery = repoRecord.baseUrl;
}
const entry = const entry =
repoRecord && repoRecord.ownerRepo repoRecord && repoRecord.ownerRepo
? { provider: repoRecord.provider, repo: repoRecord.ownerRepo, baseUrl: repoRecord.baseUrl } ? {
provider: repoRecord.provider,
repo: repoRecord.ownerRepo,
baseUrl: repoRecord.baseUrl || baseUrlQuery || undefined
}
: provider === "github" : provider === "github"
? ownerRepo ? ownerRepo
: { provider, repo: ownerRepo, baseUrl: baseUrlQuery }; : { provider, repo: ownerRepo, baseUrl: baseUrlQuery };
const normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery }); const normalizedEntry =
normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery }) ||
null;
const info = await fetchRepo(entry); if (!normalizedEntry) {
const [manifest, releases, commits] = await Promise.all([ return res.status(400).json({ error: "Ongeldige repository." });
fetchManifest(entry, info.defaultBranch).catch(() => null), }
fetchReleases(entry).catch(() => []),
fetchCommits(entry).catch(() => []) let info;
]); let manifest = null;
let releases = [];
let commits = [];
try {
info = await fetchRepo(normalizedEntry);
[manifest, releases, commits] = await Promise.all([
fetchManifest(normalizedEntry, info.defaultBranch).catch(() => null),
fetchReleases(normalizedEntry).catch(() => []),
fetchCommits(normalizedEntry).catch(() => [])
]);
} catch (fetchError) {
console.warn("Kon plugin info niet ophalen:", fetchError.message);
const parsed = parseRepoEntry(normalizedEntry);
const fallbackRepo = {
provider: repoRecord?.provider || parsed.provider,
baseUrl: repoRecord?.baseUrl || parsed.baseUrl,
repo: repoRecord?.ownerRepo || parsed.ownerRepo
};
const repoUrl = buildRepoUrl(fallbackRepo);
const name = repoRecord?.label || parsed.ownerRepo.split("/")[1] || parsed.ownerRepo;
return res.json({
fullName: fallbackRepo.repo,
name,
description: repoRecord?.label ? `Plugin: ${repoRecord.label}` : "Kon gegevens niet ophalen.",
repoUrl,
provider: fallbackRepo.provider,
ownerRepo: fallbackRepo.repo,
baseUrl: fallbackRepo.baseUrl,
repoId: repoRecord?.id || null,
label: repoRecord?.label || null,
manifest: null,
releases: [],
commits: [],
download: null,
version: null,
versionSource: null,
isPrivate: false
});
}
const isPrivate = Boolean(info?.isPrivate);
const allowDownloads = normalizedEntry && !isPrivate;
const effectiveBaseUrl = const effectiveBaseUrl =
normalizedEntry?.baseUrl || normalizedEntry?.baseUrl ||
@@ -400,7 +456,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
(provider === "github" ? "https://github.com" : ""); (provider === "github" ? "https://github.com" : "");
const releasesWithDownload = const releasesWithDownload =
normalizedEntry && releases.length > 0 allowDownloads && releases.length > 0
? releases.map((release) => { ? releases.map((release) => {
const tagOrName = release.tag || release.name; const tagOrName = release.tag || release.name;
if (!tagOrName) { if (!tagOrName) {
@@ -420,7 +476,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
: releases; : releases;
let downloadMeta = null; let downloadMeta = null;
if (normalizedEntry) { if (allowDownloads) {
const latestRelease = releasesWithDownload[0]; const latestRelease = releasesWithDownload[0];
const sourceType = latestRelease ? "release" : "branch"; const sourceType = latestRelease ? "release" : "branch";
const version = latestRelease?.tag || latestRelease?.name || info.defaultBranch || "main"; const version = latestRelease?.tag || latestRelease?.name || info.defaultBranch || "main";
@@ -454,6 +510,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
manifest, manifest,
releases: releasesWithDownload, releases: releasesWithDownload,
download: downloadMeta, download: downloadMeta,
isPrivate,
commits commits
}); });
} catch (error) { } catch (error) {
@@ -502,6 +559,9 @@ app.get("/api/plugins/:owner/:repo/download", async (req, res) => {
}); });
stream.pipe(res); stream.pipe(res);
} catch (error) { } catch (error) {
if (error?.meta === "PRIVATE_REPO") {
return res.status(403).json({ error: "Downloads voor private plugins zijn uitgeschakeld." });
}
console.error("Plugin download endpoint error:", error); console.error("Plugin download endpoint error:", error);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ error: "Download mislukt." }); res.status(500).json({ error: "Download mislukt." });
@@ -543,20 +603,34 @@ app.post("/api/licenses", requireAuth, async (req, res) => {
if (body.repoId && Number.isNaN(repoId)) { if (body.repoId && Number.isNaN(repoId)) {
return res.status(400).json({ error: "Ongeldig repo id." }); return res.status(400).json({ error: "Ongeldig repo id." });
} }
let repoRecord = null;
if (repoId) {
repoRecord = await getRepoById(repoId);
if (!repoRecord) {
return res.status(404).json({ error: "Plugin niet gevonden." });
}
}
const repoInput = const repoInput =
typeof body.repo === "object" typeof body.repo === "object"
? body.repo ? body.repo
: typeof body.plugin === "object" : typeof body.plugin === "object"
? body.plugin ? body.plugin
: body.repo || body.ownerRepo || body.fullName || body.plugin; : body.repo || body.ownerRepo || body.fullName || body.plugin;
const repoFromRecord = repoRecord
? {
repo: repoRecord.ownerRepo,
provider: repoRecord.provider,
baseUrl: repoRecord.baseUrl
}
: null;
const repoEntry = const repoEntry =
normalizeRepoInput(repoInput, { normalizeRepoInput(repoInput, {
repo: body.ownerRepo || body.fullName || body.repo, repo: body.ownerRepo || body.fullName || body.repo || repoFromRecord?.repo,
provider: body.provider, provider: body.provider || repoFromRecord?.provider,
baseUrl: body.baseUrl baseUrl: body.baseUrl || repoFromRecord?.baseUrl
}) || null; }) || repoFromRecord;
if (!repoId && !repoEntry) { if (!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." });
} }
@@ -677,6 +751,12 @@ async function handleLicenseDownload(res, { key, hostname, version = "latest" })
}); });
stream.pipe(res); stream.pipe(res);
} catch (error) { } catch (error) {
if (error?.meta === "PRIVATE_REPO") {
if (!res.headersSent) {
return res.status(403).json({ error: "Downloads voor private plugins zijn uitgeschakeld." });
}
return res.end();
}
console.error("Download endpoint error:", error); console.error("Download endpoint error:", error);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ error: "Download mislukt." }); res.status(500).json({ error: "Download mislukt." });

View File

@@ -27,3 +27,63 @@ export const DB_CONFIG = {
export const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production"; export const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
function normalizeHostKey(value) {
if (!value) return null;
try {
const url = new URL(value);
return url.host.toLowerCase();
} catch {
return value.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").trim().toLowerCase() || null;
}
}
function parseGiteaTokenMap(rawValue) {
if (!rawValue) return {};
const map = {};
try {
const parsed = typeof rawValue === "string" ? JSON.parse(rawValue) : rawValue;
if (parsed && typeof parsed === "object") {
for (const [key, token] of Object.entries(parsed)) {
const host = normalizeHostKey(key);
if (host && typeof token === "string" && token.trim()) {
map[host] = token.trim();
}
}
return map;
}
} catch {
// Fall through to comma-separated parsing.
}
const segments = String(rawValue)
.split(",")
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
const [key, token] = segment.split("=").map((part) => part.trim());
const host = normalizeHostKey(key);
if (host && token) {
map[host] = token;
}
}
return map;
}
const DEFAULT_GITEA_TOKEN = process.env.GITEA_TOKEN?.trim() || null;
const GITEA_TOKENS = parseGiteaTokenMap(process.env.GITEA_TOKENS);
function getEnvTokenForHost(host) {
if (!host) return null;
const envKey = `GITEA_TOKEN_${host.replace(/[^A-Z0-9]/gi, "_").toUpperCase()}`;
return process.env[envKey]?.trim() || null;
}
export function getGiteaToken(baseUrl) {
const host = normalizeHostKey(baseUrl);
return (
(host && (GITEA_TOKENS[host] || getEnvTokenForHost(host))) ||
DEFAULT_GITEA_TOKEN ||
null
);
}

View File

@@ -22,6 +22,11 @@ export async function resolveRepoDownload(repoEntry, requestedVersion = "latest"
} }
const repoInfo = await fetchRepo(normalizedEntry); const repoInfo = await fetchRepo(normalizedEntry);
if (repoInfo.isPrivate) {
const error = new Error("Downloads niet beschikbaar voor private repositories.");
error.meta = "PRIVATE_REPO";
throw error;
}
const releases = await fetchReleases(normalizedEntry).catch(() => []); const releases = await fetchReleases(normalizedEntry).catch(() => []);
let targetVersion = requestedVersion || "latest"; let targetVersion = requestedVersion || "latest";

View File

@@ -1,5 +1,6 @@
import { readJsonFile } from "./storage.js"; import { readJsonFile } from "./storage.js";
import { getCached, setCached } from "./cache.js"; import { getCached, setCached } from "./cache.js";
import { getGiteaToken } from "./config.js";
export function parseRepoEntry(entry) { export function parseRepoEntry(entry) {
if (typeof entry === "string") { if (typeof entry === "string") {
@@ -51,6 +52,12 @@ async function fetchJson(url, cacheKey, opts = {}) {
if (!opts.provider || opts.provider === "github") { if (!opts.provider || opts.provider === "github") {
headers.Accept = "application/vnd.github+json"; headers.Accept = "application/vnd.github+json";
} }
if (opts.provider === "gitea") {
const token = opts.token || getGiteaToken(opts.baseUrl || url);
if (token) {
headers.Authorization = `token ${token}`;
}
}
const response = await fetch(url, { headers }); const response = await fetch(url, { headers });
if (!response.ok) { if (!response.ok) {
throw new Error(`${opts.provider || "git"} request failed for ${url}`); throw new Error(`${opts.provider || "git"} request failed for ${url}`);
@@ -76,7 +83,7 @@ export async function fetchRepo(entry) {
let data; let data;
if (provider === "gitea") { if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`; const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`;
data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider }); data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider, baseUrl });
const mapped = { const mapped = {
fullName: data.full_name || `${ownerRepo}`, fullName: data.full_name || `${ownerRepo}`,
name: data.name || ownerRepo.split("/")[1] || ownerRepo, name: data.name || ownerRepo.split("/")[1] || ownerRepo,
@@ -90,13 +97,16 @@ export async function fetchRepo(entry) {
topics: data.topics || [], topics: data.topics || [],
provider, provider,
ownerRepo, ownerRepo,
baseUrl baseUrl,
isPrivate: Boolean(data.private)
}; };
setCached(cacheKey, mapped); setCached(cacheKey, mapped);
return mapped; return mapped;
} }
data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, { provider: "github" }); data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, {
provider: "github"
});
const mapped = { const mapped = {
fullName: data.full_name, fullName: data.full_name,
name: data.name, name: data.name,
@@ -110,7 +120,8 @@ export async function fetchRepo(entry) {
topics: data.topics || [], topics: data.topics || [],
provider, provider,
ownerRepo, ownerRepo,
baseUrl: "https://github.com" baseUrl: "https://github.com",
isPrivate: Boolean(data.private)
}; };
setCached(cacheKey, mapped); setCached(cacheKey, mapped);
return mapped; return mapped;
@@ -131,7 +142,14 @@ export async function fetchManifest(entry, defaultBranch) {
} else { } else {
url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`; url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`;
} }
const response = await fetch(url, { headers: { "User-Agent": "siti-plugin-repo" } }); const headers = { "User-Agent": "siti-plugin-repo" };
if (provider === "gitea") {
const token = getGiteaToken(baseUrl);
if (token) {
headers.Authorization = `token ${token}`;
}
}
const response = await fetch(url, { headers });
if (response.ok) { if (response.ok) {
const manifest = await response.json().catch(() => null); const manifest = await response.json().catch(() => null);
setCached(cacheKey, manifest); setCached(cacheKey, manifest);
@@ -145,7 +163,7 @@ export async function fetchReleases(entry) {
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry); const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
if (provider === "gitea") { if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/releases?limit=5`; const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/releases?limit=5`;
const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider }); const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider, baseUrl });
return Array.isArray(data) return Array.isArray(data)
? data.map((release) => ({ ? data.map((release) => ({
tag: release.tag_name || release.name, tag: release.tag_name || release.name,
@@ -175,7 +193,7 @@ export async function fetchCommits(entry) {
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry); const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
if (provider === "gitea") { if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/commits?limit=5`; const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/commits?limit=5`;
const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider }); const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider, baseUrl });
return Array.isArray(data) return Array.isArray(data)
? data.map((commit) => ({ ? data.map((commit) => ({
sha: commit.sha, sha: commit.sha,

View File

@@ -90,6 +90,10 @@
.hero h1 { .hero h1 {
font-size: clamp(2.4rem, 4vw, 3.4rem); font-size: clamp(2.4rem, 4vw, 3.4rem);
margin: 0 0 8px; margin: 0 0 8px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
} }
.subtitle { .subtitle {
@@ -180,6 +184,10 @@
.card h2 { .card h2 {
margin: 0; margin: 0;
font-size: 1.3rem; font-size: 1.3rem;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
} }
.card p { .card p {
@@ -241,6 +249,11 @@
color: #166534; color: #166534;
} }
.state.warning {
background: #fef9c3;
color: #92400e;
}
.state.inline { .state.inline {
margin-top: 16px; margin-top: 16px;
padding: 16px; padding: 16px;
@@ -621,3 +634,25 @@
color: #94a3b8; color: #94a3b8;
} }
} }
.privacy-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}

View File

@@ -49,6 +49,7 @@ export default function Home() {
{plugins.map((plugin) => { {plugins.map((plugin) => {
const displayName = plugin.label || 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 isPrivate = Boolean(plugin.isPrivate);
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (plugin.provider) { if (plugin.provider) {
searchParams.set("provider", plugin.provider); searchParams.set("provider", plugin.provider);
@@ -65,7 +66,15 @@ export default function Home() {
return ( return (
<article className="card" key={plugin.fullName}> <article className="card" key={plugin.fullName}>
<div className="card-header"> <div className="card-header">
<h2>{displayName}</h2> <h2>
{displayName}
{isPrivate && (
<span className="privacy-indicator" title="Privé plugin">
<span aria-hidden="true">🔒</span>
<span className="sr-only">Privé plugin</span>
</span>
)}
</h2>
<span className="pill">{plugin.fullName}</span> <span className="pill">{plugin.fullName}</span>
</div> </div>
<p>{description}</p> <p>{description}</p>

View File

@@ -49,7 +49,8 @@ export default function PluginDetail() {
const author = manifest?.author || "-"; const author = manifest?.author || "-";
const version = manifest?.version || "-"; const version = manifest?.version || "-";
const repositoryLabel = data?.provider === "gitea" ? "Gitea" : "GitHub"; const repositoryLabel = data?.provider === "gitea" ? "Gitea" : "GitHub";
const latestDownload = data?.download; const isPrivate = Boolean(data?.isPrivate);
const latestDownload = isPrivate ? null : data?.download;
const releases = useMemo(() => data?.releases || [], [data]); const releases = useMemo(() => data?.releases || [], [data]);
const commits = useMemo(() => data?.commits || [], [data]); const commits = useMemo(() => data?.commits || [], [data]);
@@ -59,7 +60,15 @@ export default function PluginDetail() {
<header className="detail-hero"> <header className="detail-hero">
<div> <div>
<p className="eyebrow">Plugin details</p> <p className="eyebrow">Plugin details</p>
<h1>{displayName}</h1> <h1>
{displayName}
{isPrivate && (
<span className="privacy-indicator" title="Privé plugin">
<span aria-hidden="true">🔒</span>
<span className="sr-only">Privé plugin</span>
</span>
)}
</h1>
<p className="subtitle">{description}</p> <p className="subtitle">{description}</p>
</div> </div>
<div className="detail-actions"> <div className="detail-actions">
@@ -77,6 +86,12 @@ export default function PluginDetail() {
</div> </div>
</header> </header>
{isPrivate && (
<div className="state warning">
<strong>Privé plugin.</strong> Downloads via deze repo zijn niet beschikbaar.
</div>
)}
{loading && <div className="state">Bezig met laden</div>} {loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>} {error && <div className="state error">{error}</div>}