diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java
index 94455a7..2afc1d1 100644
--- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java
+++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java
@@ -107,6 +107,14 @@ public class ViewController {
return "cookies";
}
+ @org.springframework.web.bind.annotation.GetMapping("/report")
+ public String reportView(Model model) {
+ LOG.info("Rendering copyright report page");
+ addStagingAttribute(model);
+ addVersionAttributes(model);
+ return "report";
+ }
+
@org.springframework.web.bind.annotation.GetMapping("/settings")
public String settingsView(OAuth2AuthenticationToken oauthToken, Model model) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js
index 65f0825..075e638 100644
--- a/src/main/resources/static/js/admin.js
+++ b/src/main/resources/static/js/admin.js
@@ -1,5 +1,6 @@
import { createAdminConsole } from "./admin/console.js";
import { createCustomAssetModal } from "./customAssets.js";
+import "./report.js";
let adminConsole;
const customAssetModal = createCustomAssetModal({
diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js
index f6f0985..67b7b58 100644
--- a/src/main/resources/static/js/admin/console.js
+++ b/src/main/resources/static/js/admin/console.js
@@ -77,6 +77,7 @@ export function createAdminConsole({
const selectedEditBtn = document.getElementById("selected-asset-edit");
const selectedVisibilityBtn = document.getElementById("selected-asset-visibility");
const selectedDeleteBtn = document.getElementById("selected-asset-delete");
+ const selectedReportBtn = document.getElementById("selected-asset-report");
const assetActionRow = document.getElementById("asset-actions");
const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []);
const canvasResolutionLabel = document.getElementById("canvas-resolution");
@@ -2185,6 +2186,9 @@ export function createAdminConsole({
selectedDeleteBtn.disabled = !asset;
selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset";
}
+ if (selectedReportBtn) {
+ selectedReportBtn.disabled = !asset;
+ }
}
function ensureDurationMetadata(asset) {
diff --git a/src/main/resources/static/js/copyright-reports.js b/src/main/resources/static/js/copyright-reports.js
new file mode 100644
index 0000000..be6c12d
--- /dev/null
+++ b/src/main/resources/static/js/copyright-reports.js
@@ -0,0 +1,202 @@
+/**
+ * Sysadmin copyright reports management.
+ * Loaded on the /settings page only.
+ */
+
+(function () {
+ const loadBtn = document.getElementById("copyright-load-btn");
+ if (!loadBtn) return;
+
+ const statusFilter = document.getElementById("copyright-status-filter");
+ const broadcasterFilter = document.getElementById("copyright-broadcaster-filter");
+ const tbody = document.getElementById("copyright-reports-body");
+ const table = document.getElementById("copyright-reports-table");
+ const placeholder = document.getElementById("copyright-reports-placeholder");
+ const pagination = document.getElementById("copyright-pagination");
+ const prevPageBtn = document.getElementById("copyright-prev-page");
+ const nextPageBtn = document.getElementById("copyright-next-page");
+ const pageIndicator = document.getElementById("copyright-page-indicator");
+
+ const reviewModal = document.getElementById("copyright-review-modal");
+ const reviewDetail = document.getElementById("copyright-review-detail");
+ const reviewForm = document.getElementById("copyright-review-form");
+ const reviewNotes = document.getElementById("copyright-review-notes");
+ const reviewError = document.getElementById("copyright-review-error");
+ const reviewCloseBtn = document.getElementById("copyright-review-close");
+ const reviewCancelBtn = document.getElementById("copyright-review-cancel");
+
+ let currentPage = 0;
+ let totalPages = 0;
+ let pendingReportId = null;
+
+ function csrfHeaders() {
+ const token = document.querySelector("meta[name='_csrf']")?.content ?? "";
+ const header = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN";
+ return { [header]: token };
+ }
+
+ function formatDate(isoStr) {
+ if (!isoStr) return "—";
+ return new Date(isoStr).toLocaleString();
+ }
+
+ function statusBadge(status) {
+ const classes = { PENDING: "badge soft", DISMISSED: "badge outline", RESOLVED: "badge" };
+ return `${status}`;
+ }
+
+ async function loadReports(page = 0) {
+ const status = statusFilter.value;
+ const broadcaster = broadcasterFilter.value.trim();
+ const params = new URLSearchParams({ page, size: 20 });
+ if (status) params.set("status", status);
+ if (broadcaster) params.set("broadcaster", broadcaster);
+
+ const resp = await fetch(`/api/copyright-reports?${params}`, {
+ headers: { ...csrfHeaders() },
+ });
+ if (!resp.ok) {
+ placeholder.textContent = "Failed to load reports.";
+ placeholder.classList.remove("hidden");
+ table.classList.add("hidden");
+ pagination.classList.add("hidden");
+ return;
+ }
+ const data = await resp.json();
+ renderTable(data);
+ currentPage = data.page;
+ totalPages = data.totalPages;
+ updatePagination(data);
+ }
+
+ function renderTable(data) {
+ tbody.innerHTML = "";
+ if (!data.content || data.content.length === 0) {
+ placeholder.textContent = "No reports found.";
+ placeholder.classList.remove("hidden");
+ table.classList.add("hidden");
+ pagination.classList.add("hidden");
+ return;
+ }
+ placeholder.classList.add("hidden");
+ table.classList.remove("hidden");
+
+ for (const r of data.content) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+
${escHtml(r.broadcaster)} |
+ ${escHtml(r.assetId.slice(0, 8))}… |
+ ${escHtml(r.claimantName)} <${escHtml(r.claimantEmail)}> |
+ ${statusBadge(r.status)} |
+ ${formatDate(r.createdAt)} |
+
+
+ |
+ `;
+ tbody.appendChild(tr);
+ }
+
+ tbody.querySelectorAll(".review-btn").forEach((btn) => {
+ btn.addEventListener("click", () => openReviewModal(btn.dataset.id, data.content.find((r) => r.id === btn.dataset.id)));
+ });
+ }
+
+ function updatePagination(data) {
+ if (data.totalPages <= 1) {
+ pagination.classList.add("hidden");
+ return;
+ }
+ pagination.classList.remove("hidden");
+ pageIndicator.textContent = `Page ${data.page + 1} of ${data.totalPages}`;
+ prevPageBtn.disabled = data.page === 0;
+ nextPageBtn.disabled = data.page >= data.totalPages - 1;
+ }
+
+ function openReviewModal(reportId, report) {
+ pendingReportId = reportId;
+ reviewForm.reset();
+ hideReviewError();
+ reviewDetail.innerHTML = `
+
+ - Broadcaster
- ${escHtml(report.broadcaster)}
+ - Asset ID
${escHtml(report.assetId)}
+ - Claimant
- ${escHtml(report.claimantName)} <${escHtml(report.claimantEmail)}>
+ - Original work
- ${escHtml(report.originalWorkDescription)}
+ - How it infringes
- ${escHtml(report.infringingDescription)}
+ - Submitted
- ${formatDate(report.createdAt)}
+
+ `;
+ reviewModal.classList.remove("hidden");
+ }
+
+ function closeReviewModal() {
+ reviewModal.classList.add("hidden");
+ pendingReportId = null;
+ }
+
+ function showReviewError(msg) {
+ reviewError.textContent = msg;
+ reviewError.classList.remove("hidden");
+ }
+
+ function hideReviewError() {
+ reviewError.textContent = "";
+ reviewError.classList.add("hidden");
+ }
+
+ reviewForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ hideReviewError();
+ const action = reviewForm.querySelector("input[name='review-action']:checked")?.value;
+ if (!action) {
+ showReviewError("Please select an action.");
+ return;
+ }
+ const submitBtn = reviewForm.querySelector("button[type=submit]");
+ submitBtn.disabled = true;
+ try {
+ const resp = await fetch(`/api/copyright-reports/${pendingReportId}/review`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...csrfHeaders(),
+ },
+ body: JSON.stringify({ action, resolutionNotes: reviewNotes.value.trim() || null }),
+ });
+ if (!resp.ok) {
+ let msg = "Failed to submit review.";
+ try { const d = await resp.json(); if (d?.message) msg = d.message; } catch (_) {}
+ showReviewError(msg);
+ return;
+ }
+ closeReviewModal();
+ if (typeof window.showToast === "function") {
+ window.showToast("Report actioned successfully.", "success");
+ }
+ loadReports(currentPage);
+ } catch (err) {
+ showReviewError(err.message);
+ } finally {
+ submitBtn.disabled = false;
+ }
+ });
+
+ loadBtn.addEventListener("click", () => loadReports(0));
+ prevPageBtn.addEventListener("click", () => loadReports(currentPage - 1));
+ nextPageBtn.addEventListener("click", () => loadReports(currentPage + 1));
+ reviewCloseBtn.addEventListener("click", closeReviewModal);
+ reviewCancelBtn.addEventListener("click", closeReviewModal);
+ reviewModal.addEventListener("click", (e) => { if (e.target === reviewModal) closeReviewModal(); });
+
+ function escHtml(str) {
+ if (!str) return "";
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+})();
diff --git a/src/main/resources/static/js/report-page.js b/src/main/resources/static/js/report-page.js
new file mode 100644
index 0000000..5d57b29
--- /dev/null
+++ b/src/main/resources/static/js/report-page.js
@@ -0,0 +1,252 @@
+/**
+ * Report copyright infringement page logic.
+ * Three-step flow: search broadcaster → pick asset → submit DMCA form.
+ */
+(function () {
+ // ── Elements ──────────────────────────────────────────────────────────────
+ const stepBroadcaster = document.getElementById("step-broadcaster");
+ const stepAsset = document.getElementById("step-asset");
+ const stepForm = document.getElementById("step-form");
+ const stepDone = document.getElementById("step-done");
+
+ const broadcasterInput = document.getElementById("broadcaster-search-input");
+ const broadcasterBtn = document.getElementById("broadcaster-search-btn");
+ const broadcasterError = document.getElementById("broadcaster-error");
+
+ const assetGrid = document.getElementById("asset-grid");
+ const assetError = document.getElementById("asset-error");
+
+ const selectedAssetPreview = document.getElementById("selected-asset-preview");
+ const dmcaForm = document.getElementById("dmca-form");
+ const formError = document.getElementById("form-error");
+ const submitBtn = document.getElementById("submit-btn");
+ const backToAssetBtn = document.getElementById("back-to-asset-btn");
+
+ let selectedAssetId = null;
+ let currentBroadcaster = null;
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+ function show(el) { el.classList.remove("hidden"); }
+ function hide(el) { el.classList.add("hidden"); }
+
+ function showError(el, msg) {
+ el.textContent = msg;
+ show(el);
+ }
+
+ function hideError(el) {
+ el.textContent = "";
+ hide(el);
+ }
+
+ function csrfHeaders() {
+ const token = document.querySelector("meta[name='_csrf']")?.content ?? "";
+ const header = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN";
+ return { [header]: token };
+ }
+
+ function escHtml(str) {
+ if (!str) return "";
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function assetTypeName(type) {
+ const names = { IMAGE: "Image", VIDEO: "Video", AUDIO: "Audio", SCRIPT: "Script", MODEL: "3D model", OTHER: "File" };
+ return names[type] ?? type ?? "Asset";
+ }
+
+ // ── Step 1 – broadcaster search ───────────────────────────────────────────
+ async function searchBroadcaster() {
+ const query = broadcasterInput.value.trim();
+ if (!query) {
+ showError(broadcasterError, "Please enter a broadcaster username.");
+ return;
+ }
+ hideError(broadcasterError);
+ broadcasterBtn.disabled = true;
+ broadcasterBtn.textContent = "Searching…";
+ try {
+ const resp = await fetch(`/api/channels?q=${encodeURIComponent(query)}`);
+ if (!resp.ok) throw new Error("Search failed");
+ const channels = await resp.json();
+ // Find an exact match first, then fall back to first result
+ const exact = channels.find(c => c.toLowerCase() === query.toLowerCase());
+ const broadcaster = exact ?? (channels.length > 0 ? channels[0] : null);
+ if (!broadcaster) {
+ showError(broadcasterError, `No broadcaster found matching "${escHtml(query)}". Check the spelling and try again.`);
+ return;
+ }
+ await loadAssets(broadcaster);
+ } catch (err) {
+ showError(broadcasterError, "Could not search for broadcaster. Please try again.");
+ } finally {
+ broadcasterBtn.disabled = false;
+ broadcasterBtn.textContent = "Search";
+ }
+ }
+
+ broadcasterBtn.addEventListener("click", searchBroadcaster);
+ broadcasterInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); searchBroadcaster(); }
+ });
+
+ // ── Step 2 – asset picker ─────────────────────────────────────────────────
+ async function loadAssets(broadcaster) {
+ hideError(assetError);
+ assetGrid.innerHTML = 'Loading assets…
';
+ currentBroadcaster = broadcaster;
+
+ try {
+ const resp = await fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/visible`);
+ if (!resp.ok) throw new Error("Failed to load assets");
+ const assets = await resp.json();
+
+ if (!assets || assets.length === 0) {
+ assetGrid.innerHTML = 'This broadcaster has no public assets.
';
+ show(stepAsset);
+ return;
+ }
+
+ assetGrid.innerHTML = "";
+ for (const asset of assets) {
+ const card = buildAssetCard(asset, broadcaster);
+ assetGrid.appendChild(card);
+ }
+
+ hide(stepBroadcaster);
+ show(stepAsset);
+ stepAsset.scrollIntoView({ behavior: "smooth", block: "start" });
+ } catch (err) {
+ showError(assetError, "Could not load assets for this broadcaster. Please try again.");
+ }
+ }
+
+ function buildAssetCard(asset, broadcaster) {
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "report-asset-card";
+ card.dataset.assetId = asset.id;
+
+ const imgSrc = asset.previewUrl || (asset.assetType === "IMAGE" ? asset.url : null);
+
+ let mediaHtml = "";
+ if (imgSrc) {
+ mediaHtml = `
`;
+ } else if (asset.assetType === "VIDEO") {
+ mediaHtml = `
`;
+ } else if (asset.assetType === "AUDIO") {
+ mediaHtml = `
`;
+ } else if (asset.assetType === "SCRIPT") {
+ mediaHtml = `
`;
+ } else {
+ mediaHtml = `
`;
+ }
+
+ card.innerHTML = `
+ ${mediaHtml}
+ ${escHtml(asset.name || assetTypeName(asset.assetType))}
+ ${escHtml(assetTypeName(asset.assetType))}
+ `;
+
+ card.addEventListener("click", () => selectAsset(asset, broadcaster));
+ return card;
+ }
+
+ function selectAsset(asset, broadcaster) {
+ selectedAssetId = asset.id;
+
+ const imgSrc = asset.previewUrl || (asset.assetType === "IMAGE" ? asset.url : null);
+ const thumb = imgSrc
+ ? `
`
+ : `
`;
+
+ selectedAssetPreview.innerHTML = `
+
+ ${thumb}
+
+
${escHtml(asset.name || assetTypeName(asset.assetType))}
+
Broadcaster: ${escHtml(broadcaster)} — Type: ${escHtml(assetTypeName(asset.assetType))}
+
+
+ `;
+
+ hide(stepAsset);
+ show(stepForm);
+ stepForm.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+
+ backToAssetBtn.addEventListener("click", () => {
+ selectedAssetId = null;
+ hide(stepForm);
+ show(stepAsset);
+ stepAsset.scrollIntoView({ behavior: "smooth", block: "start" });
+ });
+
+ // ── Step 3 – DMCA form submit ─────────────────────────────────────────────
+ dmcaForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ hideError(formError);
+
+ const claimantName = document.getElementById("claimant-name").value.trim();
+ const claimantEmail = document.getElementById("claimant-email").value.trim();
+ const originalWork = document.getElementById("original-work").value.trim();
+ const infringing = document.getElementById("infringing-description").value.trim();
+ const goodFaith = document.getElementById("good-faith").checked;
+
+ if (!claimantName || !claimantEmail || !originalWork || !infringing) {
+ showError(formError, "All fields are required.");
+ return;
+ }
+ if (!goodFaith) {
+ showError(formError, "You must confirm the good faith declaration.");
+ return;
+ }
+ if (!selectedAssetId) {
+ showError(formError, "No asset selected. Please go back and select an asset.");
+ return;
+ }
+
+ submitBtn.disabled = true;
+ submitBtn.textContent = "Submitting…";
+
+ try {
+ const resp = await fetch(`/api/assets/${encodeURIComponent(selectedAssetId)}/copyright-reports`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...csrfHeaders(),
+ },
+ body: JSON.stringify({
+ claimantName,
+ claimantEmail,
+ originalWorkDescription: originalWork,
+ infringingDescription: infringing,
+ goodFaithDeclaration: true,
+ }),
+ });
+
+ if (!resp.ok) {
+ let msg = "Failed to submit your report. Please try again.";
+ try {
+ const data = await resp.json();
+ if (data?.message) msg = data.message;
+ } catch (_) {}
+ showError(formError, msg);
+ return;
+ }
+
+ hide(stepForm);
+ show(stepDone);
+ stepDone.scrollIntoView({ behavior: "smooth", block: "start" });
+ } catch (err) {
+ showError(formError, "A network error occurred. Please check your connection and try again.");
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Submit report";
+ }
+ });
+})();
diff --git a/src/main/resources/static/js/report.js b/src/main/resources/static/js/report.js
new file mode 100644
index 0000000..853d6dd
--- /dev/null
+++ b/src/main/resources/static/js/report.js
@@ -0,0 +1,119 @@
+/**
+ * Copyright infringement report form logic.
+ * Handles opening the modal, form validation, and submission.
+ */
+
+const modal = document.getElementById("copyright-report-modal");
+const form = document.getElementById("copyright-report-form");
+const errorEl = document.getElementById("copyright-report-error");
+const closeBtn = document.getElementById("copyright-report-close");
+const cancelBtn = document.getElementById("copyright-report-cancel");
+const reportBtn = document.getElementById("selected-asset-report");
+
+let pendingAssetId = null;
+
+function openReportModal(assetId) {
+ pendingAssetId = assetId;
+ form.reset();
+ hideError();
+ modal.classList.remove("hidden");
+}
+
+function closeReportModal() {
+ modal.classList.add("hidden");
+ pendingAssetId = null;
+}
+
+function showError(message) {
+ errorEl.textContent = message;
+ errorEl.classList.remove("hidden");
+}
+
+function hideError() {
+ errorEl.textContent = "";
+ errorEl.classList.add("hidden");
+}
+
+async function submitReport(assetId, payload) {
+ const csrfToken = document.querySelector("meta[name='_csrf']")?.content ?? "";
+ const csrfHeader = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN";
+ const response = await fetch(`/api/assets/${encodeURIComponent(assetId)}/copyright-reports`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ [csrfHeader]: csrfToken,
+ },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ let msg = "Failed to submit report. Please try again.";
+ try {
+ const data = await response.json();
+ if (data?.message) msg = data.message;
+ } catch (_) {}
+ throw new Error(msg);
+ }
+ return response.json();
+}
+
+form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ hideError();
+
+ const claimantName = document.getElementById("copyright-claimant-name").value.trim();
+ const claimantEmail = document.getElementById("copyright-claimant-email").value.trim();
+ const originalWork = document.getElementById("copyright-original-work").value.trim();
+ const infringing = document.getElementById("copyright-infringing").value.trim();
+ const goodFaith = document.getElementById("copyright-good-faith").checked;
+
+ if (!claimantName || !claimantEmail || !originalWork || !infringing) {
+ showError("All fields are required.");
+ return;
+ }
+ if (!goodFaith) {
+ showError("You must confirm the good faith declaration.");
+ return;
+ }
+ if (!pendingAssetId) {
+ showError("No asset selected.");
+ return;
+ }
+
+ const submitBtn = form.querySelector("button[type=submit]");
+ submitBtn.disabled = true;
+ try {
+ await submitReport(pendingAssetId, {
+ claimantName,
+ claimantEmail,
+ originalWorkDescription: originalWork,
+ infringingDescription: infringing,
+ goodFaithDeclaration: true,
+ });
+ closeReportModal();
+ if (typeof window.showToast === "function") {
+ window.showToast("Copyright report submitted successfully.", "success");
+ }
+ } catch (err) {
+ showError(err.message);
+ } finally {
+ submitBtn.disabled = false;
+ }
+});
+
+closeBtn.addEventListener("click", closeReportModal);
+cancelBtn.addEventListener("click", closeReportModal);
+modal.addEventListener("click", (e) => {
+ if (e.target === modal) closeReportModal();
+});
+
+if (reportBtn) {
+ reportBtn.addEventListener("click", () => {
+ const assetIdEl = document.getElementById("selected-asset-id");
+ const assetId = assetIdEl?.textContent?.trim();
+ if (assetId) {
+ openReportModal(assetId);
+ }
+ });
+}
+
+export { openReportModal, closeReportModal };
diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html
index c22449a..13a5dd1 100644
--- a/src/main/resources/templates/admin.html
+++ b/src/main/resources/templates/admin.html
@@ -331,16 +331,27 @@
+ id="selected-asset-delete"
+ class="secondary danger"
+ type="button"
+ title="Delete asset"
+ disabled
+ data-audio-enabled="true"
+ data-code-enabled="true"
+ >
+
+
+
@@ -538,6 +549,48 @@
+
+