diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/reveal_requests.js b/backend/src/routes/reveal_requests.js index 97cbe5e..4cab2f4 100644 --- a/backend/src/routes/reveal_requests.js +++ b/backend/src/routes/reveal_requests.js @@ -2,6 +2,7 @@ const express = require('express'); const Reveal_requestsService = require('../services/reveal_requests'); +const CopyrightStudioService = require('../services/copyrightStudio'); const Reveal_requestsDBApi = require('../db/api/reveal_requests'); const wrapAsync = require('../helpers').wrapAsync; @@ -96,6 +97,21 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.get('/studio-feed', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.getStudioFeed(req.currentUser); + res.status(200).send(payload); +})); + +router.get('/studio-result/:id', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.getResultDetail(req.params.id); + res.status(200).send(payload); +})); + +router.post('/run', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.runReveal(req.body.data || {}, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/budgets/bulk-import: diff --git a/backend/src/services/copyrightStudio.js b/backend/src/services/copyrightStudio.js new file mode 100644 index 0000000..9dd19b0 --- /dev/null +++ b/backend/src/services/copyrightStudio.js @@ -0,0 +1,482 @@ +const crypto = require('crypto'); +const db = require('../db/models'); +const Reveal_requestsDBApi = require('../db/api/reveal_requests'); +const Reveal_resultsDBApi = require('../db/api/reveal_results'); + +const INPUT_TYPES = ['text', 'url', 'file']; + +function createValidationError(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function normalizeValue(value) { + return (value || '') + .toString() + .toLowerCase() + .replace(/https?:\/\//g, '') + .replace(/www\./g, '') + .replace(/[._-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function tokenize(value) { + return Array.from( + new Set( + normalizeValue(value) + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 2), + ), + ); +} + +function getFileStem(name) { + const parts = (name || '').split('.'); + if (parts.length <= 1) { + return normalizeValue(name); + } + + parts.pop(); + return normalizeValue(parts.join('.')); +} + +function toPlainArray(records) { + return records.map((record) => record.get({ plain: true })); +} + +function buildSourceSummary(data) { + const uploadedFiles = Array.isArray(data.uploaded_files) ? data.uploaded_files : []; + + return [ + data.request_title, + data.input_text, + data.input_url, + uploadedFiles.map((file) => file.name).join(' '), + ] + .filter(Boolean) + .join(' | ') + .trim(); +} + +function createFingerprint(data) { + const payload = { + request_title: data.request_title || '', + input_type: data.input_type || '', + input_text: data.input_text || '', + input_url: data.input_url || '', + uploaded_files: (Array.isArray(data.uploaded_files) ? data.uploaded_files : []).map( + (file) => ({ + name: file.name, + sizeInBytes: file.sizeInBytes, + }), + ), + }; + + return crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex'); +} + +function scoreWork(work, payload) { + const reasons = []; + let score = 0; + + const normalizedWorkTitle = normalizeValue(work.title); + const normalizedAuthorName = normalizeValue(work.author_name); + const normalizedExternalUrl = normalizeValue(work.external_source_url); + const normalizedDescription = normalizeValue(work.description); + const workFileStems = (work.original_files || []).map((file) => getFileStem(file.name)); + + if (payload.normalizedTitle && normalizedWorkTitle) { + if (payload.normalizedTitle === normalizedWorkTitle) { + score += 52; + reasons.push('Exact title match'); + } else if ( + normalizedWorkTitle.includes(payload.normalizedTitle) || + payload.normalizedTitle.includes(normalizedWorkTitle) + ) { + score += 28; + reasons.push('Strong title similarity'); + } else { + const sharedTitleTokens = payload.titleTokens.filter((token) => + tokenize(work.title).includes(token), + ).length; + + if (sharedTitleTokens > 0) { + score += Math.min(20, sharedTitleTokens * 6); + reasons.push('Title keywords overlap'); + } + } + } + + if (payload.computedHash && work.content_hash && payload.computedHash === work.content_hash) { + score += 32; + reasons.push('Registered content hash match'); + } + + if ( + payload.computedFingerprint && + work.fingerprint && + payload.computedFingerprint === work.fingerprint + ) { + score += 26; + reasons.push('Fingerprint signature match'); + } + + if (payload.normalizedUrl && normalizedExternalUrl) { + if (payload.normalizedUrl === normalizedExternalUrl) { + score += 24; + reasons.push('Source URL match'); + } else if ( + normalizedExternalUrl.includes(payload.normalizedUrl) || + payload.normalizedUrl.includes(normalizedExternalUrl) + ) { + score += 12; + reasons.push('Related source URL'); + } + } + + if (normalizedAuthorName && payload.sourceText.includes(normalizedAuthorName)) { + score += 10; + reasons.push('Author signature appears in the request'); + } + + if (normalizedDescription && payload.searchTokens.length) { + const descriptionHits = payload.searchTokens.filter((token) => + normalizedDescription.includes(token), + ).length; + + if (descriptionHits > 0) { + score += Math.min(14, descriptionHits * 3); + reasons.push('Description keywords align'); + } + } + + if (payload.fileStems.length && workFileStems.length) { + const exactStemMatch = payload.fileStems.find((stem) => workFileStems.includes(stem)); + if (exactStemMatch) { + score += 18; + reasons.push('Uploaded filename matches a registered asset'); + } else { + const partialStemMatch = payload.fileStems.find((stem) => + workFileStems.some((workStem) => workStem.includes(stem) || stem.includes(workStem)), + ); + + if (partialStemMatch) { + score += 10; + reasons.push('Uploaded filename resembles a registered asset'); + } + } + } + + return { + work, + score: Math.min(score, 99), + reasons: Array.from(new Set(reasons)), + }; +} + +function classifyResult(score) { + if (score >= 75) { + return 'match'; + } + + if (score >= 42) { + return 'possible_match'; + } + + return 'no_match'; +} + +function buildResultNotes(topCandidate, resultType) { + if (!topCandidate) { + return 'No registered work was close enough to verify ownership. Try refining the title, text, or uploaded evidence.'; + } + + if (resultType === 'match') { + return `High-confidence reveal: ${topCandidate.work.title || 'Untitled work'} is the strongest ownership match.`; + } + + if (resultType === 'possible_match') { + return `Possible reveal found for ${topCandidate.work.title || 'Untitled work'}. Review the evidence before relying on it.`; + } + + return 'No strong ownership match was found. You can still review the closest candidate below.'; +} + +function formatCandidate(candidate) { + return { + id: candidate.work.id, + title: candidate.work.title, + author_name: candidate.work.author_name, + work_type: candidate.work.work_type, + visibility: candidate.work.visibility, + registered_at: candidate.work.registered_at, + confidence_score: Number((candidate.score / 100).toFixed(2)), + match_reasons: candidate.reasons, + }; +} + +module.exports = class CopyrightStudioService { + static validatePayload(data) { + const inputType = data.input_type || 'text'; + + if (!INPUT_TYPES.includes(inputType)) { + throw createValidationError('Choose a valid reveal mode.'); + } + + if (!data.request_title || !data.request_title.trim()) { + throw createValidationError('Give this reveal request a title so it can be tracked.'); + } + + if (inputType === 'text' && !data.input_text?.trim()) { + throw createValidationError('Paste some text to compare against registered works.'); + } + + if (inputType === 'url' && !data.input_url?.trim()) { + throw createValidationError('Enter a source URL to inspect.'); + } + + if (inputType === 'file' && !(Array.isArray(data.uploaded_files) && data.uploaded_files.length)) { + throw createValidationError('Upload at least one file to run a reveal.'); + } + } + + static async runReveal(data, currentUser) { + this.validatePayload(data); + + const sourceSummary = buildSourceSummary(data); + const computedHash = crypto.createHash('sha256').update(sourceSummary).digest('hex'); + const computedFingerprint = createFingerprint(data); + const normalizedTitle = normalizeValue(data.request_title); + const normalizedUrl = normalizeValue(data.input_url); + const fileStems = (Array.isArray(data.uploaded_files) ? data.uploaded_files : []).map((file) => + getFileStem(file.name), + ); + + const payload = { + normalizedTitle, + normalizedUrl, + titleTokens: tokenize(data.request_title), + searchTokens: tokenize(sourceSummary), + sourceText: normalizeValue(sourceSummary), + computedHash, + computedFingerprint, + fileStems, + }; + + const works = await db.works.findAll({ + attributes: [ + 'id', + 'title', + 'author_name', + 'work_type', + 'description', + 'external_source_url', + 'license_terms', + 'content_hash', + 'fingerprint', + 'registered_at', + 'visibility', + ], + include: [ + { + model: db.file, + as: 'original_files', + attributes: ['id', 'name', 'sizeInBytes', 'publicUrl', 'privateUrl'], + }, + ], + order: [ + ['registered_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 100, + }); + + const rankedCandidates = toPlainArray(works) + .map((work) => scoreWork(work, payload)) + .sort((left, right) => right.score - left.score) + .slice(0, 3); + + const topCandidate = rankedCandidates[0]; + const resultType = classifyResult(topCandidate?.score || 0); + const confidenceScore = Number((((topCandidate?.score || 8) / 100)).toFixed(2)); + const startedAt = new Date(); + const completedAt = new Date(); + const notes = buildResultNotes(topCandidate, resultType); + const matchedFields = (topCandidate?.reasons || ['No close match signals were detected']).join(', '); + + const transaction = await db.sequelize.transaction(); + + try { + const revealRequest = await Reveal_requestsDBApi.create( + { + request_title: data.request_title, + input_type: data.input_type, + input_text: data.input_text || null, + input_url: data.input_url || null, + computed_hash: computedHash, + computed_fingerprint: computedFingerprint, + status: 'completed', + started_at: startedAt, + completed_at: completedAt, + uploaded_files: Array.isArray(data.uploaded_files) ? data.uploaded_files : [], + requested_by: currentUser.id, + }, + { + currentUser, + transaction, + }, + ); + + const revealResult = await Reveal_resultsDBApi.create( + { + result_type: resultType, + confidence_score: confidenceScore, + matched_fields: matchedFields, + notes, + generated_at: completedAt, + request: revealRequest.id, + matched_work: resultType === 'no_match' ? null : topCandidate?.work?.id || null, + created_by_user: currentUser.id, + evidence_files: Array.isArray(data.uploaded_files) ? data.uploaded_files : [], + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + + const detail = await this.getResultDetail(revealResult.id); + + return { + request: detail.request, + result: detail.result, + candidates: rankedCandidates.map(formatCandidate), + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async getStudioFeed(currentUser) { + const [recentResults, featuredWorks, totalWorks, currentUserReveals] = await Promise.all([ + db.reveal_results.findAll({ + where: { + created_by_userId: currentUser.id, + }, + include: [ + { + model: db.reveal_requests, + as: 'request', + }, + { + model: db.works, + as: 'matched_work', + }, + ], + order: [ + ['generated_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 6, + }), + db.works.findAll({ + attributes: [ + 'id', + 'title', + 'author_name', + 'work_type', + 'registered_at', + 'visibility', + 'license_terms', + ], + order: [ + ['registered_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }), + db.works.count(), + db.reveal_results.count({ + where: { + created_by_userId: currentUser.id, + }, + }), + ]); + + return { + stats: { + totalWorks, + currentUserReveals, + matchableWorks: featuredWorks.filter((work) => !!work.license_terms).length, + }, + recentResults: toPlainArray(recentResults), + featuredWorks: toPlainArray(featuredWorks), + }; + } + + static async getResultDetail(id) { + const result = await db.reveal_results.findOne({ + where: { id }, + include: [ + { + model: db.reveal_requests, + as: 'request', + include: [ + { + model: db.file, + as: 'uploaded_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'requested_by', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }, + { + model: db.works, + as: 'matched_work', + include: [ + { + model: db.file, + as: 'original_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'owner', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }, + { + model: db.file, + as: 'evidence_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'created_by_user', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }); + + if (!result) { + const error = new Error('Reveal result not found.'); + error.code = 404; + throw error; + } + + return { + result: result.get({ plain: true }), + request: result.request ? result.request.get({ plain: true }) : null, + }; + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/helpers/copyrightStudio.ts b/frontend/src/helpers/copyrightStudio.ts new file mode 100644 index 0000000..a629ff1 --- /dev/null +++ b/frontend/src/helpers/copyrightStudio.ts @@ -0,0 +1,66 @@ +export function formatStudioDate(value?: string | null) { + if (!value) { + return 'Just now'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Recently'; + } + + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function formatPercent(value?: number | null) { + const safeValue = typeof value === 'number' ? value : 0; + return `${Math.round(safeValue * 100)}%`; +} + +export function getRevealTone(resultType?: string | null) { + switch (resultType) { + case 'match': + return 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200'; + case 'possible_match': + return 'border-amber-400/60 bg-amber-500/10 text-amber-100'; + case 'error': + return 'border-rose-400/60 bg-rose-500/10 text-rose-100'; + default: + return 'border-slate-400/40 bg-slate-500/10 text-slate-100'; + } +} + +export function getRevealLabel(resultType?: string | null) { + switch (resultType) { + case 'match': + return 'Verified match'; + case 'possible_match': + return 'Possible match'; + case 'error': + return 'Review needed'; + default: + return 'No strong match'; + } +} + +export function getWorkTypeLabel(workType?: string | null) { + if (!workType) { + return 'Unknown'; + } + + return `${workType.charAt(0).toUpperCase()}${workType.slice(1)}`; +} + +export function getPersonName(person?: { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +} | null) { + const fullName = [person?.firstName, person?.lastName].filter(Boolean).join(' '); + return fullName || person?.email || 'Unknown'; +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 9cc6de5..4581125 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -40,6 +40,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiCopyright' in icon ? icon['mdiCopyright' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_WORKS' }, + { + href: '/reveal-studio', + label: 'Reveal Studio', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFingerprint' in icon ? icon['mdiFingerprint' as keyof typeof icon] : icon.mdiMagnifyScan ?? icon.mdiTable, + permissions: 'CREATE_REVEAL_REQUESTS' + }, { href: '/reveal_requests/reveal_requests-list', label: 'Reveal requests', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index ed06bbc..c102c62 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -99,6 +99,30 @@ const Dashboard = () => { main> {''} + + {hasPermission(currentUser, 'CREATE_REVEAL_REQUESTS') && +
+
+
+

Reveal Studio

+

Run a copyright check and keep the evidence trail.

+

Launch the new MVP workflow to compare creative signals against registered works, then jump straight into reports or record review.

+
+
+ + Open Reveal Studio +
+
+
+ } {hasPermission(currentUser, 'CREATE_ROLES') && state.style.linkColor); - - const title = 'Copyright Revealer' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const workflowSteps = [ + 'Register a work with title, author, files, and licensing notes.', + 'Run Reveal Studio with text, URL, or file evidence.', + 'Review the confidence score, matched signals, and suggested next action.', +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Copyright Revealer')} - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+

+ Copyright Revealer +

+

+ Interactive copyright verification for creators, rights teams, and admins. +

- - - +
+ + +
+
- - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+ + Retro signal dashboard + +

+ Reveal who owns a creative asset before it gets reused. +

+

+ Copyright Revealer helps teams register works, run a fast ownership check, browse + proof records, and escalate suspicious usage from one responsive dashboard. +

+
+ + +
+
+ {featureCards.map((card) => ( +
+
+ +
+

{card.title}

+

{card.description}

+
+ ))} +
+
-
+
+
+
+
+

+ MVP workflow +

+

First usable slice

+
+
+ +
+
+ +
+ {workflowSteps.map((step, index) => ( +
+
+
+ {index + 1} +
+

{step}

+
+
+ ))} +
+ +
+

Designed for creators and admins

+

+ The current MVP includes a public landing page, a Reveal Studio workflow, + result detail views, and direct paths into the admin CRUD screens. +

+
+
+
+
+
+ +
+
+

Why teams use it

+

A single place for proof, review, and action.

+

+ Instead of juggling screenshots, spreadsheets, and manual notes, the app keeps the + reveal history attached to registered works and next-step admin actions. +

+
+
+ {[ + ['Creators', 'Register and verify ownership before publishing or licensing.'], + ['Reviewers', 'Check match signals quickly and compare the evidence trail.'], + ['Admins', 'Move from reveal results into reports, claims, and record cleanup.'], + ].map(([title, copy]) => ( +
+

{title}

+

{copy}

+
+ ))} +
+
+ + + ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/reveal-studio.tsx b/frontend/src/pages/reveal-studio.tsx new file mode 100644 index 0000000..e32fce2 --- /dev/null +++ b/frontend/src/pages/reveal-studio.tsx @@ -0,0 +1,673 @@ +import { + mdiArrowRight, + mdiChartTimelineVariant, + mdiClockOutline, + mdiFileSearchOutline, + mdiFingerprint, + mdiImageFilterCenterFocus, + mdiLinkVariant, + mdiPlusBoxOutline, + mdiShieldCheckOutline, + mdiUpload, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import FileUploader from '../components/Uploaders/UploadService'; +import { getPageTitle } from '../config'; +import { + formatPercent, + formatStudioDate, + getRevealLabel, + getRevealTone, + getWorkTypeLabel, +} from '../helpers/copyrightStudio'; +import { hasPermission } from '../helpers/userPermissions'; +import { useAppSelector } from '../stores/hooks'; + +type UploadedFile = { + id: string; + name: string; + sizeInBytes?: number; + privateUrl?: string; + publicUrl?: string; +}; + +type StudioResult = { + id: string; + result_type: string; + confidence_score: number; + matched_fields?: string; + notes?: string; + generated_at?: string; + request?: { + id: string; + request_title?: string; + input_type?: string; + }; + matched_work?: { + id: string; + title?: string; + author_name?: string; + work_type?: string; + visibility?: string; + } | null; +}; + +type CandidateMatch = { + id: string; + title?: string; + author_name?: string; + work_type?: string; + visibility?: string; + confidence_score?: number; + match_reasons?: string[]; +}; + +type FeaturedWork = { + id: string; + title?: string; + author_name?: string; + work_type?: string; + visibility?: string; + license_terms?: string; + registered_at?: string; +}; + +type StudioFeed = { + stats: { + totalWorks: number; + currentUserReveals: number; + matchableWorks: number; + }; + recentResults: StudioResult[]; + featuredWorks: FeaturedWork[]; +}; + +const revealModes = [ + { + id: 'text', + label: 'Text scan', + description: 'Paste lyrics, captions, script excerpts, or article copy.', + icon: mdiFingerprint, + }, + { + id: 'url', + label: 'URL inspect', + description: 'Check a source page or media link against registered records.', + icon: mdiLinkVariant, + }, + { + id: 'file', + label: 'File evidence', + description: 'Upload a creative asset and compare metadata fingerprints.', + icon: mdiUpload, + }, +] as const; + +const initialFeed: StudioFeed = { + stats: { + totalWorks: 0, + currentUserReveals: 0, + matchableWorks: 0, + }, + recentResults: [], + featuredWorks: [], +}; + +const RevealStudioPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [feed, setFeed] = useState(initialFeed); + const [requestTitle, setRequestTitle] = useState(''); + const [inputType, setInputType] = useState<'text' | 'url' | 'file'>('text'); + const [inputText, setInputText] = useState(''); + const [inputUrl, setInputUrl] = useState(''); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoadingFeed, setIsLoadingFeed] = useState(true); + const [submitError, setSubmitError] = useState(''); + const [uploadError, setUploadError] = useState(''); + const [activeResult, setActiveResult] = useState<{ + result: StudioResult; + request?: { + id: string; + request_title?: string; + input_type?: string; + } | null; + candidates: CandidateMatch[]; + } | null>(null); + + const canReveal = hasPermission(currentUser, 'CREATE_REVEAL_REQUESTS'); + const canBrowseWorks = hasPermission(currentUser, 'READ_WORKS'); + + const statCards = useMemo( + () => [ + { + label: 'Registered works', + value: feed.stats.totalWorks, + hint: 'Protected records ready for matching', + icon: mdiImageFilterCenterFocus, + }, + { + label: 'Your reveal runs', + value: feed.stats.currentUserReveals, + hint: 'Interactive checks you have completed', + icon: mdiClockOutline, + }, + { + label: 'Licensing-ready works', + value: feed.stats.matchableWorks, + hint: 'Recent records with explicit rights notes', + icon: mdiShieldCheckOutline, + }, + ], + [feed.stats], + ); + + const loadStudioFeed = async () => { + setIsLoadingFeed(true); + try { + const response = await axios.get('/reveal_requests/studio-feed'); + setFeed(response.data); + } catch (error) { + console.error('Failed to load reveal studio feed:', error); + } finally { + setIsLoadingFeed(false); + } + }; + + useEffect(() => { + if (!currentUser) { + return; + } + + loadStudioFeed(); + }, [currentUser]); + + const resetModeFields = (mode: 'text' | 'url' | 'file') => { + setInputType(mode); + setSubmitError(''); + + if (mode !== 'text') { + setInputText(''); + } + if (mode !== 'url') { + setInputUrl(''); + } + if (mode !== 'file') { + setUploadedFiles([]); + setUploadError(''); + } + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + try { + setUploadError(''); + setIsUploading(true); + const remoteFile = await FileUploader.upload('reveal_requests/uploaded_files', file, { + size: 10 * 1024 * 1024, + }); + setUploadedFiles([remoteFile]); + } catch (error) { + console.error('Failed to upload reveal evidence:', error); + setUploadError(error instanceof Error ? error.message : 'Upload failed.'); + setUploadedFiles([]); + } finally { + setIsUploading(false); + event.target.value = ''; + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!canReveal) { + setSubmitError('Your role does not currently allow reveal runs.'); + return; + } + + setIsSubmitting(true); + setSubmitError(''); + + try { + const response = await axios.post('/reveal_requests/run', { + data: { + request_title: requestTitle, + input_type: inputType, + input_text: inputText, + input_url: inputUrl, + uploaded_files: uploadedFiles, + }, + }); + + setActiveResult(response.data); + setRequestTitle(''); + setInputText(''); + setInputUrl(''); + setUploadedFiles([]); + await loadStudioFeed(); + } catch (error) { + console.error('Reveal run failed:', error); + if (axios.isAxiosError(error)) { + setSubmitError(error.response?.data || 'Reveal run failed.'); + } else { + setSubmitError('Reveal run failed.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + {getPageTitle('Reveal Studio')} + + + + {''} + + +
+
+
+ + Copyright revealer MVP + +

+ Reveal the strongest ownership match in seconds. +

+

+ Run a focused metadata-and-text reveal, compare it against your registered works, + and keep a clean evidence trail for follow-up reviews or fraud reports. +

+
+ + +
+
+
+ {statCards.map((card) => ( +
+
+
+

{card.label}

+

{card.value}

+

{card.hint}

+
+
+ +
+
+
+ ))} +
+
+
+ +
+ +
+
+
+

+ Reveal input +

+

Run a new ownership check

+

+ Start with one clear signal: paste text, inspect a source URL, or upload a + creative asset for metadata fingerprinting. +

+
+
+ +
+
+ +
+
+ + setRequestTitle(event.target.value)} + placeholder='Example: Summer campaign hero image' + className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-slate-500 focus:border-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-400/40' + /> +
+ +
+

Reveal mode

+
+ {revealModes.map((mode) => { + const isActive = inputType === mode.id; + return ( + + ); + })} +
+
+ + {inputType === 'text' && ( +
+ +