Improve verification display and fix download auth

- Add visual Content Credentials summary (like verify.contentauthenticity.org)
  showing content summary, process info, OMG metadata, actions, ingredients,
  issuer details, and validation results in collapsible sections
- Fix verification logic: trust warnings (untrusted cert) no longer mark
  files as invalid - only manifest integrity errors do
- Fix file download: use authenticated fetch instead of bare <a> links
- Add raw manifest data toggle below the visual summary
- Two-column layout: preview on left, credential summary on right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-02-25 08:03:53 -05:00
parent aaa0ca6ae2
commit dc7277347c
4 changed files with 571 additions and 78 deletions

View file

@ -74,9 +74,22 @@ export function deleteFile(id: string): Promise<void> {
return del(`/api/files/${id}`);
}
export function getDownloadUrl(id: string, version: 'signed' | 'original' = 'signed'): string {
export async function downloadFile(id: string, version: 'signed' | 'original' = 'signed', filename?: string): Promise<void> {
const { getToken } = await import('./client');
const base = import.meta.env.VITE_API_URL || '';
return `${base}/api/files/${id}/download?version=${version}`;
const res = await fetch(`${base}/api/files/${id}/download?version=${version}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export async function verifyUploadedFile(file: File): Promise<VerifyResponse> {

View file

@ -0,0 +1,408 @@
import { Shield, Cpu, User, FileText, Link2, ChevronDown, ChevronRight, AlertTriangle, CheckCircle2, Info, Layers } from 'lucide-react';
import { useState } from 'react';
interface ManifestData {
manifests?: Record<string, Manifest>;
active_manifest?: string;
validation_status?: Array<{ code: string; explanation?: string }>;
validation_results?: {
activeManifest?: {
success?: Array<{ code: string; explanation?: string }>;
failure?: Array<{ code: string; explanation?: string }>;
informational?: Array<{ code: string; explanation?: string }>;
};
};
}
interface Manifest {
label?: string;
title?: string;
assertions?: Array<{ label: string; data: unknown }>;
ingredients?: Array<{
title?: string;
format?: string;
relationship?: string;
manifest_data?: { label?: string };
}>;
signature_info?: {
alg?: string;
issuer?: string;
common_name?: string;
time?: string;
cert_serial_number?: string;
};
claim_generator_info?: Array<{ name?: string; version?: string }>;
metadata?: Array<{ key: string; value: string }>;
}
interface CredentialSummaryProps {
manifestData: unknown;
isValid: boolean;
}
function parseActions(manifest: Manifest) {
const actions: Array<{ action: string; description?: string; softwareAgent?: { name?: string; version?: string }; digitalSourceType?: string }> = [];
for (const assertion of (manifest.assertions || [])) {
if (assertion.label?.includes('c2pa.actions')) {
const data = assertion.data as { actions?: Array<Record<string, unknown>> };
if (data?.actions) {
for (const a of data.actions) {
actions.push(a as typeof actions[0]);
}
}
}
}
return actions;
}
function parseOmgMetadata(manifest: Manifest): Record<string, string> {
const meta: Record<string, string> = {};
// From manifest metadata array
if (manifest.metadata) {
for (const m of manifest.metadata) {
if (m.key.startsWith('omg:')) {
meta[m.key.replace('omg:', '')] = m.value;
}
}
}
// Also try to parse from action description
const actions = parseActions(manifest);
for (const a of actions) {
if (a.description) {
const parts = a.description.split(' | ');
for (const part of parts) {
const [key, ...valueParts] = part.split(': ');
const value = valueParts.join(': ');
if (key === 'Job') meta.jobNumber = value;
if (key === 'Platform') meta.platform = value;
if (key === 'AI Model') meta.aiModel = value;
if (key === 'Stamped by') meta.stampedBy = value;
if (key === 'Stamped at') meta.stampedAt = value;
}
}
}
return meta;
}
function getActionLabel(action: string): string {
const labels: Record<string, string> = {
'c2pa.created': 'Created',
'c2pa.opened': 'Opened',
'c2pa.edited': 'Edited',
'c2pa.converted': 'Converted asset',
'c2pa.cropped': 'Cropped',
'c2pa.resized': 'Resized',
'c2pa.filtered': 'Filtered',
'c2pa.color_adjustments': 'Colour adjustments',
'c2pa.placed': 'Placed',
'c2pa.published': 'Published',
'c2pa.transcoded': 'Transcoded',
'c2pa.unknown': 'Other edits',
};
return labels[action] || action.replace('c2pa.', '').replace(/_/g, ' ');
}
function getActionDescription(action: string): string {
const desc: Record<string, string> = {
'c2pa.created': 'This asset was created',
'c2pa.opened': 'Opened a pre-existing file',
'c2pa.edited': 'The asset was edited',
'c2pa.converted': 'The format of the asset was changed',
'c2pa.cropped': 'The asset was cropped',
'c2pa.resized': 'The asset was resized',
'c2pa.unknown': 'Made other changes',
};
return desc[action] || '';
}
function getSourceTypeLabel(sourceType: string): string {
if (sourceType.includes('trainedAlgorithmicMedia')) return 'This image was generated with an AI tool.';
if (sourceType.includes('compositeWithTrainedAlgorithmicMedia')) return 'This image contains AI-generated content.';
if (sourceType.includes('algorithmicMedia')) return 'This was created by an algorithm.';
if (sourceType.includes('digitalCapture')) return 'This was captured by a digital device.';
return '';
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
}
function SectionHeader({ title, icon: Icon, children, defaultOpen = true }: {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-b border-gray-100 last:border-0">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-5 py-3 text-left hover:bg-gray-50 transition-colors"
>
<Icon className="w-4 h-4 text-gray-500" />
<span className="text-sm font-semibold text-gray-900 flex-1">{title}</span>
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
</button>
{open && <div className="px-5 pb-4">{children}</div>}
</div>
);
}
export function CredentialSummary({ manifestData, isValid }: CredentialSummaryProps) {
const data = manifestData as ManifestData;
if (!data?.manifests || !data.active_manifest) return null;
const activeManifest = data.manifests[data.active_manifest];
if (!activeManifest) return null;
const actions = parseActions(activeManifest);
const omgMeta = parseOmgMetadata(activeManifest);
const sigInfo = activeManifest.signature_info;
const claimGen = activeManifest.claim_generator_info?.[0];
const ingredients = activeManifest.ingredients || [];
const validationResults = data.validation_results?.activeManifest;
const successCount = validationResults?.success?.length || 0;
const failureCount = validationResults?.failure?.length || 0;
// Determine content summary
let contentSummary = '';
for (const a of actions) {
if (a.digitalSourceType) {
contentSummary = getSourceTypeLabel(a.digitalSourceType);
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{/* Header bar */}
<div className={`px-5 py-4 flex items-center gap-3 ${isValid ? 'bg-green-50 border-b border-green-100' : 'bg-red-50 border-b border-red-100'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isValid ? 'bg-green-100' : 'bg-red-100'}`}>
{isValid ? <CheckCircle2 className="w-5 h-5 text-green-600" /> : <AlertTriangle className="w-5 h-5 text-red-600" />}
</div>
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">
{activeManifest.title || 'Untitled asset'}
</h3>
<p className="text-xs text-gray-500">
{sigInfo?.issuer ? `Issued by ${sigInfo.issuer}` : ''}
{sigInfo?.time ? ` on ${formatDate(sigInfo.time)}` : ''}
</p>
</div>
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${isValid ? 'text-green-700 bg-green-100' : 'text-red-700 bg-red-100'}`}>
{isValid ? 'Valid Credential' : 'Invalid'}
</span>
</div>
{/* Content Summary */}
{contentSummary && (
<div className="px-5 py-3 border-b border-gray-100 bg-gray-50">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Content Summary</p>
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<p className="text-sm text-gray-700">{contentSummary}</p>
</div>
</div>
)}
{/* OMG Metadata */}
{Object.keys(omgMeta).length > 0 && (
<SectionHeader title="OMG Metadata" icon={Layers}>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{omgMeta.jobNumber && (
<>
<span className="text-xs text-gray-500">Job Number</span>
<span className="text-xs font-medium text-gray-900">{omgMeta.jobNumber}</span>
</>
)}
{omgMeta.platform && (
<>
<span className="text-xs text-gray-500">Platform</span>
<span className="text-xs font-medium text-gray-900">{omgMeta.platform}</span>
</>
)}
{omgMeta.aiModel && (
<>
<span className="text-xs text-gray-500">AI Model</span>
<span className="text-xs font-medium text-gray-900">{omgMeta.aiModel}</span>
</>
)}
{omgMeta.stampedBy && (
<>
<span className="text-xs text-gray-500">Stamped By</span>
<span className="text-xs font-medium text-gray-900">{omgMeta.stampedBy}</span>
</>
)}
{omgMeta.stampedAt && (
<>
<span className="text-xs text-gray-500">Stamped At</span>
<span className="text-xs font-medium text-gray-900">{formatDate(omgMeta.stampedAt)}</span>
</>
)}
</div>
</SectionHeader>
)}
{/* Process */}
<SectionHeader title="Process" icon={Cpu}>
<p className="text-xs text-gray-500 mb-3">
The app or device used to produce this content recorded the following info:
</p>
{/* Software agent / claim generator */}
{(claimGen || actions[0]?.softwareAgent) && (
<div className="mb-3">
<p className="text-xs font-medium text-gray-700 mb-1">App or device used</p>
<p className="text-sm text-gray-900">
{actions[0]?.softwareAgent?.name || claimGen?.name || 'Unknown'}
{(actions[0]?.softwareAgent?.version || claimGen?.version) && (
<span className="text-gray-400 ml-1">v{actions[0]?.softwareAgent?.version || claimGen?.version}</span>
)}
</p>
</div>
)}
{/* Actions */}
{actions.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-700 mb-2">Actions</p>
<div className="space-y-2">
{actions.map((a, i) => (
<div key={i} className="flex items-start gap-3 pl-2">
<div className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
<FileText className="w-3 h-3 text-gray-500" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{getActionLabel(a.action)}</p>
<p className="text-xs text-gray-500">{getActionDescription(a.action)}</p>
</div>
</div>
))}
</div>
</div>
)}
</SectionHeader>
{/* Ingredients */}
{ingredients.length > 0 && (
<SectionHeader title="Ingredients" icon={Link2}>
<div className="space-y-2">
{ingredients.map((ing, i) => (
<div key={i} className="flex items-center gap-3 pl-2">
<div className="w-8 h-8 rounded bg-gray-100 flex items-center justify-center shrink-0">
<FileText className="w-4 h-4 text-gray-400" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{ing.title || 'Untitled asset'}</p>
<p className="text-xs text-gray-500">
{ing.format || ''}
{ing.relationship ? `${ing.relationship}` : ''}
</p>
</div>
</div>
))}
</div>
</SectionHeader>
)}
{/* About this Content Credential */}
<SectionHeader title="About this Content Credential" icon={Shield}>
<div className="space-y-3">
{sigInfo?.issuer && (
<div>
<p className="text-xs text-gray-500">Issued by</p>
<p className="text-sm font-medium text-gray-900">{sigInfo.issuer}</p>
</div>
)}
{sigInfo?.common_name && (
<div>
<p className="text-xs text-gray-500">Common Name</p>
<p className="text-sm font-medium text-gray-900">{sigInfo.common_name}</p>
</div>
)}
{sigInfo?.time && (
<div>
<p className="text-xs text-gray-500">Issued on</p>
<p className="text-sm font-medium text-gray-900">{formatDate(sigInfo.time)}</p>
</div>
)}
{sigInfo?.alg && (
<div>
<p className="text-xs text-gray-500">Algorithm</p>
<p className="text-sm font-medium text-gray-900">{sigInfo.alg}</p>
</div>
)}
{sigInfo?.cert_serial_number && (
<div>
<p className="text-xs text-gray-500">Certificate Serial</p>
<p className="text-sm font-mono text-xs text-gray-600 break-all">{sigInfo.cert_serial_number}</p>
</div>
)}
</div>
</SectionHeader>
{/* Validation Results */}
<SectionHeader title="Validation Results" icon={CheckCircle2} defaultOpen={false}>
<div className="space-y-2">
<p className="text-xs text-gray-500 mb-2">
{successCount} passed, {failureCount} {failureCount === 1 ? 'notice' : 'notices'}
</p>
{validationResults?.success?.map((s, i) => (
<div key={`s${i}`} className="flex items-start gap-2 text-xs">
<CheckCircle2 className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<span className="text-gray-600">{s.explanation || s.code}</span>
</div>
))}
{validationResults?.failure?.map((f, i) => (
<div key={`f${i}`} className="flex items-start gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<span className="text-gray-600">{f.explanation || f.code}</span>
</div>
))}
</div>
</SectionHeader>
{/* Claim Generator */}
<SectionHeader title="Claim Generator" icon={User} defaultOpen={false}>
<div className="space-y-1">
{(activeManifest.claim_generator_info || []).map((cg, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-sm text-gray-900">{cg.name}</span>
{cg.version && <span className="text-xs text-gray-400">v{cg.version}</span>}
</div>
))}
</div>
</SectionHeader>
{/* Provenance Chain */}
{Object.keys(data.manifests).length > 1 && (
<SectionHeader title="Provenance Chain" icon={Link2} defaultOpen={false}>
<div className="space-y-2">
{Object.values(data.manifests).map((m, i) => (
<div key={i} className="flex items-center gap-3 pl-2">
<div className="w-2 h-2 rounded-full bg-brand shrink-0" />
<div>
<p className="text-sm font-medium text-gray-900">{m.title || m.label}</p>
<p className="text-xs text-gray-500">
{m.signature_info?.issuer || 'Unknown issuer'}
{m.signature_info?.time ? `${formatDate(m.signature_info.time)}` : ''}
</p>
</div>
</div>
))}
</div>
</SectionHeader>
)}
</div>
);
}

View file

@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getFile, verifyFile, deleteFile, getDownloadUrl, type FileRecord, type VerifyResponse } from '../api/files';
import { getFile, verifyFile, deleteFile, downloadFile, type FileRecord, type VerifyResponse } from '../api/files';
import { useAuth } from '../contexts/AuthContext';
import { SignDialog } from '../components/files/SignDialog';
import { CredentialSummary } from '../components/verification/CredentialSummary';
import { ManifestTree, ValidationStatus } from '../components/verification/ManifestTree';
import { formatFileSize, formatDate, getStatusColor, cn } from '../lib/utils';
import { ArrowLeft, Download, Trash2, Shield, Pen, Loader2 } from 'lucide-react';
import { ArrowLeft, Download, Trash2, Shield, Pen, Loader2, ChevronDown, ChevronRight } from 'lucide-react';
export function FileDetailPage() {
const { id } = useParams<{ id: string }>();
@ -16,6 +17,8 @@ export function FileDetailPage() {
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [showRawData, setShowRawData] = useState(false);
const [downloading, setDownloading] = useState<string | null>(null);
useEffect(() => {
if (id) getFile(id).then(setFile).catch(() => setError('File not found'));
@ -36,6 +39,19 @@ export function FileDetailPage() {
}
};
const handleDownload = async (version: 'signed' | 'original') => {
if (!file) return;
setDownloading(version);
try {
const filename = version === 'signed' ? `signed_${file.originalFilename}` : file.originalFilename;
await downloadFile(file.id, version, filename);
} catch {
setError('Download failed');
} finally {
setDownloading(null);
}
};
const handleDelete = async () => {
if (!id || !confirm('Are you sure you want to delete this file?')) return;
await deleteFile(id);
@ -62,6 +78,10 @@ export function FileDetailPage() {
const isVideo = file.mimeType.startsWith('video/');
const isAudio = file.mimeType.startsWith('audio/');
// Determine which manifest data to display
const displayManifest = verifyResult?.manifest || file.manifestData;
const displayIsValid = verifyResult ? verifyResult.isValid : (file.c2paStatus === 'signed' || file.c2paStatus === 'verified');
return (
<div className="space-y-6">
{/* Header */}
@ -101,21 +121,23 @@ export function FileDetailPage() {
Verify
</button>
{file.signedPath && (
<a
href={getDownloadUrl(file.id, 'signed')}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
<button
onClick={() => handleDownload('signed')}
disabled={downloading === 'signed'}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
<Download className="w-4 h-4" />
{downloading === 'signed' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Download Signed
</a>
</button>
)}
<a
href={getDownloadUrl(file.id, 'original')}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
<button
onClick={() => handleDownload('original')}
disabled={downloading === 'original'}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
<Download className="w-4 h-4" />
{downloading === 'original' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Download Original
</a>
</button>
{hasRole('admin') && (
<button
onClick={handleDelete}
@ -127,63 +149,79 @@ export function FileDetailPage() {
)}
</div>
{/* File Preview */}
{(isImage || isVideo || isAudio) && (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Preview</h3>
{isImage && file.previewUrl && (
<img
src={`${apiBase}${file.signedPreviewUrl || file.previewUrl}`}
alt={file.originalFilename}
className="max-h-96 rounded-lg mx-auto"
/>
)}
{isVideo && (
<video
src={`${apiBase}${file.signedPath ? `/storage/signed/${file.id}${file.originalFilename.substring(file.originalFilename.lastIndexOf('.'))}` : file.previewUrl || ''}`}
controls
className="max-h-96 rounded-lg mx-auto"
/>
)}
{isAudio && (
<audio
src={`${apiBase}${file.previewUrl || ''}`}
controls
className="w-full"
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column: Preview + File Info */}
<div className="space-y-6">
{/* File Preview */}
{(isImage || isVideo || isAudio) && (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Preview</h3>
{isImage && file.previewUrl && (
<img
src={`${apiBase}${file.signedPreviewUrl || file.previewUrl}`}
alt={file.originalFilename}
className="max-h-96 rounded-lg mx-auto"
/>
)}
{isVideo && (
<video
src={`${apiBase}${file.signedPath ? `/storage/signed/${file.id}${file.originalFilename.substring(file.originalFilename.lastIndexOf('.'))}` : file.previewUrl || ''}`}
controls
className="max-h-96 rounded-lg mx-auto"
/>
)}
{isAudio && (
<audio src={`${apiBase}${file.previewUrl || ''}`} controls className="w-full" />
)}
</div>
)}
{/* File Info */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">File Details</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-gray-500">ID</dt>
<dd className="text-gray-900 font-mono text-xs">{file.id}</dd>
<dt className="text-gray-500">Uploaded</dt>
<dd className="text-gray-900">{formatDate(file.createdAt)}</dd>
<dt className="text-gray-500">Updated</dt>
<dd className="text-gray-900">{formatDate(file.updatedAt)}</dd>
</dl>
</div>
</div>
)}
{/* File Info */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">File Details</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-gray-500">ID</dt>
<dd className="text-gray-900 font-mono text-xs">{file.id}</dd>
<dt className="text-gray-500">Uploaded</dt>
<dd className="text-gray-900">{formatDate(file.createdAt)}</dd>
<dt className="text-gray-500">Updated</dt>
<dd className="text-gray-900">{formatDate(file.updatedAt)}</dd>
</dl>
</div>
{/* Right column: Credential Summary */}
<div className="space-y-6">
{/* Verification status bar */}
{verifyResult && (
<ValidationStatus status={verifyResult.validationStatus} />
)}
{/* Verification Result */}
{verifyResult && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Verification Result</h3>
<ValidationStatus status={verifyResult.validationStatus} />
{verifyResult.manifest ? (
<ManifestTree manifest={verifyResult.manifest as Record<string, unknown>} />
{/* Visual Credential Summary */}
{displayManifest ? (
<CredentialSummary
manifestData={displayManifest}
isValid={displayIsValid}
/>
) : null}
</div>
)}
</div>
{/* Existing Manifest Data */}
{!verifyResult && file.manifestData ? (
{/* Raw manifest data toggle */}
{displayManifest ? (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">Manifest Data</h3>
<ManifestTree manifest={file.manifestData as Record<string, unknown>} />
<button
onClick={() => setShowRawData(!showRawData)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
>
{showRawData ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{showRawData ? 'Hide' : 'Show'} Raw Manifest Data
</button>
{showRawData && (
<div className="mt-3">
<ManifestTree manifest={displayManifest as Record<string, unknown>} />
</div>
)}
</div>
) : null}

View file

@ -1,8 +1,9 @@
import { useState } from 'react';
import { verifyUploadedFile, type VerifyResponse } from '../api/files';
import { CredentialSummary } from '../components/verification/CredentialSummary';
import { ManifestTree, ValidationStatus } from '../components/verification/ManifestTree';
import { useDropzone } from 'react-dropzone';
import { Shield, Loader2 } from 'lucide-react';
import { Shield, Loader2, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '../lib/utils';
export function VerifyPage() {
@ -12,6 +13,7 @@ export function VerifyPage() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewType, setPreviewType] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [showRaw, setShowRaw] = useState(false);
const onDrop = async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
@ -20,8 +22,8 @@ export function VerifyPage() {
setVerifying(true);
setError(null);
setResult(null);
setShowRaw(false);
// Create local preview
if (file.type.startsWith('image/')) {
setPreviewUrl(URL.createObjectURL(file));
setPreviewType('image');
@ -88,7 +90,7 @@ export function VerifyPage() {
{/* Results */}
{result && (
<div className="space-y-4">
<div className="space-y-6">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900">Results for {fileName}</h2>
{result.isValid ? (
@ -100,23 +102,55 @@ export function VerifyPage() {
)}
</div>
{/* File preview */}
{previewUrl && (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">File Preview</h3>
{previewType === 'image' && (
<img src={previewUrl} alt={fileName} className="max-h-96 rounded-lg mx-auto" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Preview */}
<div className="space-y-4">
{previewUrl && (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">File Preview</h3>
{previewType === 'image' && (
<img src={previewUrl} alt={fileName} className="max-h-96 rounded-lg mx-auto" />
)}
{previewType === 'video' && (
<video src={previewUrl} controls className="max-h-96 rounded-lg mx-auto" />
)}
</div>
)}
{previewType === 'video' && (
<video src={previewUrl} controls className="max-h-96 rounded-lg mx-auto" />
<ValidationStatus status={result.validationStatus} />
</div>
{/* Right: Credential Summary */}
<div>
{result.manifest ? (
<CredentialSummary
manifestData={result.manifest}
isValid={result.isValid}
/>
) : (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<Shield className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-sm text-gray-500">No C2PA content credentials found in this file.</p>
</div>
)}
</div>
)}
<ValidationStatus status={result.validationStatus} />
</div>
{/* Raw data toggle */}
{result.manifest ? (
<ManifestTree manifest={result.manifest as Record<string, unknown>} />
<div>
<button
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
>
{showRaw ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{showRaw ? 'Hide' : 'Show'} Raw Manifest Data
</button>
{showRaw && (
<div className="mt-3">
<ManifestTree manifest={result.manifest as Record<string, unknown>} />
</div>
)}
</div>
) : null}
</div>
)}