This commit is contained in:
Flatlogic Bot 2026-04-05 14:13:54 +00:00
parent a30ca5a54f
commit c21ed70146
12 changed files with 1789 additions and 152 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -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:

View File

@ -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,
};
}
};

View File

@ -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'

View File

@ -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';
}

View File

@ -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'

View File

@ -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',

View File

@ -99,6 +99,30 @@ const Dashboard = () => {
main>
{''}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_REVEAL_REQUESTS') && <Link href={'/reveal-studio'}>
<div className={`mb-6 overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border border-cyan-300/20 bg-[linear-gradient(135deg,#0B1230_0%,#111A43_55%,#0A1026_100%)] p-6 text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]`}>
<div className='flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>Reveal Studio</p>
<h2 className='mt-3 text-3xl font-black tracking-tight'>Run a copyright check and keep the evidence trail.</h2>
<p className='mt-3 max-w-2xl text-sm leading-7 text-slate-300'>Launch the new MVP workflow to compare creative signals against registered works, then jump straight into reports or record review.</p>
</div>
<div className='inline-flex items-center gap-3 rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-cyan-100'>
<BaseIcon
className={`${iconsColor}`}
w='w-8'
h='h-8'
size={28}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiFingerprint' in icon ? icon['mdiFingerprint' as keyof typeof icon] : icon.mdiMagnifyScan || icon.mdiTable}
/>
<span className='text-sm font-semibold'>Open Reveal Studio</span>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}

View File

@ -1,166 +1,166 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiArrowRight,
mdiFingerprint,
mdiImageFilterCenterFocus,
mdiMagnifyScan,
mdiShieldCheckOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const featureCards = [
{
title: 'Upload or paste evidence',
description:
'Start with text, a source URL, or an asset file and build a reveal request in one focused flow.',
icon: mdiFingerprint,
},
{
title: 'Reveal the strongest match',
description:
'Compare creative signals against your registered catalog and surface the most likely ownership record fast.',
icon: mdiMagnifyScan,
},
{
title: 'Escalate with confidence',
description:
'Turn a suspicious reveal into a fraud report, ownership claim, or admin review without losing context.',
icon: mdiShieldCheckOutline,
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => 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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
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 (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Copyright Revealer')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Copyright Revealer app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main className='min-h-screen bg-[#070B18] text-white'>
<section className='mx-auto max-w-7xl px-6 pb-16 pt-6 lg:px-8'>
<header className='flex flex-col gap-4 rounded-3xl border border-white/10 bg-white/5 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-cyan-200'>
Copyright Revealer
</p>
<p className='mt-2 text-sm text-slate-300'>
Interactive copyright verification for creators, rights teams, and admins.
</p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='white' outline label='Login' />
<BaseButton href='/dashboard' color='info' label='Admin interface' icon={mdiViewDashboardOutline} />
</div>
</header>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className='mt-8 overflow-hidden rounded-[2rem] border border-[#24315E] bg-[radial-gradient(circle_at_top_left,_rgba(74,222,255,0.18),_transparent_35%),linear-gradient(135deg,#0B1230_0%,#111A43_55%,#0A1026_100%)] shadow-[0_30px_90px_rgba(2,6,23,0.45)]'>
<div className='grid gap-10 px-6 py-14 lg:grid-cols-[1.15fr_0.85fr] lg:px-10'>
<div>
<span className='inline-flex rounded-full border border-fuchsia-400/35 bg-fuchsia-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-fuchsia-100'>
Retro signal dashboard
</span>
<h1 className='mt-6 max-w-3xl text-4xl font-black tracking-tight text-white md:text-6xl'>
Reveal who owns a creative asset before it gets reused.
</h1>
<p className='mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg'>
Copyright Revealer helps teams register works, run a fast ownership check, browse
proof records, and escalate suspicious usage from one responsive dashboard.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton href='/reveal-studio' color='info' label='Open Reveal Studio' icon={mdiArrowRight} />
<BaseButton href='/works/works-list' color='white' outline label='Browse works' icon={mdiImageFilterCenterFocus} />
</div>
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
{featureCards.map((card) => (
<div key={card.title} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<div className='inline-flex rounded-2xl bg-cyan-400/10 p-3 text-cyan-200'>
<BaseIcon path={card.icon} size={22} />
</div>
<h2 className='mt-4 text-lg font-semibold text-white'>{card.title}</h2>
<p className='mt-2 text-sm leading-7 text-slate-400'>{card.description}</p>
</div>
))}
</div>
</div>
</div>
<div className='rounded-[1.75rem] border border-white/10 bg-[#071028]/90 p-5 shadow-[0_24px_60px_rgba(3,7,18,0.42)]'>
<div className='rounded-[1.5rem] border border-cyan-300/20 bg-[#0D173A] p-5'>
<div className='flex items-center justify-between'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>
MVP workflow
</p>
<p className='mt-2 text-2xl font-bold text-white'>First usable slice</p>
</div>
<div className='rounded-2xl bg-cyan-400/10 p-3 text-cyan-200'>
<BaseIcon path={mdiFingerprint} size={24} />
</div>
</div>
<div className='mt-6 space-y-4'>
{workflowSteps.map((step, index) => (
<div key={step} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<div className='flex items-start gap-4'>
<div className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-fuchsia-400/15 text-sm font-black text-fuchsia-100'>
{index + 1}
</div>
<p className='text-sm leading-7 text-slate-200'>{step}</p>
</div>
</div>
))}
</div>
<div className='mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 p-4'>
<p className='text-sm font-semibold text-emerald-100'>Designed for creators and admins</p>
<p className='mt-2 text-sm leading-7 text-emerald-50'>
The current MVP includes a public landing page, a Reveal Studio workflow,
result detail views, and direct paths into the admin CRUD screens.
</p>
</div>
</div>
</div>
</div>
</div>
<section className='mt-10 grid gap-6 lg:grid-cols-[0.9fr_1.1fr]'>
<div className='rounded-[1.75rem] border border-white/10 bg-white/5 p-6'>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>Why teams use it</p>
<h2 className='mt-3 text-3xl font-bold text-white'>A single place for proof, review, and action.</h2>
<p className='mt-4 text-sm leading-7 text-slate-300'>
Instead of juggling screenshots, spreadsheets, and manual notes, the app keeps the
reveal history attached to registered works and next-step admin actions.
</p>
</div>
<div className='grid gap-4 md:grid-cols-3'>
{[
['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]) => (
<div key={title} className='rounded-[1.75rem] border border-white/10 bg-[#0C1432] p-5'>
<p className='text-lg font-semibold text-white'>{title}</p>
<p className='mt-3 text-sm leading-7 text-slate-400'>{copy}</p>
</div>
))}
</div>
</section>
</section>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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<StudioFeed>(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<UploadedFile[]>([]);
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<HTMLInputElement>) => {
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<HTMLFormElement>) => {
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 (
<>
<Head>
<title>{getPageTitle('Reveal Studio')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Reveal Studio' main>
{''}
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl border border-[#2B3565] bg-[#0C1130] text-white shadow-[0_28px_80px_rgba(9,12,28,0.45)]'>
<div className='grid gap-8 px-6 py-8 lg:grid-cols-[1.3fr_0.8fr] lg:px-8'>
<div>
<span className='inline-flex rounded-full border border-fuchsia-400/40 bg-fuchsia-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-fuchsia-100'>
Copyright revealer MVP
</span>
<h1 className='mt-4 max-w-2xl text-3xl font-black tracking-tight text-white md:text-5xl'>
Reveal the strongest ownership match in seconds.
</h1>
<p className='mt-4 max-w-2xl text-base leading-7 text-slate-300 md:text-lg'>
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.
</p>
<div className='mt-6 flex flex-wrap gap-3'>
<BaseButton href='/works/works-new' color='info' label='Register new work' icon={mdiPlusBoxOutline} />
<BaseButton href='/reveal_results/reveal_results-list' color='white' outline label='Open result archive' icon={mdiFileSearchOutline} />
</div>
</div>
<div className='grid gap-4 sm:grid-cols-3 lg:grid-cols-1'>
{statCards.map((card) => (
<div key={card.label} className='rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-sm text-slate-400'>{card.label}</p>
<p className='mt-2 text-3xl font-black text-white'>{card.value}</p>
<p className='mt-2 text-sm text-slate-300'>{card.hint}</p>
</div>
<div className='rounded-2xl bg-cyan-400/10 p-3 text-cyan-200'>
<BaseIcon path={card.icon} size={24} />
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
<CardBox className='border-[#1A2451] bg-gradient-to-br from-[#10183D] via-[#0F1536] to-[#0A1026] text-white shadow-[0_26px_60px_rgba(7,12,33,0.35)]'>
<div className='flex flex-col gap-6'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>
Reveal input
</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Run a new ownership check</h2>
<p className='mt-2 max-w-2xl text-sm leading-6 text-slate-300'>
Start with one clear signal: paste text, inspect a source URL, or upload a
creative asset for metadata fingerprinting.
</p>
</div>
<div className='rounded-2xl border border-cyan-400/20 bg-cyan-400/10 p-3 text-cyan-200'>
<BaseIcon path={mdiFingerprint} size={28} />
</div>
</div>
<form className='space-y-6' onSubmit={handleSubmit}>
<div>
<label className='mb-2 block text-sm font-semibold text-slate-200' htmlFor='requestTitle'>
Request title
</label>
<input
id='requestTitle'
value={requestTitle}
onChange={(event) => 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'
/>
</div>
<div>
<p className='mb-3 text-sm font-semibold text-slate-200'>Reveal mode</p>
<div className='grid gap-3 md:grid-cols-3'>
{revealModes.map((mode) => {
const isActive = inputType === mode.id;
return (
<button
key={mode.id}
type='button'
onClick={() => resetModeFields(mode.id)}
className={`rounded-2xl border px-4 py-4 text-left transition ${
isActive
? 'border-cyan-300 bg-cyan-400/10 text-white shadow-[0_10px_30px_rgba(34,211,238,0.15)]'
: 'border-white/10 bg-white/5 text-slate-300 hover:border-cyan-300/40 hover:bg-white/10'
}`}
>
<div className='mb-3 inline-flex rounded-xl bg-[#111B44] p-2 text-cyan-200'>
<BaseIcon path={mode.icon} size={20} />
</div>
<p className='font-semibold'>{mode.label}</p>
<p className='mt-1 text-sm leading-6 text-slate-400'>{mode.description}</p>
</button>
);
})}
</div>
</div>
{inputType === 'text' && (
<div>
<label className='mb-2 block text-sm font-semibold text-slate-200' htmlFor='inputText'>
Text to compare
</label>
<textarea
id='inputText'
rows={7}
value={inputText}
onChange={(event) => setInputText(event.target.value)}
placeholder='Paste a caption, article snippet, lyrics, narration, or any creative text you want to compare.'
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'
/>
</div>
)}
{inputType === 'url' && (
<div>
<label className='mb-2 block text-sm font-semibold text-slate-200' htmlFor='inputUrl'>
Source URL
</label>
<input
id='inputUrl'
value={inputUrl}
onChange={(event) => setInputUrl(event.target.value)}
placeholder='https://example.com/original-story'
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'
/>
</div>
)}
{inputType === 'file' && (
<div className='rounded-2xl border border-dashed border-cyan-300/30 bg-white/5 p-5'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm font-semibold text-slate-100'>Upload a creative file</p>
<p className='mt-1 text-sm leading-6 text-slate-400'>
The first slice uses filename and metadata fingerprinting to compare against
the registered catalog.
</p>
</div>
<label className='inline-flex cursor-pointer items-center gap-2 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-200 hover:bg-cyan-400/20'>
<BaseIcon path={mdiUpload} size={18} />
{isUploading ? 'Uploading…' : 'Choose file'}
<input type='file' className='hidden' onChange={handleFileUpload} disabled={isUploading} />
</label>
</div>
{uploadedFiles.length > 0 && (
<div className='mt-4 rounded-2xl border border-emerald-400/30 bg-emerald-500/10 p-4'>
<p className='text-sm font-semibold text-emerald-100'>Evidence ready</p>
<p className='mt-1 text-sm text-emerald-50'>{uploadedFiles[0].name}</p>
</div>
)}
{uploadError && <p className='mt-4 text-sm text-rose-200'>{uploadError}</p>}
</div>
)}
{submitError && (
<div className='rounded-2xl border border-rose-400/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100'>
{submitError}
</div>
)}
<div className='flex flex-wrap items-center gap-3'>
<BaseButton
type='submit'
color='info'
label={isSubmitting ? 'Revealing…' : 'Reveal copyright signals'}
icon={mdiArrowRight}
disabled={isSubmitting || isUploading}
/>
<p className='text-sm text-slate-400'>
Results are stored in your history so you can reopen the evidence trail later.
</p>
</div>
</form>
</div>
</CardBox>
<div className='space-y-6'>
<CardBox className='border-[#1A2451] bg-[#0E1534] text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-fuchsia-200'>
Last reveal result
</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Outcome snapshot</h2>
</div>
<BaseIcon path={mdiShieldCheckOutline} size={28} className='text-fuchsia-200' />
</div>
{!activeResult && (
<div className='mt-6 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm leading-7 text-slate-300'>
Run your first reveal to see the strongest match, confidence score, and next-step
actions here.
</div>
)}
{activeResult && (
<div className='mt-6 space-y-4'>
<div className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${getRevealTone(activeResult.result.result_type)}`}>
{getRevealLabel(activeResult.result.result_type)}
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-sm text-slate-400'>Confidence</p>
<p className='mt-2 text-4xl font-black text-white'>
{formatPercent(activeResult.result.confidence_score)}
</p>
</div>
<div className='rounded-2xl bg-[#121C48] px-4 py-3 text-right'>
<p className='text-xs uppercase tracking-[0.18em] text-slate-400'>Generated</p>
<p className='mt-1 text-sm text-slate-100'>
{formatStudioDate(activeResult.result.generated_at)}
</p>
</div>
</div>
<div className='mt-5 grid gap-4 rounded-2xl border border-white/10 bg-[#101B45] p-4'>
<div>
<p className='text-sm text-slate-400'>Matched work</p>
<p className='mt-2 text-lg font-semibold text-white'>
{activeResult.result.matched_work?.title || 'No direct registered work selected'}
</p>
<p className='mt-1 text-sm text-slate-300'>
{activeResult.result.matched_work?.author_name ||
'Register the work or review the top candidates below.'}
</p>
</div>
<div>
<p className='text-sm text-slate-400'>Signals used</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>
{activeResult.result.matched_fields}
</p>
</div>
<div>
<p className='text-sm text-slate-400'>Analyst note</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>{activeResult.result.notes}</p>
</div>
</div>
</div>
{activeResult.candidates.length > 0 && (
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm font-semibold text-slate-100'>Closest candidates</p>
<div className='mt-3 space-y-3'>
{activeResult.candidates.map((candidate) => (
<div key={candidate.id} className='rounded-2xl border border-white/10 bg-[#0B1230] p-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='font-semibold text-white'>{candidate.title || 'Untitled work'}</p>
<p className='mt-1 text-sm text-slate-300'>
{candidate.author_name || 'Unknown author'} · {getWorkTypeLabel(candidate.work_type)}
</p>
</div>
<span className='rounded-full bg-cyan-400/10 px-3 py-1 text-xs font-semibold text-cyan-100'>
{formatPercent(candidate.confidence_score)}
</span>
</div>
{!!candidate.match_reasons?.length && (
<p className='mt-3 text-sm leading-6 text-slate-400'>
{candidate.match_reasons.join(' · ')}
</p>
)}
</div>
))}
</div>
</div>
)}
<div className='flex flex-wrap gap-3'>
<BaseButton
href={`/reveal-studio/results/${activeResult.result.id}`}
color='info'
label='Open full result'
icon={mdiArrowRight}
/>
<BaseButton href='/fraud_reports/fraud_reports-new' color='white' outline label='Report suspicious use' />
</div>
</div>
)}
</CardBox>
<CardBox className='border-[#1A2451] bg-[#0E1534] text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>
Browse works
</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Fresh registered catalog</h2>
</div>
{canBrowseWorks && <BaseButton href='/works/works-list' color='white' outline label='See all' small />}
</div>
{isLoadingFeed && (
<div className='mt-6 rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300'>
Loading your studio feed
</div>
)}
{!isLoadingFeed && feed.featuredWorks.length === 0 && (
<div className='mt-6 rounded-2xl border border-white/10 bg-white/5 p-4 text-sm leading-7 text-slate-300'>
No works are registered yet. Add your first protected asset to power the reveal flow.
</div>
)}
{!isLoadingFeed && feed.featuredWorks.length > 0 && (
<div className='mt-6 space-y-3'>
{feed.featuredWorks.map((work) => (
<div key={work.id} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-white'>{work.title || 'Untitled work'}</p>
<p className='mt-1 text-sm text-slate-300'>
{work.author_name || 'Unknown author'} · {getWorkTypeLabel(work.work_type)}
</p>
</div>
<span className='rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.18em] text-slate-300'>
{work.visibility || 'private'}
</span>
</div>
<p className='mt-3 text-sm text-slate-400'>
{work.license_terms || 'No license notes yet.'}
</p>
<p className='mt-3 text-xs uppercase tracking-[0.18em] text-slate-500'>
Registered {formatStudioDate(work.registered_at)}
</p>
</div>
))}
</div>
)}
</CardBox>
</div>
</div>
<div className='mt-6'>
<CardBox className='border-[#1A2451] bg-[#0E1534] text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]'>
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>History</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Recent reveal trail</h2>
<p className='mt-2 text-sm leading-7 text-slate-400'>
Every run is saved so creators and admins can reopen the evidence trail and decide
whether to approve usage, register a missing work, or escalate a report.
</p>
</div>
<BaseButton href='/reveal_results/reveal_results-list' color='white' outline label='Open CRUD archive' />
</div>
{!isLoadingFeed && feed.recentResults.length === 0 && (
<div className='mt-6 rounded-2xl border border-dashed border-white/10 bg-white/5 p-5 text-sm leading-7 text-slate-300'>
You have not run a reveal yet. Start above to create your first evidence-backed result.
</div>
)}
{feed.recentResults.length > 0 && (
<div className='mt-6 grid gap-4 lg:grid-cols-3'>
{feed.recentResults.map((item) => (
<Link key={item.id} href={`/reveal-studio/results/${item.id}`} className='group rounded-3xl border border-white/10 bg-white/5 p-5 transition hover:border-cyan-300/40 hover:bg-white/10'>
<div className='flex items-center justify-between gap-3'>
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getRevealTone(item.result_type)}`}>
{getRevealLabel(item.result_type)}
</span>
<span className='text-sm font-semibold text-cyan-100'>
{formatPercent(item.confidence_score)}
</span>
</div>
<h3 className='mt-4 text-lg font-semibold text-white'>
{item.request?.request_title || 'Untitled reveal request'}
</h3>
<p className='mt-2 text-sm leading-6 text-slate-400'>
{item.matched_work?.title || 'No direct registered work selected yet'}
</p>
<p className='mt-4 text-xs uppercase tracking-[0.18em] text-slate-500'>
{formatStudioDate(item.generated_at)}
</p>
</Link>
))}
</div>
)}
</CardBox>
</div>
</SectionMain>
</>
);
};
RevealStudioPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_REVEAL_REQUESTS'>{page}</LayoutAuthenticated>;
};
export default RevealStudioPage;

View File

@ -0,0 +1,370 @@
import {
mdiArrowLeft,
mdiChartTimelineVariant,
mdiShieldCheckOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import BaseButton from '../../../components/BaseButton';
import CardBox from '../../../components/CardBox';
import LayoutAuthenticated from '../../../layouts/Authenticated';
import SectionMain from '../../../components/SectionMain';
import SectionTitleLineWithButton from '../../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../../config';
import {
formatPercent,
formatStudioDate,
getPersonName,
getRevealLabel,
getRevealTone,
getWorkTypeLabel,
} from '../../../helpers/copyrightStudio';
import { useRouter } from 'next/router';
type StudioDetail = {
request: {
id: string;
request_title?: string;
input_type?: string;
input_text?: string;
input_url?: string;
computed_hash?: string;
computed_fingerprint?: string;
uploaded_files?: Array<{
id: string;
name?: string;
publicUrl?: string;
}>;
requested_by?: {
firstName?: string;
lastName?: string;
email?: string;
};
} | null;
result: {
id: string;
result_type?: string;
confidence_score?: number;
matched_fields?: string;
notes?: string;
generated_at?: string;
evidence_files?: Array<{
id: string;
name?: string;
publicUrl?: string;
}>;
matched_work?: {
id: string;
title?: string;
author_name?: string;
work_type?: string;
description?: string;
license_terms?: string;
visibility?: string;
registered_at?: string;
owner?: {
firstName?: string;
lastName?: string;
email?: string;
};
original_files?: Array<{
id: string;
name?: string;
publicUrl?: string;
}>;
} | null;
created_by_user?: {
firstName?: string;
lastName?: string;
email?: string;
};
};
};
const RevealStudioResultPage = () => {
const router = useRouter();
const { resultId } = router.query;
const [detail, setDetail] = useState<StudioDetail | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
if (!resultId || typeof resultId !== 'string') {
return;
}
const loadResult = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get(`/reveal_requests/studio-result/${resultId}`);
setDetail(response.data);
} catch (error) {
console.error('Failed to load reveal result detail:', error);
if (axios.isAxiosError(error)) {
setErrorMessage(error.response?.data || 'Unable to load the reveal result.');
} else {
setErrorMessage('Unable to load the reveal result.');
}
} finally {
setLoading(false);
}
};
loadResult();
}, [resultId]);
return (
<>
<Head>
<title>{getPageTitle('Reveal Result')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Reveal Result Detail' main>
{''}
</SectionTitleLineWithButton>
<div className='mb-6 overflow-hidden rounded-3xl border border-[#2B3565] bg-[#0C1130] text-white shadow-[0_28px_80px_rgba(9,12,28,0.45)]'>
<div className='flex flex-col gap-6 px-6 py-8 lg:flex-row lg:items-center lg:justify-between lg:px-8'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>
Evidence detail
</p>
<h1 className='mt-3 text-3xl font-black tracking-tight text-white md:text-4xl'>
{detail?.request?.request_title || 'Reveal result'}
</h1>
<p className='mt-3 max-w-2xl text-base leading-7 text-slate-300'>
Review the matching signals, attached evidence, and registered work details before
approving use or escalating a suspicious claim.
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/reveal-studio' color='white' outline label='Back to studio' icon={mdiArrowLeft} />
<BaseButton href='/fraud_reports/fraud_reports-new' color='info' label='Create fraud report' icon={mdiShieldCheckOutline} />
</div>
</div>
</div>
{loading && (
<CardBox className='bg-[#0E1534] text-white'>
<p className='text-sm text-slate-300'>Loading reveal evidence</p>
</CardBox>
)}
{!loading && errorMessage && (
<CardBox className='bg-[#0E1534] text-white'>
<p className='text-sm text-rose-200'>{errorMessage}</p>
</CardBox>
)}
{!loading && detail && (
<div className='grid gap-6 xl:grid-cols-[1.05fr_0.95fr]'>
<CardBox className='bg-[#0E1534] text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-fuchsia-200'>
Result summary
</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Verification outcome</h2>
</div>
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${getRevealTone(detail.result.result_type)}`}>
{getRevealLabel(detail.result.result_type)}
</span>
</div>
<div className='mt-6 grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-400'>Confidence</p>
<p className='mt-2 text-3xl font-black text-white'>
{formatPercent(detail.result.confidence_score)}
</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-400'>Generated</p>
<p className='mt-2 text-sm leading-6 text-white'>
{formatStudioDate(detail.result.generated_at)}
</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm text-slate-400'>Requested by</p>
<p className='mt-2 text-sm leading-6 text-white'>
{getPersonName(detail.request?.requested_by || detail.result.created_by_user)}
</p>
</div>
</div>
<div className='mt-6 space-y-4 rounded-3xl border border-white/10 bg-[#101B45] p-5'>
<div>
<p className='text-sm text-slate-400'>Signals used</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>{detail.result.matched_fields}</p>
</div>
<div>
<p className='text-sm text-slate-400'>Analyst note</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>{detail.result.notes}</p>
</div>
</div>
<div className='mt-6 rounded-3xl border border-white/10 bg-white/5 p-5'>
<p className='text-sm font-semibold uppercase tracking-[0.18em] text-cyan-200'>Request evidence</p>
<div className='mt-4 space-y-4 text-sm text-slate-200'>
<div>
<p className='text-slate-400'>Reveal mode</p>
<p className='mt-1'>{detail.request?.input_type || 'Unknown'}</p>
</div>
{detail.request?.input_text && (
<div>
<p className='text-slate-400'>Input text</p>
<p className='mt-2 whitespace-pre-line rounded-2xl border border-white/10 bg-[#0C1333] p-4 leading-7'>
{detail.request.input_text}
</p>
</div>
)}
{detail.request?.input_url && (
<div>
<p className='text-slate-400'>Input URL</p>
<a
className='mt-2 inline-flex break-all text-cyan-200 underline underline-offset-4'
href={detail.request.input_url}
target='_blank'
rel='noreferrer'
>
{detail.request.input_url}
</a>
</div>
)}
<div className='grid gap-4 md:grid-cols-2'>
<div>
<p className='text-slate-400'>Computed hash</p>
<p className='mt-2 break-all rounded-2xl border border-white/10 bg-[#0C1333] p-4 text-xs text-slate-200'>
{detail.request?.computed_hash || 'Not generated'}
</p>
</div>
<div>
<p className='text-slate-400'>Fingerprint</p>
<p className='mt-2 break-all rounded-2xl border border-white/10 bg-[#0C1333] p-4 text-xs text-slate-200'>
{detail.request?.computed_fingerprint || 'Not generated'}
</p>
</div>
</div>
<div>
<p className='text-slate-400'>Attached files</p>
<div className='mt-3 flex flex-wrap gap-3'>
{(detail.request?.uploaded_files || detail.result.evidence_files || []).map((file) => (
<a
key={file.id}
href={file.publicUrl}
target='_blank'
rel='noreferrer'
className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-slate-100'
>
{file.name || 'Evidence file'}
</a>
))}
{!(detail.request?.uploaded_files || detail.result.evidence_files || []).length && (
<p className='text-sm text-slate-400'>No files were attached for this reveal.</p>
)}
</div>
</div>
</div>
</div>
</CardBox>
<CardBox className='bg-[#0E1534] text-white shadow-[0_24px_60px_rgba(8,12,31,0.32)]'>
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-cyan-200'>Matched work</p>
<h2 className='mt-2 text-2xl font-bold text-white'>Registered record</h2>
{!detail.result.matched_work && (
<div className='mt-6 rounded-2xl border border-dashed border-white/10 bg-white/5 p-5 text-sm leading-7 text-slate-300'>
No direct work was attached to this result. Review the request and consider adding a
new registered work before re-running the reveal.
</div>
)}
{detail.result.matched_work && (
<div className='mt-6 space-y-4'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5'>
<div className='flex items-start justify-between gap-4'>
<div>
<p className='text-2xl font-bold text-white'>
{detail.result.matched_work.title || 'Untitled work'}
</p>
<p className='mt-2 text-sm text-slate-300'>
{detail.result.matched_work.author_name || 'Unknown author'} ·{' '}
{getWorkTypeLabel(detail.result.matched_work.work_type)}
</p>
</div>
<span className='rounded-full border border-white/10 px-3 py-1 text-xs uppercase tracking-[0.18em] text-slate-300'>
{detail.result.matched_work.visibility || 'private'}
</span>
</div>
<div className='mt-5 grid gap-4 md:grid-cols-2'>
<div>
<p className='text-sm text-slate-400'>Owner</p>
<p className='mt-2 text-sm text-white'>
{getPersonName(detail.result.matched_work.owner)}
</p>
</div>
<div>
<p className='text-sm text-slate-400'>Registered</p>
<p className='mt-2 text-sm text-white'>
{formatStudioDate(detail.result.matched_work.registered_at)}
</p>
</div>
</div>
</div>
<div className='rounded-3xl border border-white/10 bg-[#101B45] p-5'>
<p className='text-sm text-slate-400'>Description</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>
{detail.result.matched_work.description || 'No description provided.'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-[#101B45] p-5'>
<p className='text-sm text-slate-400'>License terms</p>
<p className='mt-2 text-sm leading-7 text-slate-200'>
{detail.result.matched_work.license_terms || 'No license terms recorded.'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5'>
<p className='text-sm text-slate-400'>Registered files</p>
<div className='mt-3 flex flex-wrap gap-3'>
{(detail.result.matched_work.original_files || []).map((file) => (
<a
key={file.id}
href={file.publicUrl}
target='_blank'
rel='noreferrer'
className='rounded-full border border-white/10 bg-[#0C1333] px-4 py-2 text-xs font-semibold text-slate-100'
>
{file.name || 'Registered file'}
</a>
))}
{!(detail.result.matched_work.original_files || []).length && (
<p className='text-sm text-slate-400'>No registered files attached.</p>
)}
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href={`/works/${detail.result.matched_work.id}`} color='info' label='Open work record' />
<BaseButton href='/work_claims/work_claims-new' color='white' outline label='Create ownership claim' />
</div>
</div>
)}
</CardBox>
</div>
)}
</SectionMain>
</>
);
};
RevealStudioResultPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_REVEAL_REQUESTS'>{page}</LayoutAuthenticated>;
};
export default RevealStudioResultPage;