Implement Scene 7 evidence goal flow
This commit is contained in:
+88
-14
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { AdminPanel } from './admin'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
import type { PlaythroughState } from './narrative'
|
||||
|
||||
const BOARD_W = 2400
|
||||
const BOARD_H = 1500
|
||||
@@ -69,6 +70,9 @@ export function App() {
|
||||
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
||||
const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
|
||||
const [advancing, setAdvancing] = useState(false)
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -101,7 +105,15 @@ export function App() {
|
||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||
const openLevel = async (slug: string) => {
|
||||
const data = await loadLevelBySlug(slug, editQuery)
|
||||
if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
if ((data.goals.some(goal => goal.status === 'pending') || data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId))
|
||||
&& !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
|
||||
try {
|
||||
const playthroughResponse = await fetch('/api/playthroughs/current')
|
||||
if (playthroughResponse.ok && playthroughResponse.status !== 204) {
|
||||
const playthrough = await playthroughResponse.json() as PlaythroughState
|
||||
setActivePlaythroughId(playthrough.node?.kind === 'level' && playthrough.node.levelSlug === data.id ? playthrough.playthrough.id : null)
|
||||
} else setActivePlaythroughId(null)
|
||||
} catch { setActivePlaythroughId(null) }
|
||||
setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
|
||||
}
|
||||
const boot = async () => {
|
||||
@@ -360,6 +372,7 @@ export function App() {
|
||||
form.append('x', String(position.x))
|
||||
form.append('y', String(position.y))
|
||||
try {
|
||||
setStatus(source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${file.name.toUpperCase()} · READING SOURCE…`)
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||||
const uploaded: UploadedCaseDocument = await response.json()
|
||||
@@ -372,14 +385,36 @@ export function App() {
|
||||
void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||
})
|
||||
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
|
||||
if (deterministicCompletion) setCompletedGoal(deterministicCompletion)
|
||||
if (analysis.awardedFlags.length) {
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
await loadLevelBySlug(caseState.id, editQuery)
|
||||
setSelected(document.id)
|
||||
setStatus(`EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||
setStatus(deterministicCompletion ? 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||
else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
|
||||
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
|
||||
const judgeResponse = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents/${encodeURIComponent(document.id)}/judge`, { method: 'POST' })
|
||||
if (judgeResponse.ok) {
|
||||
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
|
||||
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
|
||||
if (semanticCompletion || semantic.awardedFlags.length) {
|
||||
if (semanticCompletion) setCompletedGoal(semanticCompletion)
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
await loadLevelBySlug(caseState.id, editQuery)
|
||||
setSelected(document.id)
|
||||
}
|
||||
if (semanticCompletion) setStatus('SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
||||
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
|
||||
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
|
||||
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
|
||||
else setStatus('SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE')
|
||||
} else if (source === 'clipboard') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||
else setStatus(`IMPORTED · ${file.name.toUpperCase()} · TEXT ANALYZED`)
|
||||
} else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
|
||||
else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
|
||||
else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
|
||||
} catch (error) {
|
||||
@@ -388,6 +423,20 @@ export function App() {
|
||||
}
|
||||
}, [caseState, loadLevelBySlug, requestedEditMode, update])
|
||||
|
||||
const continueAfterGoal = async () => {
|
||||
if (!activePlaythroughId) { setCompletedGoal(null); return }
|
||||
setAdvancing(true)
|
||||
try {
|
||||
const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
||||
})
|
||||
if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
|
||||
const next = await response.json() as PlaythroughState
|
||||
if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
|
||||
else window.location.assign('/')
|
||||
} finally { setAdvancing(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseState) return
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
@@ -413,6 +462,9 @@ export function App() {
|
||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||
const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
||||
const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
|
||||
@@ -431,7 +483,7 @@ export function App() {
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
<nav>
|
||||
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
@@ -467,10 +519,11 @@ export function App() {
|
||||
</aside>
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
goals={caseState.goals}
|
||||
parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
|
||||
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
||||
canEdit={canAuthor}
|
||||
@@ -609,6 +662,7 @@ export function App() {
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||
{completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
|
||||
</main>
|
||||
}
|
||||
|
||||
@@ -968,19 +1022,35 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
const pending = goals.filter(goal => goal.status === 'pending').length
|
||||
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
|
||||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||||
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button>
|
||||
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
|
||||
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
|
||||
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
||||
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) {
|
||||
return <div className="goal-complete-shade" role="dialog" aria-modal="true" aria-labelledby="goal-complete-title">
|
||||
<section className="goal-complete-card">
|
||||
<div className="goal-complete-signal"><i/><span>GU-NET SOURCE AUTHENTICATION</span><i/></div>
|
||||
<small>OBJECTIVE COMPLETE</small>
|
||||
<h2 id="goal-complete-title">{goal.title}</h2>
|
||||
<p>{goal.completionMessage || 'The submitted evidence satisfies this objective.'}</p>
|
||||
<div className="goal-complete-stamp">SOURCE<br/><b>VERIFIED</b></div>
|
||||
<button type="button" disabled={busy} onClick={onContinue}>{busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD'} <span>▸</span></button>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
|
||||
const [body, setBody] = useState(brief.body)
|
||||
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
||||
@@ -1149,12 +1219,14 @@ function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; on
|
||||
type MatchRuleDraft = {
|
||||
id?: string
|
||||
name: string
|
||||
sourceLabel: string
|
||||
sourceUri: string
|
||||
flagKey: string
|
||||
minimumAnchorMatches: number
|
||||
enabled: boolean
|
||||
anchors: { id: string; phrase: string; minimumSimilarity: number }[]
|
||||
}
|
||||
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||||
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', sourceLabel:'', sourceUri:'', flagKey: '', minimumAnchorMatches: 1, enabled: true,
|
||||
anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })
|
||||
|
||||
function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
|
||||
@@ -1168,7 +1240,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
|
||||
setRules(await response.json())
|
||||
}, [levelId])
|
||||
useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
|
||||
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,flagKey:rule.flagKey,
|
||||
const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,sourceLabel:rule.sourceLabel || '',sourceUri:rule.sourceUri || '',flagKey:rule.flagKey,
|
||||
minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
|
||||
anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
@@ -1176,7 +1248,7 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
|
||||
try {
|
||||
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
|
||||
method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name:draft.name,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||||
body: JSON.stringify({ name:draft.name,sourceLabel:draft.sourceLabel,sourceUri:draft.sourceUri,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
|
||||
enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
|
||||
})
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
|
||||
@@ -1206,6 +1278,8 @@ function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClo
|
||||
<form className="match-rule-form" onSubmit={event => void submit(event)}>
|
||||
<div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
|
||||
<label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
|
||||
<div className="match-rule-fields"><label className="field"><span>SOURCE LABEL · OPTIONAL</span><input value={draft.sourceLabel} maxLength={300} onChange={event => setDraft(value => ({ ...value,sourceLabel:event.target.value }))} placeholder="Google Patents · GB695913A"/></label>
|
||||
<label className="field"><span>CANONICAL URL · OPTIONAL</span><input type="url" value={draft.sourceUri} maxLength={2000} onChange={event => setDraft(value => ({ ...value,sourceUri:event.target.value }))} placeholder="https://patents.google.com/…"/></label></div>
|
||||
<div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
|
||||
<label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
|
||||
<label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
|
||||
|
||||
@@ -66,6 +66,7 @@ const folder: FolderExhibit = {
|
||||
|
||||
const state: CaseState = {
|
||||
brief: { body: '', concepts: [] },
|
||||
goals: [],
|
||||
id: 'test-level',
|
||||
title: 'Test',
|
||||
subtitle: '',
|
||||
|
||||
+3
-1
@@ -205,6 +205,7 @@ type LegacyCaseState = {
|
||||
subtitle: string
|
||||
viewport: Viewport
|
||||
brief?: CaseState['brief']
|
||||
goals?: CaseState['goals']
|
||||
updatedAt?: string
|
||||
levelStatus?: string
|
||||
sourceTemplateVersionId?: string
|
||||
@@ -238,6 +239,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
return { id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport,
|
||||
relations: (state.relations || []) as unknown as ExhibitRelation[], connections: (state.connections || []) as unknown as Connection[],
|
||||
revision: Number(state.revision || 0), brief: state.brief || { body: '', concepts: [] },
|
||||
goals: state.goals || [],
|
||||
views: Array.isArray(state.views) && state.views.length ? state.views : [defaultTimelineView(state.timelineRange)],
|
||||
exhibits: state.exhibits.map((exhibit, index) => ({ ...exhibit, ...placement(exhibit as unknown as Record<string, unknown>, { width: exhibit.type === 'document' ? 174 : 240, height: exhibit.type === 'document' ? 145 : 160 }, index) })),
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||
@@ -278,7 +280,7 @@ export function normalizeCase(input: CaseState | LegacyCaseState): CaseState {
|
||||
const connections: Connection[] = (state.connections || []).map(connection => ({ ...connection, id: String(connection.id),
|
||||
fromExhibitId: String(connection.fromExhibitId || connection.fromEvidenceId), toExhibitId: String(connection.toExhibitId || connection.toEvidenceId) } as Connection))
|
||||
return { id: state.id, title: state.title, subtitle: state.subtitle, exhibits: [...documents, ...evidence], relations: derivedRelations, connections,
|
||||
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, revision: Number(state.revision || 0),
|
||||
views: [defaultTimelineView(state.timelineRange)], viewport: state.viewport, brief: state.brief || { body: '', concepts: [] }, goals: state.goals || [], revision: Number(state.revision || 0),
|
||||
updatedAt: state.updatedAt, levelStatus: state.levelStatus, sourceTemplateVersionId: state.sourceTemplateVersionId, editingAllowed: state.editingAllowed,
|
||||
newlyVisibleDocumentIds: state.newlyVisibleDocumentIds || [] }
|
||||
}
|
||||
|
||||
+9
-2
@@ -51,6 +51,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.case-heading { position: absolute; top: 20px; left: 27px; z-index: 2; display: flex; color: #dde2de; pointer-events: none; }
|
||||
.case-heading h1 { font: 500 24px Special Elite, serif; margin: 6px 0 5px; letter-spacing: .02em; }
|
||||
.case-heading p { margin: 0; color: #a06d3e; font: 9px IBM Plex Mono; letter-spacing: .13em; }
|
||||
.active-goal { pointer-events: auto; width: min(430px, 48vw); margin-top: 13px; padding: 8px 11px; display: grid; gap: 3px; text-align: left; border: 1px solid #536860; border-left: 3px solid #cf8644; background: #0b211ccc; color: #d5dcd8; box-shadow: 3px 4px #02090766; cursor: pointer; }
|
||||
.active-goal span { color: #d18b49; font: 600 7px IBM Plex Mono; letter-spacing: .13em; }.active-goal b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 9px IBM Plex Mono; }.active-goal.complete { border-left-color: #71b889; }.active-goal.complete span { color: #7fc594; }
|
||||
.case-number { border-left: 1px solid #415750; margin-left: 26px; padding-left: 17px; font: 8px IBM Plex Mono; color: #6d867f; line-height: 1.5; }
|
||||
.case-number b { color: #bdc9c3; font-size: 13px; }
|
||||
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
|
||||
@@ -193,12 +195,16 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.brief-panel > header { position: sticky; z-index: 3; top: 0; height: 44px; padding: 0 7px 0 13px; display: flex; align-items: center; background: #173d34; color: #e0e8e4; cursor: default; }
|
||||
.brief-panel > header div { display: grid; gap: 2px; }.brief-panel > header > span { flex: 1; }.brief-panel > header small { color: #9bb0a9; font: 7px IBM Plex Mono; letter-spacing: .14em; }.brief-panel > header b { font: 10px IBM Plex Mono; }.brief-panel > header button { flex: 0 0 auto; width: 25px; height: 24px; margin-left: 4px; display: grid; place-items: center; padding: 0; border: 1px outset #e8ece8; background: #c9cec8; color: #17312b; cursor: pointer; }.brief-panel > header button:hover { background: #eef0eb; color: #070d0b; }
|
||||
.brief-panel.minimized { width: min(330px, 42vw); overflow: hidden; }.brief-panel.minimized > :not(header) { display: none; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
.brief-panel > p { margin: 16px; padding: 13px; white-space: pre-line; background: #e2dfd2; border-left: 3px solid #a66d37; font: 13px/1.55 Special Elite; }
|
||||
.brief-goals { margin: 0 16px 16px; display: grid; gap: 7px; }.brief-goals section { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid #89928b; background: #d5d5ca; }.brief-goals section > i { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid #9b6232; border-radius: 50%; color: #854d25; font: 600 10px IBM Plex Mono; font-style: normal; }.brief-goals section > div { display: grid; gap: 4px; }.brief-goals b { color: #293b35; font: 600 9px IBM Plex Mono; }.brief-goals span { color: #5b6862; font: 10px/1.4 Special Elite; }.brief-goals em { color: #9a5f2e; font: 600 7px IBM Plex Mono; font-style: normal; letter-spacing: .08em; }.brief-goals section.complete { background: #cbd9cc; border-color: #78927d; }.brief-goals section.complete > i { border-color: #437558; background: #4d7f60; color: #f1f1e7; }.brief-goals section.complete em { color: #34644a; }
|
||||
.brief-concepts { border-top: 1px solid #8c958e; }.brief-concepts section { padding: 12px 15px; border-bottom: 1px solid #959c95; }.brief-concepts section.resolved { background: #d5ddcf; }.brief-concepts section.just-resolved { animation: concept-resolved 1.15s ease-out; }.brief-concepts section > div:first-child { display: grid; gap: 4px; }.brief-concepts b { font: 600 10px IBM Plex Mono; }.brief-concepts span { color: #616d67; font: 9px Special Elite; }
|
||||
.classify-actions, .resolved-actions { display: flex; align-items: center; gap: 7px; margin-top: 9px; }.classify-actions button, .resolved-actions button, .edit-brief, .new-party-from-brief { display: inline-flex; align-items: center; gap: 5px; border: 1px outset #89948e; background: #e3e1d6; color: #29463e; padding: 7px 8px; cursor: pointer; font: 8px IBM Plex Mono; }.resolved-actions > span { margin-right: auto; display: inline-flex; align-items: center; gap: 5px; color: #31584d; font: 600 8px IBM Plex Mono; }.edit-brief, .new-party-from-brief { margin: 12px 15px 0; }.edit-brief { background: #234c41; color: white; }.new-party-from-brief { width: calc(100% - 30px); justify-content: center; border-style: dashed; background: #d8d8cf; }.dismiss-brief { width: calc(100% - 30px); margin: 12px 15px 14px; padding: 9px; border: 1px outset #73877f; background: #1e473d; color: white; cursor: pointer; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }
|
||||
@keyframes concept-resolved { 0% { background: #e8b76c; box-shadow: inset 4px 0 #b56127; } 100% { background: #d5ddcf; box-shadow: inset 0 0 transparent; } }
|
||||
.concept-editor-list { max-height: 280px; overflow: auto; border: 1px solid #8d968f; background: #d4d5cd; }.concept-editor-row { display: grid; grid-template-columns: 150px 1fr 125px 30px; gap: 6px; padding: 7px; border-bottom: 1px solid #9fa59e; }.concept-editor-row input, .concept-editor-row select { min-width: 0; border: 1px solid #7d8780; background: #e8e5d8; padding: 7px; font: 9px IBM Plex Mono; }.concept-editor-row button { border: 0; color: #713c2d; }
|
||||
.file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; }
|
||||
.goal-complete-shade { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 18px; background: #020907d9; backdrop-filter: blur(3px); animation: goal-shade-in .28s ease-out both; }.goal-complete-card { position: relative; width: min(570px, calc(100vw - 36px)); min-height: 390px; padding: 48px 54px 43px; overflow: hidden; display: flex; flex-direction: column; align-items: center; text-align: center; border: 1px solid #8b9d95; background: radial-gradient(circle at 50% 20%, #173c33, #0a211c 62%); box-shadow: 0 0 0 5px #081511, 0 0 0 6px #4f635b, 16px 19px 0 #0008; animation: goal-card-in .52s cubic-bezier(.16,.8,.2,1) both; }.goal-complete-card::before { content: ''; position: absolute; inset: 11px; pointer-events: none; border: 1px solid #405b51; }.goal-complete-signal { width: 100%; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; color: #78928a; font: 7px IBM Plex Mono; letter-spacing: .17em; }.goal-complete-signal i { height: 1px; background: #526d64; }.goal-complete-card > small { margin-top: 40px; color: #e09a52; font: 600 9px IBM Plex Mono; letter-spacing: .25em; }.goal-complete-card h2 { max-width: 430px; margin: 12px 0 13px; color: #edf1ed; font: 27px/1.18 Special Elite; }.goal-complete-card p { margin: 0; color: #9fb1aa; font: 9px/1.6 IBM Plex Mono; letter-spacing: .08em; }.goal-complete-stamp { margin: 24px 0; padding: 8px 14px 6px; transform: rotate(-3deg); border: 2px solid #8a5e39; color: #bd7c43; font: 7px IBM Plex Mono; letter-spacing: .15em; opacity: .9; }.goal-complete-stamp b { font-size: 15px; }.goal-complete-card > button { min-width: 190px; padding: 11px 17px; border: 1px solid #d29351; background: #a25527; color: #fff0db; font: 600 10px IBM Plex Mono; letter-spacing: .14em; cursor: pointer; box-shadow: 4px 5px #020907; }.goal-complete-card > button:hover:not(:disabled) { background: #c06a31; }.goal-complete-card > button:disabled { opacity: .58; cursor: wait; }.goal-complete-card > button span { margin-left: 8px; }
|
||||
@keyframes goal-shade-in { from { opacity: 0; } }
|
||||
@keyframes goal-card-in { from { opacity: 0; transform: translateY(24px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
|
||||
.temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
|
||||
@@ -306,6 +312,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.timeline-track { margin: 0 23px; }
|
||||
.document-window { width: 80vw; }
|
||||
.case-heading { left: 18px; }
|
||||
.active-goal { width: min(360px, calc(100vw - 92px)); }
|
||||
.case-number { display: none; }
|
||||
.brief-panel { position: fixed; inset: 0; width: 100vw; height: 100dvh; max-height: none; border-width: 0; box-shadow: none; }
|
||||
.brief-panel > header { position: sticky; z-index: 2; top: 0; min-height: 48px; padding-left: max(13px, env(safe-area-inset-left)); padding-right: max(7px, env(safe-area-inset-right)); }
|
||||
@@ -321,7 +328,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.board-actions button { flex: 0 0 36px; width: 36px; justify-content: center; }
|
||||
.board-actions > span { flex: 0 0 1px; width: 30px; height: 1px; margin: 3px 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .source-file-widget.arriving, .doc-row.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse, .goal-complete-shade, .goal-complete-card { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }
|
||||
|
||||
/* Narrative layer: splash + NPC dialogue */
|
||||
.splash { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle at 50% 40%, #123029 0, #071916 68%); color: #9bb0a9; }
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface DocumentUploadAnalysis {
|
||||
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
|
||||
matchedFlags: string[]
|
||||
awardedFlags: string[]
|
||||
goals: LevelGoal[]
|
||||
}
|
||||
|
||||
export interface UploadedCaseDocument extends DocumentExhibit {
|
||||
@@ -150,6 +151,22 @@ export interface BriefConcept {
|
||||
|
||||
export interface LevelBrief { body: string; concepts: BriefConcept[] }
|
||||
|
||||
export interface LevelGoal {
|
||||
/** Present in author mode so the goal can be edited; omitted in play mode. */
|
||||
id?: string
|
||||
key: string
|
||||
title: string
|
||||
instructions: string
|
||||
completionMessage: string
|
||||
enabled?: boolean
|
||||
/** Author-only success condition. Expected answer flags stay out of play payloads. */
|
||||
requiredFlags?: string[]
|
||||
status: 'pending' | 'complete'
|
||||
completedAt?: string
|
||||
/** True only in the response to the mutation that completed this goal. */
|
||||
newlyCompleted: boolean
|
||||
}
|
||||
|
||||
export interface CaseState {
|
||||
id: string
|
||||
title: string
|
||||
@@ -160,6 +177,7 @@ export interface CaseState {
|
||||
views: BoardView[]
|
||||
viewport: Viewport
|
||||
brief: LevelBrief
|
||||
goals: LevelGoal[]
|
||||
revision: number
|
||||
updatedAt?: string
|
||||
levelStatus?: string
|
||||
@@ -185,6 +203,8 @@ export interface EvidenceMatchAnchorDefinition {
|
||||
export interface EvidenceMatchRuleDefinition {
|
||||
id: string
|
||||
name: string
|
||||
sourceLabel?: string
|
||||
sourceUri?: string
|
||||
flagKey: string
|
||||
matcherVersion: 'char_trigram_v1'
|
||||
minimumAnchorMatches: number
|
||||
@@ -192,6 +212,32 @@ export interface EvidenceMatchRuleDefinition {
|
||||
anchors: EvidenceMatchAnchorDefinition[]
|
||||
}
|
||||
|
||||
export interface EvidenceSemanticRuleDefinition {
|
||||
id: string
|
||||
goalId: string
|
||||
goalKey: string
|
||||
name: string
|
||||
targetSubject: string
|
||||
relatedSubject?: string
|
||||
assertion: string
|
||||
successFlagKey: string
|
||||
relatedFlagKey?: string
|
||||
minimumConfidence: number
|
||||
evaluatorVersion: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface DocumentSemanticAnalysis {
|
||||
status: 'not_needed' | 'unavailable' | 'pending' | 'succeeded' | 'failed'
|
||||
subject?: 'target' | 'related' | 'ambiguous' | 'neither'
|
||||
supportsClaim?: boolean
|
||||
evidenceExcerpt?: string
|
||||
confidence?: number
|
||||
retryable: boolean
|
||||
awardedFlags: string[]
|
||||
goals: LevelGoal[]
|
||||
}
|
||||
|
||||
export function isDocumentExhibit(exhibit: Exhibit): exhibit is DocumentExhibit { return exhibit.type === 'document' }
|
||||
export function isEvidenceExhibit(exhibit: Exhibit): exhibit is Evidence { return exhibit.type !== 'document' }
|
||||
export function isFolderExhibit(exhibit: Exhibit): exhibit is FolderExhibit { return exhibit.type === 'folder' }
|
||||
|
||||
Reference in New Issue
Block a user