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:
@@ -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()}` : "");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
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