mirror of
https://github.com/imgfloat/server.git
synced 2026-05-08 10:19:35 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createAdminConsole } from "./admin/console.js";
|
||||
import { createCustomAssetModal } from "./customAssets.js";
|
||||
import "./report.js";
|
||||
|
||||
let adminConsole;
|
||||
const customAssetModal = createCustomAssetModal({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)} <${escHtml(r.claimantEmail)}></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)} <${escHtml(report.claimantEmail)}></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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -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, ">")
|
||||
.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 = '<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> — 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";
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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 };
|
||||
@@ -341,6 +341,17 @@
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</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>
|
||||
@@ -538,6 +549,48 @@
|
||||
</form>
|
||||
</section>
|
||||
</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">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
const username = /*[[${username}]]*/ "";
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<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>
|
||||
<a class="version-badge version-link" href="/report">Report abuse</a>
|
||||
<a
|
||||
class="version-badge version-link"
|
||||
th:href="${docsUrl}"
|
||||
|
||||
@@ -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">✓</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>
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
</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">
|
||||
const serverRenderedSettings = /*[[${settingsJson}]]*/;
|
||||
const serverRenderedInitialSysadmin = /*[[${initialSysadmin}]]*/;
|
||||
@@ -255,6 +326,7 @@
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/session-refresh.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
<script src="/js/copyright-reports.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user