feat: add copyright report UI (public report page, admin flag button, sysadmin review)

- /report: public three-step page to find a broadcaster, pick an asset, and
  submit a DMCA-style claim; uses asset.url fallback for images without previews
- Admin console: flag button opens report modal for the selected asset
- Settings page: sysadmin copyright reports section with status/broadcaster
  filters, paginated table, and review modal with action radio buttons
- Footer on index.html links to /report
This commit is contained in:
2026-04-28 14:47:05 +02:00
parent 05a7c5d2b5
commit 87367a6e35
10 changed files with 879 additions and 10 deletions
@@ -107,6 +107,14 @@ public class ViewController {
return "cookies"; 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") @org.springframework.web.bind.annotation.GetMapping("/settings")
public String settingsView(OAuth2AuthenticationToken oauthToken, Model model) { public String settingsView(OAuth2AuthenticationToken oauthToken, Model model) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
+1
View File
@@ -1,5 +1,6 @@
import { createAdminConsole } from "./admin/console.js"; import { createAdminConsole } from "./admin/console.js";
import { createCustomAssetModal } from "./customAssets.js"; import { createCustomAssetModal } from "./customAssets.js";
import "./report.js";
let adminConsole; let adminConsole;
const customAssetModal = createCustomAssetModal({ const customAssetModal = createCustomAssetModal({
@@ -77,6 +77,7 @@ export function createAdminConsole({
const selectedEditBtn = document.getElementById("selected-asset-edit"); const selectedEditBtn = document.getElementById("selected-asset-edit");
const selectedVisibilityBtn = document.getElementById("selected-asset-visibility"); const selectedVisibilityBtn = document.getElementById("selected-asset-visibility");
const selectedDeleteBtn = document.getElementById("selected-asset-delete"); const selectedDeleteBtn = document.getElementById("selected-asset-delete");
const selectedReportBtn = document.getElementById("selected-asset-report");
const assetActionRow = document.getElementById("asset-actions"); const assetActionRow = document.getElementById("asset-actions");
const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []); const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []);
const canvasResolutionLabel = document.getElementById("canvas-resolution"); const canvasResolutionLabel = document.getElementById("canvas-resolution");
@@ -2185,6 +2186,9 @@ export function createAdminConsole({
selectedDeleteBtn.disabled = !asset; selectedDeleteBtn.disabled = !asset;
selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset"; selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset";
} }
if (selectedReportBtn) {
selectedReportBtn.disabled = !asset;
}
} }
function ensureDurationMetadata(asset) { function ensureDurationMetadata(asset) {
@@ -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 `<span class="${classes[status] ?? "badge"}">${status}</span>`;
}
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 = `
<td>${escHtml(r.broadcaster)}</td>
<td><code title="${escHtml(r.assetId)}">${escHtml(r.assetId.slice(0, 8))}…</code></td>
<td>${escHtml(r.claimantName)} &lt;${escHtml(r.claimantEmail)}&gt;</td>
<td>${statusBadge(r.status)}</td>
<td>${formatDate(r.createdAt)}</td>
<td>
<button class="button ghost review-btn" data-id="${escHtml(r.id)}" type="button"
${r.status !== "PENDING" ? "disabled title='Already actioned'" : ""}>
Review
</button>
</td>
`;
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 = `
<dl class="detail-list">
<div><dt>Broadcaster</dt><dd>${escHtml(report.broadcaster)}</dd></div>
<div><dt>Asset ID</dt><dd><code>${escHtml(report.assetId)}</code></dd></div>
<div><dt>Claimant</dt><dd>${escHtml(report.claimantName)} &lt;${escHtml(report.claimantEmail)}&gt;</dd></div>
<div><dt>Original work</dt><dd>${escHtml(report.originalWorkDescription)}</dd></div>
<div><dt>How it infringes</dt><dd>${escHtml(report.infringingDescription)}</dd></div>
<div><dt>Submitted</dt><dd>${formatDate(report.createdAt)}</dd></div>
</dl>
`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
})();
+252
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = '<p class="muted tiny">Loading assets…</p>';
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 = '<p class="muted tiny">This broadcaster has no public assets.</p>';
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 = `<img class="asset-card-thumb" src="${escHtml(imgSrc)}" alt="${escHtml(asset.name)}" loading="lazy" />`;
} else if (asset.assetType === "VIDEO") {
mediaHtml = `<div class="asset-card-thumb asset-card-icon"><i class="fa-solid fa-film"></i></div>`;
} else if (asset.assetType === "AUDIO") {
mediaHtml = `<div class="asset-card-thumb asset-card-icon"><i class="fa-solid fa-music"></i></div>`;
} else if (asset.assetType === "SCRIPT") {
mediaHtml = `<div class="asset-card-thumb asset-card-icon"><i class="fa-solid fa-code"></i></div>`;
} else {
mediaHtml = `<div class="asset-card-thumb asset-card-icon"><i class="fa-solid fa-file"></i></div>`;
}
card.innerHTML = `
${mediaHtml}
<span class="asset-card-name">${escHtml(asset.name || assetTypeName(asset.assetType))}</span>
<span class="asset-card-type">${escHtml(assetTypeName(asset.assetType))}</span>
`;
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
? `<img class="asset-card-thumb" src="${escHtml(imgSrc)}" alt="${escHtml(asset.name)}" />`
: `<div class="asset-card-thumb asset-card-icon"><i class="fa-solid fa-file"></i></div>`;
selectedAssetPreview.innerHTML = `
<div class="selected-asset-summary">
${thumb}
<div>
<p class="asset-summary-name">${escHtml(asset.name || assetTypeName(asset.assetType))}</p>
<p class="muted tiny">Broadcaster: <strong>${escHtml(broadcaster)}</strong> &mdash; Type: ${escHtml(assetTypeName(asset.assetType))}</p>
</div>
</div>
`;
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";
}
});
})();
+119
View File
@@ -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 };
+63 -10
View File
@@ -331,16 +331,27 @@
<i class="fa-solid fa-eye-slash"></i> <i class="fa-solid fa-eye-slash"></i>
</button> </button>
<button <button
id="selected-asset-delete" id="selected-asset-delete"
class="secondary danger" class="secondary danger"
type="button" type="button"
title="Delete asset" title="Delete asset"
disabled disabled
data-audio-enabled="true" data-audio-enabled="true"
data-code-enabled="true" data-code-enabled="true"
> >
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>
<button
id="selected-asset-report"
class="secondary"
type="button"
title="Report copyright infringement"
disabled
data-audio-enabled="true"
data-code-enabled="true"
>
<i class="fa-solid fa-flag"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -538,6 +549,48 @@
</form> </form>
</section> </section>
</div> </div>
<div id="copyright-report-modal" class="modal hidden">
<section class="modal-inner">
<div class="modal-header-row">
<div>
<h1>Report copyright infringement</h1>
<p>Use this form to submit a DMCA-style report. All fields are required.</p>
</div>
<button type="button" class="ghost icon-button" id="copyright-report-close" aria-label="Close">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<form id="copyright-report-form" novalidate>
<div class="form-group">
<label for="copyright-claimant-name">Your full name</label>
<input id="copyright-claimant-name" type="text" class="text-input" placeholder="Jane Smith" maxlength="255" required />
</div>
<div class="form-group">
<label for="copyright-claimant-email">Your email address</label>
<input id="copyright-claimant-email" type="email" class="text-input" placeholder="jane@example.com" maxlength="255" required />
</div>
<div class="form-group">
<label for="copyright-original-work">Describe the original work you own</label>
<textarea id="copyright-original-work" class="text-input" rows="4" maxlength="4000" placeholder="Describe the copyrighted work (e.g. my original illustration published at …)" required></textarea>
</div>
<div class="form-group">
<label for="copyright-infringing">How is this asset infringing?</label>
<textarea id="copyright-infringing" class="text-input" rows="4" maxlength="4000" placeholder="Explain how the asset infringes your copyright" required></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input id="copyright-good-faith" type="checkbox" required />
I declare in good faith that the use of the material is not authorized by the copyright owner, its agent, or the law.
</label>
</div>
<div class="form-error hidden" id="copyright-report-error"></div>
<div class="form-actions">
<button type="button" class="secondary" id="copyright-report-cancel">Cancel</button>
<button type="submit" class="primary">Submit report</button>
</div>
</form>
</section>
</div>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ""; const broadcaster = /*[[${broadcaster}]]*/ "";
const username = /*[[${username}]]*/ ""; const username = /*[[${username}]]*/ "";
+1
View File
@@ -45,6 +45,7 @@
<a class="version-badge version-link" href="/terms">Terms</a> <a class="version-badge version-link" href="/terms">Terms</a>
<a class="version-badge version-link" href="/privacy">Privacy</a> <a class="version-badge version-link" href="/privacy">Privacy</a>
<a class="version-badge version-link" href="/cookies">Cookies</a> <a class="version-badge version-link" href="/cookies">Cookies</a>
<a class="version-badge version-link" href="/report">Report abuse</a>
<a <a
class="version-badge version-link" class="version-badge version-link"
th:href="${docsUrl}" th:href="${docsUrl}"
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
<title>Report Copyright Infringement - Imgfloat</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body class="landing-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="landing">
<header class="landing-header">
<div class="brand">
<a href="/" class="brand">
<img class="brand-mark" alt="brand" src="/img/brand.png" />
<div>
<div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div>
</div>
</a>
</div>
</header>
<main class="report-page">
<div class="report-container">
<div class="report-header">
<h1>Report Copyright Infringement</h1>
<p class="muted">
Use this form to report an asset that infringes your copyright. You will need to identify
the broadcaster who uploaded it and select the specific asset, then complete a
DMCA-style declaration.
</p>
</div>
<!-- Step 1: Find broadcaster -->
<section class="report-step" id="step-broadcaster">
<div class="step-header">
<span class="step-number">1</span>
<h2>Find the broadcaster</h2>
</div>
<p class="muted tiny">Enter the Twitch username of the broadcaster who uploaded the infringing content.</p>
<div class="inline-form">
<input
id="broadcaster-search-input"
type="text"
class="text-input"
placeholder="Twitch username"
autocomplete="off"
spellcheck="false"
/>
<button id="broadcaster-search-btn" class="button" type="button">Search</button>
</div>
<p class="form-error hidden" id="broadcaster-error"></p>
</section>
<!-- Step 2: Select asset -->
<section class="report-step hidden" id="step-asset">
<div class="step-header">
<span class="step-number">2</span>
<h2>Select the infringing asset</h2>
</div>
<p class="muted tiny">Choose the specific asset you believe infringes your copyright.</p>
<div id="asset-grid" class="report-asset-grid"></div>
<p class="form-error hidden" id="asset-error"></p>
</section>
<!-- Step 3: DMCA form -->
<section class="report-step hidden" id="step-form">
<div class="step-header">
<span class="step-number">3</span>
<h2>Submit your claim</h2>
</div>
<p class="muted tiny">
Complete all fields below. By submitting this form you confirm the declaration under penalty of
perjury that the information is accurate.
</p>
<div class="selected-asset-preview" id="selected-asset-preview"></div>
<form id="dmca-form" novalidate class="report-form">
<div class="form-group">
<label for="claimant-name">Your full legal name <span class="required">*</span></label>
<input id="claimant-name" type="text" class="text-input" maxlength="255" required />
</div>
<div class="form-group">
<label for="claimant-email">Your email address <span class="required">*</span></label>
<input id="claimant-email" type="email" class="text-input" maxlength="255" required />
</div>
<div class="form-group">
<label for="original-work">Describe the original work you own <span class="required">*</span></label>
<textarea
id="original-work"
class="text-input"
rows="4"
maxlength="4000"
placeholder="Describe the work — e.g. my original illustration published at https://…"
required
></textarea>
</div>
<div class="form-group">
<label for="infringing-description">How is this asset infringing? <span class="required">*</span></label>
<textarea
id="infringing-description"
class="text-input"
rows="4"
maxlength="4000"
placeholder="Explain how this asset reproduces your copyrighted work without authorisation"
required
></textarea>
</div>
<div class="form-group">
<label class="checkbox-label">
<input id="good-faith" type="checkbox" required />
I declare in good faith that the use of the material described above is not authorised
by the copyright owner, its agent, or the law, and that the information in this
notification is accurate. <span class="required">*</span>
</label>
</div>
<p class="form-error hidden" id="form-error"></p>
<div class="form-actions">
<button type="button" class="secondary" id="back-to-asset-btn">Back</button>
<button type="submit" class="primary" id="submit-btn">Submit report</button>
</div>
</form>
</section>
<!-- Step 4: Confirmation -->
<section class="report-step hidden" id="step-done">
<div class="step-header">
<span class="step-number">&#10003;</span>
<h2>Report submitted</h2>
</div>
<p>
Your copyright report has been received and will be reviewed by our team. We will contact
you at the email address you provided if we need further information.
</p>
<div class="form-actions">
<a href="/" class="button">Back to home</a>
</div>
</section>
</div>
</main>
<footer class="landing-meta">
<div class="build-chip">
<span class="muted">Legal</span>
<a class="version-badge version-link" href="/terms">Terms</a>
<a class="version-badge version-link" href="/privacy">Privacy</a>
<a class="version-badge version-link" href="/cookies">Cookies</a>
</div>
</footer>
</div>
<script src="/js/report-page.js"></script>
</body>
</html>
@@ -245,9 +245,80 @@
<ul id="sysadmin-list" class="stacked-list"></ul> <ul id="sysadmin-list" class="stacked-list"></ul>
</div> </div>
</section> </section>
<section class="settings-card">
<div class="section-heading">
<div>
<h2>Copyright reports</h2>
<p class="muted tiny">Review and action DMCA-style infringement reports.</p>
</div>
</div>
<div class="inline-form">
<select id="copyright-status-filter" class="text-input">
<option value="">All statuses</option>
<option value="PENDING">Pending</option>
<option value="DISMISSED">Dismissed</option>
<option value="RESOLVED">Resolved</option>
</select>
<input id="copyright-broadcaster-filter" class="text-input" type="text" placeholder="Filter by broadcaster" />
<button id="copyright-load-btn" class="button" type="button">Load reports</button>
</div>
<div id="copyright-reports-container">
<p class="muted tiny" id="copyright-reports-placeholder">No reports loaded yet.</p>
<table class="data-table hidden" id="copyright-reports-table">
<thead>
<tr>
<th>Broadcaster</th>
<th>Asset ID</th>
<th>Claimant</th>
<th>Status</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="copyright-reports-body"></tbody>
</table>
<div class="pagination-controls hidden" id="copyright-pagination">
<button class="button ghost" id="copyright-prev-page" type="button">Previous</button>
<span id="copyright-page-indicator"></span>
<button class="button ghost" id="copyright-next-page" type="button">Next</button>
</div>
</div>
</section>
</div> </div>
</main> </main>
</div> </div>
<div id="copyright-review-modal" class="modal hidden">
<section class="modal-inner">
<div class="modal-header-row">
<h1>Review copyright report</h1>
<button type="button" class="ghost icon-button" id="copyright-review-close" aria-label="Close">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div id="copyright-review-detail"></div>
<form id="copyright-review-form" novalidate>
<div class="form-group">
<label>Action</label>
<div class="radio-group">
<label class="radio-label"><input type="radio" name="review-action" value="DISMISS" /> Dismiss — no action needed</label>
<label class="radio-label"><input type="radio" name="review-action" value="REMOVE_ASSET" /> Remove the infringing asset</label>
<label class="radio-label"><input type="radio" name="review-action" value="NOTIFY_BROADCASTER" /> Notify broadcaster</label>
<label class="radio-label"><input type="radio" name="review-action" value="BAN_BROADCASTER" /> Ban broadcaster</label>
</div>
</div>
<div class="form-group">
<label for="copyright-review-notes">Resolution notes (optional)</label>
<textarea id="copyright-review-notes" class="text-input" rows="3" maxlength="4000" placeholder="Internal notes for this decision"></textarea>
</div>
<div class="form-error hidden" id="copyright-review-error"></div>
<div class="form-actions">
<button type="button" class="secondary" id="copyright-review-cancel">Cancel</button>
<button type="submit" class="primary">Confirm action</button>
</div>
</form>
</section>
</div>
<script th:inline="javascript"> <script th:inline="javascript">
const serverRenderedSettings = /*[[${settingsJson}]]*/; const serverRenderedSettings = /*[[${settingsJson}]]*/;
const serverRenderedInitialSysadmin = /*[[${initialSysadmin}]]*/; const serverRenderedInitialSysadmin = /*[[${initialSysadmin}]]*/;
@@ -255,6 +326,7 @@
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/session-refresh.js"></script> <script src="/js/session-refresh.js"></script>
<script src="/js/settings.js"></script> <script src="/js/settings.js"></script>
<script src="/js/copyright-reports.js"></script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
</body> </body>
</html> </html>