Implement Admin Reporting UI with backend and frontend support (#21)
Some checks failed
continuous-integration/drone/push Build is failing

* Initial plan for issue

* Implement Admin Reporting UI with backend and frontend

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Implement reviewed flag for reports instead of deletion and revert upload.tsx changes

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove legacy files migration logic from upload UI

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Delete ui_src/package-lock.json

* Delete ui_src/yarn.lock

* Restore yarn.lock file to original state

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
Co-authored-by: Kieran <kieran@harkin.me>
This commit is contained in:
Copilot
2025-06-10 16:34:09 +01:00
committed by GitHub
parent fc080b5cd0
commit d3711ff52c
7 changed files with 219 additions and 46 deletions

View File

@ -12,6 +12,15 @@ export interface AdminSelf {
total_available_quota?: number;
}
export interface Report {
id: number;
file_id: string;
reporter_id: number;
event_json: string;
created: string;
reviewed: boolean;
}
export class Route96 {
constructor(
readonly url: string,
@ -39,6 +48,25 @@ export class Route96 {
};
}
async listReports(page = 0, count = 10) {
const rsp = await this.#req(
`admin/reports?page=${page}&count=${count}`,
"GET",
);
const data = await this.#handleResponse<AdminResponseReportList>(rsp);
return {
...data,
...data.data,
files: data.data.files,
};
}
async acknowledgeReport(reportId: number) {
const rsp = await this.#req(`admin/reports/${reportId}`, "DELETE");
const data = await this.#handleResponse<AdminResponse<void>>(rsp);
return data;
}
async #handleResponse<T extends AdminResponseBase>(rsp: Response) {
if (rsp.ok) {
return (await rsp.json()) as T;
@ -94,3 +122,10 @@ export type AdminResponseFileList = AdminResponse<{
count: number;
files: Array<NostrEvent>;
}>;
export type AdminResponseReportList = AdminResponse<{
total: number;
page: number;
count: number;
files: Array<Report>;
}>;

View File

@ -0,0 +1,141 @@
import { NostrLink } from "@snort/system";
import classNames from "classnames";
import Profile from "../components/profile";
import { Report } from "../upload/admin";
export default function ReportList({
reports,
pages,
page,
onPage,
onAcknowledge,
onDeleteFile,
}: {
reports: Array<Report>;
pages?: number;
page?: number;
onPage?: (n: number) => void;
onAcknowledge?: (reportId: number) => void;
onDeleteFile?: (fileId: string) => void;
}) {
if (reports.length === 0) {
return <b>No Reports</b>;
}
function pageButtons(page: number, n: number) {
const ret = [];
const start = 0;
for (let x = start; x < n; x++) {
ret.push(
<div
key={x}
onClick={() => onPage?.(x)}
className={classNames(
"bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold",
{
"rounded-l-md": x === start,
"rounded-r-md": x + 1 === n,
"bg-neutral-400": page === x,
},
)}
>
{x + 1}
</div>,
);
}
return ret;
}
function getReporterPubkey(eventJson: string): string | null {
try {
const event = JSON.parse(eventJson);
return event.pubkey;
} catch {
return null;
}
}
function getReportReason(eventJson: string): string {
try {
const event = JSON.parse(eventJson);
return event.content || "No reason provided";
} catch {
return "Invalid event data";
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}
return (
<>
<table className="w-full border-collapse border border-neutral-500">
<thead>
<tr className="bg-neutral-700">
<th className="border border-neutral-500 py-2 px-4 text-left">Report ID</th>
<th className="border border-neutral-500 py-2 px-4 text-left">File ID</th>
<th className="border border-neutral-500 py-2 px-4 text-left">Reporter</th>
<th className="border border-neutral-500 py-2 px-4 text-left">Reason</th>
<th className="border border-neutral-500 py-2 px-4 text-left">Created</th>
<th className="border border-neutral-500 py-2 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody>
{reports.map((report) => {
const reporterPubkey = getReporterPubkey(report.event_json);
const reason = getReportReason(report.event_json);
return (
<tr key={report.id} className="hover:bg-neutral-700">
<td className="border border-neutral-500 py-2 px-4">{report.id}</td>
<td className="border border-neutral-500 py-2 px-4 font-mono text-sm">
{report.file_id.substring(0, 12)}...
</td>
<td className="border border-neutral-500 py-2 px-4">
{reporterPubkey ? (
<Profile link={NostrLink.publicKey(reporterPubkey)} size={20} />
) : (
"Unknown"
)}
</td>
<td className="border border-neutral-500 py-2 px-4 max-w-xs truncate">
{reason}
</td>
<td className="border border-neutral-500 py-2 px-4">
{formatDate(report.created)}
</td>
<td className="border border-neutral-500 py-2 px-4">
<div className="flex gap-2">
<button
onClick={() => onAcknowledge?.(report.id)}
className="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm"
>
Acknowledge
</button>
<button
onClick={() => onDeleteFile?.(report.file_id)}
className="bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm"
>
Delete File
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{pages !== undefined && (
<>
<div className="flex justify-center mt-4">
<div className="flex gap-1">{pageButtons(page ?? 0, pages)}</div>
</div>
</>
)}
</>
);
}

View File

@ -8,16 +8,13 @@ import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96";
import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const";
import Report from "../report.json";
export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("blossom");
const [noCompress, setNoCompress] = useState(false);
const [showLegacy, setShowLegacy] = useState(false);
const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>();
const [bulkPrgress, setBulkProgress] = useState<number>();
const [results, setResults] = useState<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
@ -28,9 +25,6 @@ export default function Upload() {
const login = useLogin();
const pub = usePublisher();
const legacyFiles = Report as Record<string, Array<string>>;
const myLegacyFiles = login ? (legacyFiles[login.pubkey] ?? []) : [];
const url =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;
async function doUpload() {
@ -116,20 +110,6 @@ export default function Upload() {
}
}
async function migrateLegacy() {
if (!pub) return;
const uploader = new Blossom(url, pub);
let ctr = 0;
for (const f of myLegacyFiles) {
try {
await uploader.mirror(`https://void.cat/d/${f}`);
} catch (e) {
console.error(e);
}
setBulkProgress(ctr++ / myLegacyFiles.length);
}
}
useEffect(() => {
listUploads(listedPage);
}, [listedPage]);
@ -211,27 +191,6 @@ export default function Upload() {
</div>
)}
{login && myLegacyFiles.length > 0 && (
<div className="flex flex-col gap-4 font-bold">
You have {myLegacyFiles.length.toLocaleString()} files which can be
migrated from void.cat
<div className="flex gap-2">
<Button onClick={() => migrateLegacy()}>Migrate Files</Button>
<Button onClick={() => setShowLegacy((s) => !s)}>
{!showLegacy ? "Show Files" : "Hide Files"}
</Button>
</div>
{bulkPrgress !== undefined && <progress value={bulkPrgress} />}
</div>
)}
{showLegacy && (
<FileList
files={myLegacyFiles.map((f) => ({
id: f,
url: `https://void.cat/d/${f}`,
}))}
/>
)}
{listedFiles && (
<FileList
files={listedFiles.files}