feat(pm15): share read-only link for client preview
Backend:
- ShareToken model (share_tokens collection)
- POST /jobs/{id}/share — create token (PM/PROD/ADMIN)
- GET /jobs/{id}/share — list active tokens
- DELETE /jobs/{id}/share/{token_id} — revoke token
- GET /public/share/{token} — unauthenticated preview with signed GCS URLs (6h TTL)
Returns video, captions, AD for all languages
Frontend:
- ShareView.tsx — public page at /share/:token with language switcher, video player, download tiles
- App.tsx — /share/:token route (no auth wrapper)
- QCDetail.tsx — "↗ Share link" button in header → modal to generate + copy link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f1a9e6ee46
commit
abf81515a4
7 changed files with 552 additions and 6 deletions
213
backend/app/api/v1/routes_share.py
Normal file
213
backend/app/api/v1/routes_share.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Share-token endpoints — create/revoke/list tokens + public read-only view."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.config import settings
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import get_current_user, require_roles
|
||||
from ...models.share_token import ShareToken, ShareTokenResponse
|
||||
from ...models.user import User, UserRole
|
||||
from ...services.gcs import get_signed_download_url
|
||||
|
||||
router = APIRouter(tags=["share"])
|
||||
|
||||
_TOKENS = "share_tokens"
|
||||
_JOBS = "jobs"
|
||||
|
||||
|
||||
def _share_url(token: str) -> str:
|
||||
base = getattr(settings, "app_url", "https://ai-sandbox.oliver.solutions/video-accessibility")
|
||||
return f"{base}/share/{token}"
|
||||
|
||||
|
||||
# ── Request schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
class CreateShareTokenRequest(BaseModel):
|
||||
expires_in_days: Optional[int] = 30 # None = no expiry
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class ShareTokenListResponse(BaseModel):
|
||||
tokens: list[ShareTokenResponse]
|
||||
|
||||
|
||||
class PublicJobPreviewLanguage(BaseModel):
|
||||
captions_vtt_url: Optional[str] = None
|
||||
audio_description_vtt_url: Optional[str] = None
|
||||
accessible_video_mp4_url: Optional[str] = None
|
||||
audio_description_mp3_url: Optional[str] = None
|
||||
|
||||
|
||||
class PublicJobPreviewResponse(BaseModel):
|
||||
job_id: str
|
||||
job_title: str
|
||||
job_status: str
|
||||
source_language: str
|
||||
languages: list[str]
|
||||
language_outputs: dict[str, PublicJobPreviewLanguage]
|
||||
|
||||
|
||||
# ── Authenticated routes ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/share", response_model=ShareTokenResponse, status_code=201)
|
||||
async def create_share_token(
|
||||
job_id: str,
|
||||
request: CreateShareTokenRequest,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Generate a read-only share link for a job."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
token_id = secrets.token_hex(32)
|
||||
now = datetime.utcnow()
|
||||
expires_at = (now + timedelta(days=request.expires_in_days)) if request.expires_in_days else None
|
||||
|
||||
token_doc = {
|
||||
"_id": token_id,
|
||||
"job_id": job_id,
|
||||
"organization_id": job_doc.get("organization_id", ""),
|
||||
"created_by_user_id": str(current_user.id),
|
||||
"created_by_email": current_user.email,
|
||||
"created_at": now,
|
||||
"expires_at": expires_at,
|
||||
"is_active": True,
|
||||
"label": request.label,
|
||||
}
|
||||
await db[_TOKENS].insert_one(token_doc)
|
||||
|
||||
return ShareTokenResponse(
|
||||
id=token_id,
|
||||
job_id=job_id,
|
||||
created_by_email=current_user.email,
|
||||
created_at=now,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
label=request.label,
|
||||
share_url=_share_url(token_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/share", response_model=ShareTokenListResponse)
|
||||
async def list_share_tokens(
|
||||
job_id: str,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""List all active share tokens for a job."""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
cursor = db[_TOKENS].find({"job_id": job_id, "is_active": True})
|
||||
tokens = []
|
||||
async for doc in cursor:
|
||||
tokens.append(ShareTokenResponse(
|
||||
id=doc["_id"],
|
||||
job_id=doc["job_id"],
|
||||
created_by_email=doc["created_by_email"],
|
||||
created_at=doc["created_at"],
|
||||
expires_at=doc.get("expires_at"),
|
||||
is_active=doc["is_active"],
|
||||
label=doc.get("label"),
|
||||
share_url=_share_url(doc["_id"]),
|
||||
))
|
||||
return ShareTokenListResponse(tokens=tokens)
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}/share/{token_id}", status_code=204)
|
||||
async def revoke_share_token(
|
||||
job_id: str,
|
||||
token_id: str,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Revoke (deactivate) a share token."""
|
||||
result = await db[_TOKENS].update_one(
|
||||
{"_id": token_id, "job_id": job_id},
|
||||
{"$set": {"is_active": False}},
|
||||
)
|
||||
if result.matched_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
|
||||
# ── Public route (no auth) ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/share/{token}", response_model=PublicJobPreviewResponse)
|
||||
async def get_public_job_preview(
|
||||
token: str,
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Return read-only job preview for a valid share token. No authentication required."""
|
||||
token_doc = await db[_TOKENS].find_one({"_id": token, "is_active": True})
|
||||
if not token_doc:
|
||||
raise HTTPException(status_code=404, detail="Share link not found or has been revoked")
|
||||
|
||||
if token_doc.get("expires_at") and token_doc["expires_at"] < datetime.utcnow():
|
||||
raise HTTPException(status_code=410, detail="Share link has expired")
|
||||
|
||||
job_doc = await db[_JOBS].find_one({"_id": token_doc["job_id"]})
|
||||
if not job_doc:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
outputs = job_doc.get("outputs") or {}
|
||||
language_outputs: dict[str, PublicJobPreviewLanguage] = {}
|
||||
|
||||
for lang, lang_output in outputs.items():
|
||||
if not isinstance(lang_output, dict):
|
||||
continue
|
||||
|
||||
lang_data = PublicJobPreviewLanguage()
|
||||
|
||||
if "captions_vtt_gcs" in lang_output:
|
||||
blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
try:
|
||||
lang_data.captions_vtt_url = await get_signed_download_url(blob_path, 6)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "ad_vtt_gcs" in lang_output:
|
||||
blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
try:
|
||||
lang_data.audio_description_vtt_url = await get_signed_download_url(blob_path, 6)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "ad_mp3_gcs" in lang_output:
|
||||
blob_path = lang_output["ad_mp3_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
try:
|
||||
lang_data.audio_description_mp3_url = await get_signed_download_url(blob_path, 6)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "accessible_video_gcs" in lang_output:
|
||||
blob_path = lang_output["accessible_video_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
try:
|
||||
lang_data.accessible_video_mp4_url = await get_signed_download_url(blob_path, 6)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
language_outputs[lang] = lang_data
|
||||
|
||||
return PublicJobPreviewResponse(
|
||||
job_id=str(job_doc["_id"]),
|
||||
job_title=job_doc.get("title", "Untitled"),
|
||||
job_status=job_doc.get("status", ""),
|
||||
source_language=job_doc.get("source", {}).get("language", "en"),
|
||||
languages=list(outputs.keys()),
|
||||
language_outputs=language_outputs,
|
||||
)
|
||||
|
|
@ -23,6 +23,7 @@ from .api.v1.routes_organizations import router as organizations_router
|
|||
from .api.v1.routes_review_notes import router as review_notes_router
|
||||
from .api.v1.routes_tts import router as tts_router
|
||||
from .api.v1.routes_vtt_versions import router as vtt_versions_router
|
||||
from .api.v1.routes_share import router as share_router
|
||||
from .api.v1.routes_websockets import router as websockets_router
|
||||
from .core.config import settings
|
||||
from .core.database import (
|
||||
|
|
@ -265,6 +266,7 @@ app.include_router(language_qc_router, prefix="/api/v1")
|
|||
app.include_router(glossaries_router, prefix="/api/v1")
|
||||
app.include_router(tts_router, prefix="/api/v1")
|
||||
app.include_router(admin_router, prefix="/api/v1")
|
||||
app.include_router(share_router, prefix="/api/v1")
|
||||
app.include_router(websockets_router, prefix="/api/v1")
|
||||
|
||||
|
||||
|
|
|
|||
27
backend/app/models/share_token.py
Normal file
27
backend/app/models/share_token.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ShareToken(BaseModel):
|
||||
id: Optional[str] = None # token itself (32 hex chars), used as _id
|
||||
job_id: str
|
||||
organization_id: str
|
||||
created_by_user_id: str
|
||||
created_by_email: str
|
||||
created_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
label: Optional[str] = None # human-readable note e.g. "Sent to ACME 2026-05-01"
|
||||
|
||||
|
||||
class ShareTokenResponse(BaseModel):
|
||||
id: str
|
||||
job_id: str
|
||||
created_by_email: str
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
label: Optional[str] = None
|
||||
share_url: str # full public URL, assembled server-side
|
||||
|
|
@ -24,6 +24,7 @@ import { GlossaryDetail } from './routes/admin/glossaries/GlossaryDetail';
|
|||
import { AuditLog } from './routes/admin/AuditLog';
|
||||
import { LinguistQueue } from './routes/jobs/LinguistQueue';
|
||||
import { Downloads } from './routes/Downloads';
|
||||
import { ShareView } from './routes/ShareView';
|
||||
import { AcceptInvite } from './routes/AcceptInvite';
|
||||
import { NoAccess } from './routes/NoAccess';
|
||||
import { OrgSettingsLayout } from './routes/org/OrgSettingsLayout';
|
||||
|
|
@ -59,6 +60,7 @@ function AppContent() {
|
|||
<div className="min-h-screen bg-gray-50">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/share/:token" element={<ShareView />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/no-access" element={
|
||||
<AuthenticatedRoute>
|
||||
|
|
|
|||
|
|
@ -905,6 +905,42 @@ class ApiClient {
|
|||
async archiveGlossary(clientId: string, glossaryId: string): Promise<void> {
|
||||
await this.client.delete(`/clients/${clientId}/glossaries/${glossaryId}`);
|
||||
}
|
||||
|
||||
// ── Share tokens ────────────────────────────────────────────────────────────
|
||||
|
||||
async createShareToken(jobId: string, opts?: { expires_in_days?: number; label?: string }): Promise<{
|
||||
id: string; job_id: string; created_by_email: string; created_at: string;
|
||||
expires_at?: string; is_active: boolean; label?: string; share_url: string;
|
||||
}> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/share`, opts ?? { expires_in_days: 30 });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async listShareTokens(jobId: string): Promise<{ tokens: Array<{
|
||||
id: string; job_id: string; created_by_email: string; created_at: string;
|
||||
expires_at?: string; is_active: boolean; label?: string; share_url: string;
|
||||
}> }> {
|
||||
const r = await this.client.get(`/jobs/${jobId}/share`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async revokeShareToken(jobId: string, tokenId: string): Promise<void> {
|
||||
await this.client.delete(`/jobs/${jobId}/share/${tokenId}`);
|
||||
}
|
||||
|
||||
async getPublicJobPreview(token: string): Promise<{
|
||||
job_id: string; job_title: string; job_status: string;
|
||||
source_language: string; languages: string[];
|
||||
language_outputs: Record<string, {
|
||||
captions_vtt_url?: string;
|
||||
audio_description_vtt_url?: string;
|
||||
accessible_video_mp4_url?: string;
|
||||
audio_description_mp3_url?: string;
|
||||
}>;
|
||||
}> {
|
||||
const r = await this.client.get(`/public/share/${token}`);
|
||||
return r.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
175
frontend/src/routes/ShareView.tsx
Normal file
175
frontend/src/routes/ShareView.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
type LanguageOutput = {
|
||||
captions_vtt_url?: string;
|
||||
audio_description_vtt_url?: string;
|
||||
accessible_video_mp4_url?: string;
|
||||
audio_description_mp3_url?: string;
|
||||
};
|
||||
|
||||
type JobPreview = {
|
||||
job_id: string;
|
||||
job_title: string;
|
||||
job_status: string;
|
||||
source_language: string;
|
||||
languages: string[];
|
||||
language_outputs: Record<string, LanguageOutput>;
|
||||
};
|
||||
|
||||
export function ShareView() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [preview, setPreview] = useState<JobPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedLang, setSelectedLang] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiClient.getPublicJobPreview(token)
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setSelectedLang(data.source_language || data.languages[0] || '');
|
||||
})
|
||||
.catch((e) => {
|
||||
const status = e?.response?.status;
|
||||
if (status === 404) setError('This share link is not valid or has been revoked.');
|
||||
else if (status === 410) setError('This share link has expired.');
|
||||
else setError('Failed to load preview. Please try again later.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !preview) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="text-4xl mb-4">🔒</div>
|
||||
<h1 className="text-xl font-semibold text-gray-800 mb-2">Link unavailable</h1>
|
||||
<p className="text-gray-500 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const langOut = preview.language_outputs[selectedLang] ?? {};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{preview.job_title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Read-only preview · Shared by Oliver</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-full">{preview.job_status.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6 py-8 space-y-6">
|
||||
{/* Language selector */}
|
||||
{preview.languages.length > 1 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700">Language:</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{preview.languages.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setSelectedLang(lang)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedLang === lang
|
||||
? 'bg-indigo-600 text-white border-indigo-600'
|
||||
: 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
|
||||
}`}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
{lang === preview.source_language && <span className="ml-1 opacity-60">(source)</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video player */}
|
||||
{langOut.accessible_video_mp4_url ? (
|
||||
<div className="bg-black rounded-xl overflow-hidden">
|
||||
<video
|
||||
key={langOut.accessible_video_mp4_url}
|
||||
controls
|
||||
className="w-full max-h-[60vh]"
|
||||
src={langOut.accessible_video_mp4_url}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-100 rounded-xl h-48 flex items-center justify-center text-gray-400 text-sm">
|
||||
No accessible video available for {selectedLang.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloads */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-4">Downloads — {selectedLang.toUpperCase()}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{langOut.captions_vtt_url && (
|
||||
<a
|
||||
href={langOut.captions_vtt_url}
|
||||
download={`captions-${selectedLang}.vtt`}
|
||||
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
|
||||
>
|
||||
<span className="text-2xl">📄</span>
|
||||
<span className="text-xs text-gray-700 font-medium">Captions VTT</span>
|
||||
</a>
|
||||
)}
|
||||
{langOut.audio_description_vtt_url && (
|
||||
<a
|
||||
href={langOut.audio_description_vtt_url}
|
||||
download={`ad-${selectedLang}.vtt`}
|
||||
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
|
||||
>
|
||||
<span className="text-2xl">📝</span>
|
||||
<span className="text-xs text-gray-700 font-medium">Audio Description VTT</span>
|
||||
</a>
|
||||
)}
|
||||
{langOut.audio_description_mp3_url && (
|
||||
<a
|
||||
href={langOut.audio_description_mp3_url}
|
||||
download={`ad-${selectedLang}.mp3`}
|
||||
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
|
||||
>
|
||||
<span className="text-2xl">🔊</span>
|
||||
<span className="text-xs text-gray-700 font-medium">AD Audio MP3</span>
|
||||
</a>
|
||||
)}
|
||||
{langOut.accessible_video_mp4_url && (
|
||||
<a
|
||||
href={langOut.accessible_video_mp4_url}
|
||||
download={`accessible-video-${selectedLang}.mp4`}
|
||||
className="flex flex-col items-center gap-2 p-3 border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 transition-colors text-center"
|
||||
>
|
||||
<span className="text-2xl">🎬</span>
|
||||
<span className="text-xs text-gray-700 font-medium">Accessible Video MP4</span>
|
||||
</a>
|
||||
)}
|
||||
{!langOut.captions_vtt_url && !langOut.audio_description_vtt_url && !langOut.accessible_video_mp4_url && (
|
||||
<p className="col-span-4 text-sm text-gray-400 italic">No files available for this language yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
This is a read-only preview link. To request revisions, contact your Oliver project manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -222,6 +222,21 @@ export function QCDetail() {
|
|||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Bulk assignment failed'),
|
||||
});
|
||||
|
||||
// Share link state
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState('');
|
||||
const [shareLabel, setShareLabel] = useState('');
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
|
||||
const createShareTokenMutation = useMutation({
|
||||
mutationFn: () => apiClient.createShareToken(id!, { expires_in_days: 30, label: shareLabel || undefined }),
|
||||
onSuccess: (data) => {
|
||||
setShareUrl(data.share_url);
|
||||
setShareLabel('');
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Failed to generate share link'),
|
||||
});
|
||||
|
||||
const submitForReviewMutation = useMutation({
|
||||
mutationFn: (lang: string) => apiClient.submitForReview(id!, lang),
|
||||
onSuccess: () => {
|
||||
|
|
@ -789,12 +804,22 @@ export function QCDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/admin/qc')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← Back to Queue
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{canAssign && (
|
||||
<button
|
||||
onClick={() => { setShareUrl(''); setShareLabel(''); setShareCopied(false); setShowShareModal(true); }}
|
||||
className="text-xs px-3 py-1.5 bg-white border border-gray-300 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800"
|
||||
>
|
||||
↗ Share link
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/admin/qc')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← Back to Queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Re-render complete banner */}
|
||||
|
|
@ -1249,6 +1274,72 @@ export function QCDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Share link modal */}
|
||||
{showShareModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">Share read-only link</h3>
|
||||
<p className="text-sm text-gray-500">Generate a public link that lets the client preview this job without logging in. Link expires in 30 days.</p>
|
||||
|
||||
{!shareUrl ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Label (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Sent to ACME 2026-05-01"
|
||||
value={shareLabel}
|
||||
onChange={e => setShareLabel(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowShareModal(false)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createShareTokenMutation.mutate()}
|
||||
disabled={createShareTokenMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{createShareTokenMutation.isPending ? 'Generating…' : 'Generate link'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<input
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
className="flex-1 text-xs text-gray-700 bg-transparent outline-none select-all"
|
||||
onFocus={e => e.target.select()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(shareUrl); setShareCopied(true); setTimeout(() => setShareCopied(false), 2000); }}
|
||||
className="text-xs px-2 py-1 bg-indigo-600 text-white rounded hover:bg-indigo-700 shrink-0"
|
||||
>
|
||||
{shareCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">Anyone with this link can view the job. Link expires in 30 days.</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowShareModal(false)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rendering Status Banner */}
|
||||
{isRendering && (
|
||||
<div className="mb-6 p-4 bg-purple-50 border border-purple-200 rounded-md">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue