1.0
This commit is contained in:
parent
a30ca5a54f
commit
c21ed70146
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal 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:
|
||||
|
||||
482
backend/src/services/copyrightStudio.js
Normal file
482
backend/src/services/copyrightStudio.js
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
|
||||
66
frontend/src/helpers/copyrightStudio.ts
Normal file
66
frontend/src/helpers/copyrightStudio.ts
Normal 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';
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
673
frontend/src/pages/reveal-studio.tsx
Normal file
673
frontend/src/pages/reveal-studio.tsx
Normal 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;
|
||||
370
frontend/src/pages/reveal-studio/results/[resultId].tsx
Normal file
370
frontend/src/pages/reveal-studio/results/[resultId].tsx
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user