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:
parent
aaa0ca6ae2
commit
dc7277347c
4 changed files with 571 additions and 78 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
408
frontend/src/components/verification/CredentialSummary.tsx
Normal file
408
frontend/src/components/verification/CredentialSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue