Initial commit

This commit is contained in:
2026-08-14 12:43:11 +02:00
commit fa78640227
25 changed files with 6003 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim
ENV NODE_ENV=production PORT=8787
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
COPY --from=build /app/src/types.ts ./src/types.ts
COPY --from=build /app/migrations ./migrations
EXPOSE 8787
CMD ["node", "--import", "tsx", "server/index.ts"]
+89
View File
@@ -0,0 +1,89 @@
# GUPI OSINT Board
A standalone, server-backed proof of concept for the Glitch University investigation desktop. Levels are authored data: the repository contains the engine and schema, not a compiled-in case.
The accepted normalized domain model and terminology are specified in [`docs/exhibit-data-model.md`](docs/exhibit-data-model.md). PostgreSQL stores **exhibits**; frontend **widgets** visualize exhibit types.
Planned schema and exhibit work is tracked in [`docs/TODO.md`](docs/TODO.md).
## Run locally
Requires Node 20 or 22 (Node 22 is used by the production container).
The self-contained development stack starts PostgreSQL, runs migrations, and serves the production build:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Open `http://localhost:8787`. PostgreSQL is also exposed on `localhost:5433` for inspection.
For Vite hot reload, start only the database, then run the server and web app natively:
```bash
docker compose -f docker-compose.dev.yml up -d db
cp .env.example .env
npm install
npm run migrate:up
npm run dev
```
Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
## Data and API
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
The accepted model stores each playable or editable level as an isolated mutable board of normalized exhibits. Immutable template-version boards are cloned to create levels, and mutable levels can be cloned back into new template versions. Assets remain immutable and reusable across those copies.
The running POC still uses the earlier `widgets` and `playthrough_*` tables while the exhibit-schema cutover is prepared. They are explicitly transitional; new domain concepts should follow the exhibit model rather than extending those tables.
The API surface is:
- `GET /api/levels`
- `POST /api/levels` (editor only)
- `GET /api/levels/:id`
- `PUT /api/levels/:id`
- `POST /api/levels/:id/reset`
- `GET /api/health`
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
## Level editor
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. The target model intentionally makes editing and playing the same operation against a mutable level; authoring additionally exposes “save as template”. The current playthrough overlay remains only until the exhibit-schema migration is complete.
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. This is a capability switch, not authentication; add authentication before exposing production editing to untrusted users.
## POC exhibit and widget contract
The current POC defines four exhibit families and corresponding frontend widgets:
- **Folder** — a titled, annotated collection. Opening it expands its contained source files to their saved relation-bound board positions; closing it retracts and hides them.
- **Source file** — immutable binary evidence plus user-editable title, publication time, file type, and arbitrary key/value metadata. Image is the first fully visual file renderer. PDF, web capture, email, article, filing, price list, text, and generic file are registered types with a generic fallback until their renderers are built.
- **Note** — investigator-authored interpretation, visually distinct from source evidence.
- **Event** — an investigator-authored “this happened” statement with narrative text, an occurrence time, and normalized links to its supporting evidence. Chronologically ordered events become the emerging story.
The planned **Party** family adds distinct Person and Organization exhibits (businesses are organizations). Their dossier-like widgets reveal normalized evidence associations, but parties remain identity objects rather than special folders.
Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders.
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
The frontend widget registry maps an exhibit type, and optionally a document type, to its React visualization. Widgets do not own investigation-domain data.
## Deploy at osint.glitch.university
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
```bash
./deploy.sh
```
The deploy script builds and syncs the application, reads production database credentials from `/opt/gu_common/.env.prod`, executes pending migrations in the `osint` schema, starts `gnommo-osint-board`, and waits for its health endpoint.
## Deliberate POC boundaries
There are no accounts, arbitrary uploads, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# deploy.sh - Deploy OSINT Board to the shared gu_common infrastructure
set -e
SKIP_PULL=false
if [ "$1" = "--skip-pull" ]; then
SKIP_PULL=true
fi
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
REMOTE_DIR="${DEPLOY_DIR:-/opt/osint-board}"
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file /opt/gu_common/.env.prod"
TARGET_HOST=$(echo "${SERVER}" | sed 's/.*@//')
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
if [ "$OWN_IP" = "$TARGET_HOST" ]; then
echo "Error: deploy.sh must be run from your local machine, not the server."
exit 1
fi
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "==> Deploying OSINT Board to ${SERVER}:${REMOTE_DIR}"
if [ "$SKIP_PULL" = true ]; then
echo "==> Skipping git pull (--skip-pull flag set)"
else
echo "==> Checking for upstream changes..."
HEAD_BEFORE=$(git -C "$REPO_DIR" rev-parse HEAD)
if ! git -C "$REPO_DIR" pull --ff-only; then
echo "Error: git pull failed. Resolve local state before deploying."
exit 1
fi
HEAD_AFTER=$(git -C "$REPO_DIR" rev-parse HEAD)
if [ "$HEAD_BEFORE" != "$HEAD_AFTER" ]; then
echo "New upstream commits were pulled. Review and test them before deploying."
git -C "$REPO_DIR" log --oneline "${HEAD_BEFORE}..${HEAD_AFTER}"
exit 1
fi
fi
echo "==> Building locally..."
npm run build
echo "==> Syncing files..."
ssh "$SERVER" "mkdir -p ${REMOTE_DIR}"
rsync -avz --delete \
--exclude 'node_modules' \
--exclude '.git' \
--exclude 'dist' \
--exclude '.env' \
--exclude '.env.local' \
--exclude '.env.prod' \
--exclude '.DS_Store' \
./ "${SERVER}:${REMOTE_DIR}/"
echo "==> Verifying gu_common configuration..."
ssh "$SERVER" "test -f /opt/gu_common/.env.prod || { echo 'ERROR: /opt/gu_common/.env.prod is missing'; exit 1; }"
echo "==> Ensuring shared network exists..."
ssh "$SERVER" "docker network create gnommo 2>/dev/null || true"
echo "==> Building application image..."
ssh "$SERVER" "CACHEBUST=\$(date +%s) && $COMPOSE build --build-arg CACHEBUST=\$CACHEBUST app"
echo "==> Waiting for gu_common PostgreSQL..."
for i in $(seq 1 12); do
if ssh "$SERVER" "docker exec gnommo-db pg_isready -q" 2>/dev/null; then
echo " PostgreSQL is ready."
break
fi
echo " Not ready yet, waiting... ($i/12)"
sleep 5
if [ "$i" -eq 12 ]; then
echo "ERROR: gu_common PostgreSQL did not become ready."
exit 1
fi
done
echo "==> Running osint schema migrations..."
ssh "$SERVER" "$COMPOSE run --rm --no-deps app npm run migrate:up"
echo "==> Starting application..."
ssh "$SERVER" "$COMPOSE up -d app"
echo "==> Waiting for OSINT Board health check..."
for i in $(seq 1 24); do
if ssh "$SERVER" "docker exec gnommo-osint-board node -e \"fetch('http://127.0.0.1:8787/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" 2>/dev/null; then
echo " OSINT Board is healthy."
break
fi
echo " Not ready yet, waiting... ($i/24)"
sleep 5
if [ "$i" -eq 24 ]; then
echo "ERROR: OSINT Board did not become healthy within 2 minutes."
exit 1
fi
done
echo "==> Done! https://osint.glitch.university"
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
container_name: osint-board-db
restart: unless-stopped
environment:
POSTGRES_DB: osint_dev
POSTGRES_USER: osint
POSTGRES_PASSWORD: osint_secret
ports:
- "5433:5432"
volumes:
- osint_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U osint -d osint_dev"]
interval: 3s
timeout: 3s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
container_name: osint-board-app
environment:
NODE_ENV: production
PORT: 8787
DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev
CORS_ORIGIN: http://localhost:8787
LEVEL_EDITING_ENABLED: "true"
MAX_DOCUMENT_BYTES: 26214400
ports:
- "8787:8787"
depends_on:
db:
condition: service_healthy
command: ["sh", "-c", "npm run migrate:up && npm start"]
volumes:
osint_postgres_data:
+24
View File
@@ -0,0 +1,24 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: gnommo-osint-board
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 8787
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
CORS_ORIGIN: https://osint.${DOMAIN}
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
expose:
- "8787"
networks:
- default
- gnommo
networks:
default: {}
gnommo:
external: true
+26
View File
@@ -0,0 +1,26 @@
# GUPI OSINT Board roadmap
This list tracks domain and product work that follows the accepted exhibit model. It is not a substitute for migrations or implementation issues.
## Exhibit-schema cutover
- [ ] Add boards, exhibits, exhibit types, subtype tables, immutable template versions, and cloned mutable levels.
- [ ] Migrate transitional `widgets`, `widget_relations`, and `playthrough_*` data with equivalence checks.
- [ ] Implement template instantiation and “save level as template” as transactional clone operations.
- [ ] Move the frontend to an exhibit/widget registry backed by the normalized API.
## Party exhibits
- [ ] Add a Party exhibit supertype representing an investigation participant.
- [ ] Add a Person subtype with display name, normalized aliases, and extensible structured identity fields.
- [ ] Add an Organization subtype covering businesses, public bodies, associations, and informal groups.
- [ ] Add normalized many-to-many Party-to-Evidence associations with an optional explanatory note.
- [ ] Add typed Party-to-Party relationships such as employment, ownership, membership, control, and representation.
- [ ] Build distinct Person and Organization widgets that can open as dossiers and reveal associated evidence without using folder ownership semantics.
- [ ] Include parties, aliases, evidence associations, and party relationships in template/level cloning.
## Events and narrative
- [ ] Implement Event exhibits with occurrence time and investigator-authored narrative text.
- [ ] Implement normalized Event-to-Evidence links and their board visualization.
- [ ] Present chronologically ordered events as the emerging investigation story.
+330
View File
@@ -0,0 +1,330 @@
# GUPI OSINT Board: canonical exhibit data model
Status: accepted design foundation.
## Vocabulary
- An **exhibit** is a durable investigation-domain object stored in PostgreSQL.
- An **exhibit type** describes its domain behavior: folder, document, clipping, note, event, party, or conclusion.
- A **widget** is the frontend visualization and interaction implementation selected for an exhibit type.
- A **document type** specializes a document exhibit: image, PDF, web capture, email, article, filing, price list, text, or generic file.
- A **board** is a neutral container for exhibits. Both mutable levels and immutable template versions own boards.
- A **level** is a mutable board copy used for either play or authoring.
- A **level template version** is an immutable board snapshot.
The frontend registry maps `exhibit_type` (and, for documents, `document_type`) to a widget component. Changing a widget must never change the meaning or storage of its exhibit.
## Lifecycle
Templates are never edited in place.
1. A template family has one or more immutable versions.
2. Starting work creates a new level and clones the selected template-version board into it.
3. Every cloned exhibit receives a new ID, belongs to the new level board, and retains provenance through `origin_exhibit_id`.
4. Playing and editing write to exactly the same level and exhibit tables.
5. “Save as template” clones the current level board into a new immutable template version.
6. Binary assets are immutable and shared; document exhibits are copied, asset bytes are not.
No long-term `playthrough_*` overlay is required. A level is already the user's isolated working copy.
## Core structure
```sql
CREATE TABLE osint.boards (
id UUID PRIMARY KEY,
board_kind TEXT NOT NULL CHECK (board_kind IN ('level', 'template_version')),
revision BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.level_templates (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
current_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.level_template_versions (
id UUID PRIMARY KEY,
template_id UUID NOT NULL REFERENCES osint.level_templates(id),
version INTEGER NOT NULL,
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id),
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
created_from_level_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (template_id, version)
);
CREATE TABLE osint.levels (
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
board_id UUID NOT NULL UNIQUE REFERENCES osint.boards(id),
source_template_version_id UUID REFERENCES osint.level_template_versions(id),
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 28,
viewport_zoom DOUBLE PRECISION NOT NULL DEFAULT 0.7,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
`boards.revision` is incremented transactionally and used for optimistic concurrency control.
## Exhibits
Common spatial and lifecycle properties belong to the base exhibit, not to its widget or subtype.
```sql
CREATE TABLE osint.exhibit_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
is_spatial BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE osint.exhibits (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
exhibit_type_id TEXT NOT NULL REFERENCES osint.exhibit_types(id),
origin_exhibit_id UUID REFERENCES osint.exhibits(id) ON DELETE SET NULL,
xpos DOUBLE PRECISION NOT NULL,
ypos DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
height DOUBLE PRECISION NOT NULL,
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
z_index INTEGER NOT NULL DEFAULT 0,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (board_id, id)
);
```
The common table is extended with one-to-one subtype tables. Domain fields do not live in an unstructured `config` document.
```sql
CREATE TABLE osint.folder_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
label_text TEXT NOT NULL DEFAULT '',
is_open BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE osint.document_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE osint.document_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
document_type_id TEXT NOT NULL REFERENCES osint.document_types(id),
asset_id UUID REFERENCES osint.assets(id),
title TEXT NOT NULL,
published_at TIMESTAMPTZ,
captured_at TIMESTAMPTZ,
source_uri TEXT
);
CREATE TABLE osint.image_documents (
exhibit_id UUID PRIMARY KEY REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
pixel_width INTEGER CHECK (pixel_width > 0),
pixel_height INTEGER CHECK (pixel_height > 0),
alt_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE osint.note_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
note_text TEXT NOT NULL
);
CREATE TABLE osint.event_exhibits (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
title TEXT NOT NULL,
narrative_text TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL
);
```
The service validates that every base exhibit has exactly one subtype row matching `exhibit_type_id`.
### Event semantics
An event is an investigator-authored assertion: **“this happened.”** It is not source evidence and must not silently inherit a document's publication time.
- `narrative_text` states what the investigator believes happened.
- `occurred_at` places that assertion in reconstructed time.
- One event may cite several supporting exhibits.
- One exhibit may support several events.
- Events ordered by `occurred_at` form the emerging case narrative; there is no duplicated story-text record.
Supporting evidence is an explicit normalized relationship:
```sql
CREATE TABLE osint.event_evidence (
event_exhibit_id UUID NOT NULL REFERENCES osint.event_exhibits(exhibit_id) ON DELETE CASCADE,
evidence_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
note TEXT,
PRIMARY KEY (event_exhibit_id, evidence_exhibit_id),
CHECK (event_exhibit_id <> evidence_exhibit_id)
);
```
A deferred constraint trigger verifies that both exhibits belong to the same board and that the evidence endpoint is not itself the same event. The optional `note` explains why that exhibit supports the event; it does not replace the event's narrative.
In the frontend, the Event widget shows its occurrence time, narrative text, and evidence count. Opening or selecting it reveals its supporting exhibits. Lines between an event and its evidence visualize `event_evidence`; they are not ordinary folder containment bands.
### Party semantics (planned)
A party is a person or organization that participates in the investigation. Businesses are organizations. A party is a first-class exhibit, not a special folder: identity and evidence association must not be represented as file ownership.
- A **person** has a display name and may have aliases and other structured identity fields.
- An **organization** has a display name and an organization kind such as business, public body, association, or informal group.
- A party may be linked to several supporting exhibits, and one exhibit may concern several parties.
- Relationships between parties—employment, ownership, membership, control, representation, or an investigator-defined connection—are explicit typed relationships rather than containment.
The intended normalized shape is a `party_exhibits` supertype with one-to-one `person_parties` and `organization_parties` subtype tables, plus a many-to-many `party_evidence` association. Aliases and party-to-party relationships use child tables rather than arrays or widget configuration JSON.
The frontend provides distinct Person and Organization widgets through the exhibit registry. They may visually behave like dossiers—opening one can reveal associated evidence—but that interaction is derived from `party_evidence`; it does not turn the party into a folder or cause evidence to be owned by or disappear into the party.
## Assets and document content
`assets` stores immutable uploaded bytes, checksum, MIME type, original filename, and size. Multiple cloned document exhibits may reference one asset.
Document text and extractable regions are normalized separately:
```sql
CREATE TABLE osint.document_content_blocks (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
content TEXT NOT NULL,
UNIQUE (document_exhibit_id, sort_order)
);
CREATE TABLE osint.document_regions (
id UUID PRIMARY KEY,
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
region_key TEXT NOT NULL,
label TEXT NOT NULL,
excerpt TEXT NOT NULL,
occurred_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (document_exhibit_id, region_key)
);
```
## Folder ownership
Folder containment is a domain relationship, not a generic visual connection.
```sql
CREATE TABLE osint.folder_memberships (
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
folder_exhibit_id UUID NOT NULL REFERENCES osint.folder_exhibits(exhibit_id) ON DELETE CASCADE,
child_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (folder_exhibit_id, child_exhibit_id),
UNIQUE (board_id, child_exhibit_id),
CHECK (folder_exhibit_id <> child_exhibit_id)
);
```
For the POC, one exhibit has at most one owning folder. This makes open/close behavior deterministic. If the same uploaded image must appear in two folders, two document exhibits reference the same immutable asset.
Composite foreign keys or deferred constraint triggers enforce that folder, child, and membership all belong to the same board and that folders cannot contain themselves or form containment cycles.
The pale red folder band is derived from this table. Its geometry is not stored.
## Investigative connections and provenance
```sql
CREATE TABLE osint.connection_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
directed BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE osint.exhibit_connections (
id UUID PRIMARY KEY,
board_id UUID NOT NULL REFERENCES osint.boards(id) ON DELETE CASCADE,
connection_type_id TEXT NOT NULL REFERENCES osint.connection_types(id),
from_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
to_exhibit_id UUID NOT NULL REFERENCES osint.exhibits(id) ON DELETE CASCADE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (from_exhibit_id <> to_exhibit_id)
);
CREATE TABLE osint.exhibit_sources (
exhibit_id UUID PRIMARY KEY REFERENCES osint.exhibits(id) ON DELETE CASCADE,
source_document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id),
source_region_id UUID REFERENCES osint.document_regions(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
Red investigative thread is derived from `exhibit_connections`. Extraction provenance is stored in `exhibit_sources`; it is not represented as a visual thread.
## Document metadata
Known, semantically important values remain real columns: `published_at`, `captured_at`, and `source_uri`. Truly author-defined fields use typed metadata definitions and values rather than JSONB or one untyped EAV table.
```sql
CREATE TABLE osint.metadata_fields (
id UUID PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
value_type TEXT NOT NULL CHECK (value_type IN ('text', 'timestamp', 'number', 'boolean'))
);
CREATE TABLE osint.document_metadata_text_values (
document_exhibit_id UUID NOT NULL REFERENCES osint.document_exhibits(exhibit_id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES osint.metadata_fields(id),
value TEXT NOT NULL,
PRIMARY KEY (document_exhibit_id, field_id)
);
```
Timestamp, number, and boolean values use equivalent type-specific tables. This lets PostgreSQL enforce actual value types and avoids nullable multi-type value columns.
## Derived presentation
The following are computed and must not become duplicate source-of-truth tables:
- Grey timeline projection: document exhibit position to `published_at` on the timeline.
- Pale red containment band: folder position to contained exhibit position.
- Folder flight animation: closed folder position to the exhibit's stored `xpos` and `ypos`.
- Widget selection: `exhibit_type` plus optional `document_type` mapped through the frontend registry.
## Transactional operations
`instantiate_template_version(source_version_id)` performs one database transaction:
1. Create the level board and level row.
2. Clone all exhibits and build an old-to-new ID map.
3. Clone each subtype row.
4. Clone content, typed metadata, and provenance through the map.
5. Clone folder memberships, event-evidence links, and exhibit connections through the map.
6. Reuse asset IDs.
7. Commit only after all constraints pass.
`save_level_as_template(level_id)` runs the same clone operation into a new immutable template-version board.
## Migration direction
The current `widgets`, `widget_relations`, and `playthrough_*` tables are transitional. The cutover should:
1. Introduce boards, exhibit tables, subtype tables, templates, and typed metadata.
2. Convert existing authored widgets to exhibits.
3. Materialize each existing playthrough as its own cloned mutable level.
4. Move document layout from relation JSON into document exhibit coordinates.
5. Convert `contains` relations to folder memberships and generic connections to exhibit connections.
6. Update the API to read and write exhibits directly.
7. Remove the transitional widget/playthrough tables only after data equivalence checks pass.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#071916" />
<title>GUPI OSINT Board</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE osint.cases (
id TEXT PRIMARY KEY,
state_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON SCHEMA osint IS 'GUPI OSINT Board application data';
COMMENT ON TABLE osint.cases IS 'Server-authoritative investigation board state';
+130
View File
@@ -0,0 +1,130 @@
CREATE TABLE osint.levels (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.widgets (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
widget_type TEXT NOT NULL CHECK (widget_type IN ('document', 'evidence', 'note', 'event')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}'::jsonb,
source_widget_id TEXT REFERENCES osint.widgets(id) ON DELETE SET NULL,
source_region_key TEXT,
event_date DATE,
x DOUBLE PRECISION,
y DOUBLE PRECISION,
width DOUBLE PRECISION,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (level_id, id)
);
CREATE INDEX widgets_level_type_idx ON osint.widgets(level_id, widget_type, sort_order);
CREATE TABLE osint.widget_regions (
id TEXT PRIMARY KEY,
document_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
region_key TEXT NOT NULL,
label TEXT NOT NULL,
excerpt TEXT NOT NULL,
event_date DATE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (document_widget_id, region_key)
);
CREATE TABLE osint.level_connections (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
to_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE osint.playthroughs (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
viewport JSONB NOT NULL DEFAULT '{"x":0,"y":28,"zoom":0.7}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.playthrough_widget_state (
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
annotation TEXT,
PRIMARY KEY (playthrough_id, widget_id)
);
CREATE TABLE osint.playthrough_widgets (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
widget_type TEXT NOT NULL CHECK (widget_type IN ('evidence', 'note', 'event')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
source_widget_id TEXT REFERENCES osint.widgets(id) ON DELETE SET NULL,
source_region_key TEXT,
event_date DATE,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE osint.playthrough_connections (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL,
to_widget_id TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Preserve levels saved by the pre-widget architecture, if any exist.
INSERT INTO osint.levels (id, title, subtitle, status, created_at, updated_at)
SELECT id, state_json->>'title', COALESCE(state_json->>'subtitle', ''), 'draft', created_at, updated_at
FROM osint.cases;
INSERT INTO osint.widgets (id, level_id, widget_type, title, content, config, event_date, sort_order)
SELECT doc->>'id', c.id, 'document', doc->>'title', '',
jsonb_build_object('kind', doc->>'kind', 'date', doc->>'date', 'body', COALESCE(doc->'body', '[]'::jsonb)),
NULLIF(doc->>'date', '')::date, ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'documents') WITH ORDINALITY AS item(doc, ordinality);
INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
SELECT (doc->>'id') || ':' || (region->>'id'), doc->>'id', region->>'id', region->>'label', region->>'excerpt',
NULLIF(region->>'date', '')::date, region_ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'documents') AS docs(doc)
CROSS JOIN LATERAL jsonb_array_elements(doc->'regions') WITH ORDINALITY AS regions(region, region_ordinality);
INSERT INTO osint.widgets (id, level_id, widget_type, title, content, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
SELECT ev->>'id', c.id, ev->>'type', ev->>'title', ev->>'content', NULLIF(ev->>'sourceDocumentId', ''),
NULLIF(ev->>'sourceRegionId', ''), NULLIF(ev->>'eventDate', '')::date,
(ev->>'x')::double precision, (ev->>'y')::double precision, (ev->>'width')::double precision, ordinality::integer
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'evidence') WITH ORDINALITY AS item(ev, ordinality);
INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id)
SELECT conn->>'id', c.id, conn->>'fromEvidenceId', conn->>'toEvidenceId'
FROM osint.cases c
CROSS JOIN LATERAL jsonb_array_elements(c.state_json->'connections') AS connections(conn);
INSERT INTO osint.playthroughs (id, level_id, viewport)
SELECT 'default:' || id, id, COALESCE(state_json->'viewport', '{"x":0,"y":28,"zoom":0.7}'::jsonb)
FROM osint.cases;
DROP TABLE osint.cases;
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE osint.assets (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
content BYTEA NOT NULL,
checksum_sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX assets_level_idx ON osint.assets(level_id, created_at);
ALTER TABLE osint.widgets
ADD COLUMN asset_id TEXT REFERENCES osint.assets(id) ON DELETE SET NULL;
COMMENT ON TABLE osint.assets IS 'Uploaded source files used by database-backed document widgets';
@@ -0,0 +1,56 @@
-- Widget kinds are intentionally data-driven. Removing enum-like checks means
-- future widget types do not require a table rewrite merely to exist.
ALTER TABLE osint.widgets DROP CONSTRAINT IF EXISTS widgets_widget_type_check;
ALTER TABLE osint.playthrough_widgets DROP CONSTRAINT IF EXISTS playthrough_widgets_widget_type_check;
ALTER TABLE osint.widgets ADD COLUMN published_at TIMESTAMPTZ;
UPDATE osint.widgets
SET published_at = event_date::timestamp AT TIME ZONE 'UTC'
WHERE widget_type = 'document' AND event_date IS NOT NULL;
CREATE TABLE osint.widget_relations (
id TEXT PRIMARY KEY,
level_id TEXT NOT NULL REFERENCES osint.levels(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
to_widget_id TEXT NOT NULL REFERENCES osint.widgets(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (from_widget_id, to_widget_id, relation_type)
);
CREATE INDEX widget_relations_from_idx ON osint.widget_relations(level_id, from_widget_id, relation_type);
CREATE INDEX widget_relations_to_idx ON osint.widget_relations(level_id, to_widget_id, relation_type);
CREATE TABLE osint.playthrough_widget_relations (
id TEXT PRIMARY KEY,
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
from_widget_id TEXT NOT NULL,
to_widget_id TEXT NOT NULL,
relation_type TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (playthrough_id, from_widget_id, to_widget_id, relation_type)
);
UPDATE osint.widgets SET widget_type = 'folder' WHERE widget_type = 'evidence';
UPDATE osint.playthrough_widgets SET widget_type = 'folder' WHERE widget_type = 'evidence';
INSERT INTO osint.widget_relations (id, level_id, from_widget_id, to_widget_id, relation_type)
SELECT 'contains:' || id || ':' || source_widget_id, level_id, id, source_widget_id, 'contains'
FROM osint.widgets
WHERE widget_type = 'folder' AND source_widget_id IS NOT NULL
ON CONFLICT (from_widget_id, to_widget_id, relation_type) DO NOTHING;
INSERT INTO osint.playthrough_widget_relations
(id, playthrough_id, from_widget_id, to_widget_id, relation_type)
SELECT 'contains:' || playthrough_id || ':' || id || ':' || source_widget_id,
playthrough_id, id, source_widget_id, 'contains'
FROM osint.playthrough_widgets
WHERE widget_type = 'folder' AND source_widget_id IS NOT NULL
ON CONFLICT (playthrough_id, from_widget_id, to_widget_id, relation_type) DO NOTHING;
COMMENT ON COLUMN osint.widgets.published_at IS 'Publication/creation time of a source document; chronology belongs to documents, not folders';
COMMENT ON TABLE osint.widget_relations IS 'Generic authored widget graph; contains is the first relation type';
+15
View File
@@ -0,0 +1,15 @@
-- Runtime state is generic JSON so future widget and relation renderers can
-- persist interaction-specific state without adding a column per widget kind.
ALTER TABLE osint.playthrough_widget_state
ADD COLUMN config JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE osint.playthrough_widget_relation_state (
playthrough_id TEXT NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
relation_id TEXT NOT NULL REFERENCES osint.widget_relations(id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (playthrough_id, relation_id)
);
COMMENT ON COLUMN osint.playthrough_widget_state.config IS 'Player-owned runtime and metadata overrides for any authored widget type';
COMMENT ON TABLE osint.playthrough_widget_relation_state IS 'Player-owned layout/state overrides for authored widget relations';
+3990
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "gupi-osint-board",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"",
"dev:web": "vite",
"dev:server": "tsx watch server/index.ts",
"build": "tsc -b && vite build",
"start": "NODE_ENV=production tsx server/index.ts",
"migrate:up": "tsx server/migrate.ts",
"test": "vitest run"
},
"dependencies": {
"cors": "2.8.5",
"dotenv": "16.5.0",
"express": "5.1.0",
"lucide-react": "0.468.0",
"multer": "2.0.2",
"pg": "8.16.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tsx": "4.20.3"
},
"devDependencies": {
"@types/cors": "2.8.18",
"@types/express": "5.0.3",
"@types/node": "22.15.30",
"@types/multer": "2.0.0",
"@types/pg": "8.15.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@vitejs/plugin-react": "4.5.2",
"concurrently": "9.1.2",
"typescript": "5.8.3",
"vite": "6.3.5",
"vitest": "3.2.3"
},
"engines": {
"node": ">=20"
}
}
+290
View File
@@ -0,0 +1,290 @@
import 'dotenv/config'
import cors from 'cors'
import express from 'express'
import fs from 'node:fs'
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import multer from 'multer'
import pg, { type PoolClient } from 'pg'
import type { CaseDocument, CaseState, Connection, Evidence, WidgetRelation } from '../src/types.js'
const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('DATABASE_URL is required. Run PostgreSQL and execute npm run migrate:up first.')
process.exit(1)
}
const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
type WidgetRow = {
id: string; widget_type: 'document' | Evidence['type']; title: string; content: string
config: { kind?: string; date?: string; body?: string[]; fileType?: CaseDocument['fileType']; metadata?: Record<string, string>; [key: string]: unknown }; source_widget_id?: string; source_region_key?: string
event_date?: string; published_at?: string; x?: number; y?: number; width?: number; sort_order: number; asset_id?: string
original_name?: string; mime_type?: string; byte_size?: number
}
function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1'
}
async function assembleLevel(levelId: string, playthroughId = `default:${levelId}`, authorMode = false): Promise<CaseState | null> {
const levelResult = await pool.query<{ id: string; title: string; subtitle: string; status: string }>(
'SELECT id, title, subtitle, status FROM osint.levels WHERE id = $1', [levelId],
)
const level = levelResult.rows[0]
if (!level) return null
await pool.query(
`INSERT INTO osint.playthroughs (id, level_id) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
[playthroughId, levelId],
)
const [widgetsResult, regionsResult, authoredConnections, authoredRelations, playthroughResult, generatedResult, playerConnections, playerRelations, stateResult, relationStateResult] = await Promise.all([
pool.query<WidgetRow>(`SELECT w.id, w.widget_type, w.title, w.content, w.config, w.source_widget_id, w.source_region_key,
w.event_date::text, w.published_at::text, w.x, w.y, w.width, w.sort_order, w.asset_id, a.original_name, a.mime_type, a.byte_size
FROM osint.widgets w LEFT JOIN osint.assets a ON a.id = w.asset_id
WHERE w.level_id = $1 ORDER BY w.sort_order, w.id`, [levelId]),
pool.query<{ document_widget_id: string; region_key: string; label: string; excerpt: string; event_date?: string }>(
`SELECT r.document_widget_id, r.region_key, r.label, r.excerpt, r.event_date::text
FROM osint.widget_regions r JOIN osint.widgets w ON w.id = r.document_widget_id
WHERE w.level_id = $1 ORDER BY r.sort_order, r.id`, [levelId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
'SELECT id, from_widget_id, to_widget_id FROM osint.level_connections WHERE level_id = $1', [levelId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.widget_relations
WHERE level_id = $1 ORDER BY sort_order, id`, [levelId]),
pool.query<{ viewport: CaseState['viewport']; updated_at: Date }>(
'SELECT viewport, updated_at FROM osint.playthroughs WHERE id = $1', [playthroughId]),
pool.query<WidgetRow>(`SELECT id, widget_type, title, content, config, source_widget_id,
source_region_key, event_date::text, x, y, width, 0 AS sort_order
FROM osint.playthrough_widgets WHERE playthrough_id = $1 ORDER BY created_at, id`, [playthroughId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string }>(
'SELECT id, from_widget_id, to_widget_id FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId]),
pool.query<{ id: string; from_widget_id: string; to_widget_id: string; relation_type: string; sort_order: number; config: Record<string, unknown> }>(
`SELECT id, from_widget_id, to_widget_id, relation_type, sort_order, config FROM osint.playthrough_widget_relations
WHERE playthrough_id = $1 ORDER BY sort_order, id`, [playthroughId]),
pool.query<{ widget_id: string; x: number; y: number; width: number; hidden: boolean; config: Record<string, unknown> }>(
'SELECT widget_id, x, y, width, hidden, config FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId]),
pool.query<{ relation_id: string; config: Record<string, unknown> }>(
'SELECT relation_id, config FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId]),
])
const stateByWidget = new Map(authorMode ? [] : stateResult.rows.map(row => [row.widget_id, row]))
const documents: CaseDocument[] = widgetsResult.rows.filter(w => w.widget_type === 'document').map(w => { const override = stateByWidget.get(w.id)?.config || {}; const publishedAt = String(override.publishedAt || w.published_at || ''); return ({
id: w.id, title: String(override.title || w.title), kind: w.config.kind || 'DOCUMENT', date: publishedAt.slice(0, 10) || w.config.date || '', publishedAt: publishedAt || undefined,
body: w.config.body || [], fileType: (override.fileType || w.config.fileType || (w.mime_type?.startsWith('image/') ? 'image' : 'file')) as CaseDocument['fileType'], metadata: (override.metadata || w.config.metadata || {}) as Record<string, string>,
assetId: w.asset_id, fileName: w.original_name, mimeType: w.mime_type, fileSize: w.byte_size,
regions: regionsResult.rows.filter(r => r.document_widget_id === w.id).map(r => ({ id: r.region_key, label: r.label, excerpt: r.excerpt, date: r.event_date })),
}) })
const relationState = new Map(authorMode ? [] : relationStateResult.rows.map(row => [row.relation_id, row.config]))
const containedByFolder = new Map<string, string[]>()
for (const relation of [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)]) {
if (relation.relation_type !== 'contains') continue
containedByFolder.set(relation.from_widget_id, [...(containedByFolder.get(relation.from_widget_id) || []), relation.to_widget_id])
}
const toEvidence = (w: WidgetRow): Evidence => {
const override = stateByWidget.get(w.id)
const runtimeConfig = { ...w.config, ...(override?.config || {}) }
return { id: w.id, type: w.widget_type as Evidence['type'], title: String(override?.config?.title || w.title), content: String(override?.config?.content ?? w.content), config: runtimeConfig,
sourceDocumentId: w.source_widget_id, sourceRegionId: w.source_region_key, eventDate: w.widget_type === 'event' ? w.event_date : undefined,
containedDocumentIds: containedByFolder.get(w.id) || (w.source_widget_id ? [w.source_widget_id] : []),
x: override?.x ?? w.x ?? 100, y: override?.y ?? w.y ?? 100, width: override?.width ?? w.width ?? 240 }
}
const authoredEvidence = widgetsResult.rows.filter(w => w.widget_type !== 'document' && !stateByWidget.get(w.id)?.hidden).map(toEvidence)
const evidence = [...authoredEvidence, ...(authorMode ? [] : generatedResult.rows.map(toEvidence))]
const connections: Connection[] = [...authoredConnections.rows, ...(authorMode ? [] : playerConnections.rows)].map(c => ({
id: c.id, fromEvidenceId: c.from_widget_id, toEvidenceId: c.to_widget_id,
}))
const relations: WidgetRelation[] = [...authoredRelations.rows, ...(authorMode ? [] : playerRelations.rows)].map(relation => ({
id: relation.id, fromWidgetId: relation.from_widget_id, toWidgetId: relation.to_widget_id, type: relation.relation_type,
sortOrder: relation.sort_order, config: { ...relation.config, ...(relationState.get(relation.id) || {}) },
}))
const playthrough = playthroughResult.rows[0]
return { id: level.id, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections,
viewport: playthrough.viewport, updatedAt: playthrough.updated_at.toISOString(), levelStatus: level.status, editingAllowed: editingEnabled }
}
async function savePlaythrough(client: PoolClient, state: CaseState) {
const playthroughId = `default:${state.id}`
const authored = await client.query<{ id: string }>('SELECT id FROM osint.widgets WHERE level_id = $1', [state.id])
const authoredIds = new Set(authored.rows.map(row => row.id))
const authoredRelations = await client.query<{ id: string }>('SELECT id FROM osint.widget_relations WHERE level_id = $1', [state.id])
const authoredRelationIds = new Set(authoredRelations.rows.map(row => row.id))
await client.query('UPDATE osint.playthroughs SET viewport = $2::jsonb, updated_at = NOW() WHERE id = $1', [playthroughId, JSON.stringify(state.viewport)])
await client.query('DELETE FROM osint.playthrough_widget_state WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widget_relation_state WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widget_relations WHERE playthrough_id = $1', [playthroughId])
await client.query('DELETE FROM osint.playthrough_widgets WHERE playthrough_id = $1', [playthroughId])
for (const document of state.documents) {
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1,$2,0,0,0,$3::jsonb)`, [playthroughId, document.id, JSON.stringify({ title: document.title, publishedAt: document.publishedAt || null, fileType: document.fileType, metadata: document.metadata })])
}
for (const widget of state.evidence) {
if (authoredIds.has(widget.id)) {
await client.query(`INSERT INTO osint.playthrough_widget_state (playthrough_id, widget_id, x, y, width, config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, [playthroughId, widget.id, widget.x, widget.y, widget.width, JSON.stringify({ ...(widget.config || {}), title: widget.title, content: widget.content })])
} else {
await client.query(`INSERT INTO osint.playthrough_widgets
(id, playthrough_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12)`, [widget.id, playthroughId, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width])
}
}
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
for (const relation of (state.relations || fallbackRelations).filter(relation => authoredRelationIds.has(relation.id))) {
await client.query(`INSERT INTO osint.playthrough_widget_relation_state (playthrough_id, relation_id, config)
VALUES ($1,$2,$3::jsonb)`, [playthroughId, relation.id, JSON.stringify(relation.config || {})])
}
for (const relation of (state.relations || fallbackRelations).filter(relation => !authoredRelationIds.has(relation.id))) {
await client.query(`INSERT INTO osint.playthrough_widget_relations
(id, playthrough_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[relation.id, playthroughId, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
}
await client.query('DELETE FROM osint.playthrough_connections WHERE playthrough_id = $1', [playthroughId])
const authoredConnections = await client.query<{ id: string }>('SELECT id FROM osint.level_connections WHERE level_id = $1', [state.id])
const authoredConnectionIds = new Set(authoredConnections.rows.map(row => row.id))
for (const connection of state.connections.filter(c => !authoredConnectionIds.has(c.id))) {
await client.query(`INSERT INTO osint.playthrough_connections (id, playthrough_id, from_widget_id, to_widget_id)
VALUES ($1, $2, $3, $4)`, [connection.id, playthroughId, connection.fromEvidenceId, connection.toEvidenceId])
}
}
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
for (const [index, doc] of state.documents.entries()) {
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, published_at, asset_id, sort_order)
VALUES ($1,$2,'document',$3,$4::jsonb,$5,$6,$7)`, [doc.id, state.id, doc.title, JSON.stringify({ kind: doc.kind, body: doc.body, fileType: doc.fileType, metadata: doc.metadata }), doc.publishedAt || doc.date || null, doc.assetId || null, index])
for (const [regionIndex, region] of doc.regions.entries()) {
await client.query(`INSERT INTO osint.widget_regions (id, document_widget_id, region_key, label, excerpt, event_date, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [`${doc.id}:${region.id}`, doc.id, region.id, region.label, region.excerpt, region.date || null, regionIndex])
}
}
for (const [index, widget] of state.evidence.entries()) {
await client.query(`INSERT INTO osint.widgets
(id, level_id, widget_type, title, content, config, source_widget_id, source_region_key, event_date, x, y, width, sort_order)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,$13)`, [widget.id, state.id, widget.type, widget.title, widget.content, JSON.stringify(widget.config || {}),
widget.sourceDocumentId || null, widget.sourceRegionId || null, widget.eventDate || null, widget.x, widget.y, widget.width, index])
}
const fallbackRelations: WidgetRelation[] = state.evidence.flatMap(widget => (widget.containedDocumentIds || []).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
for (const relation of state.relations || fallbackRelations) {
await client.query(`INSERT INTO osint.widget_relations
(id, level_id, from_widget_id, to_widget_id, relation_type, sort_order, config)
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[relation.id, state.id, relation.fromWidgetId, relation.toWidgetId, relation.type, relation.sortOrder || 0, JSON.stringify(relation.config || {})])
}
for (const connection of state.connections) {
await client.query(`INSERT INTO osint.level_connections (id, level_id, from_widget_id, to_widget_id)
VALUES ($1,$2,$3,$4)`, [connection.id, state.id, connection.fromEvidenceId, connection.toEvidenceId])
}
}
const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
app.use(express.json({ limit: '2mb' }))
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: Number(process.env.MAX_DOCUMENT_BYTES || 25 * 1024 * 1024), files: 1 },
})
app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/levels', async (_req, res, next) => {
try { const result = await pool.query('SELECT id, title, subtitle, status, updated_at AS "updatedAt" FROM osint.levels ORDER BY updated_at DESC'); res.json(result.rows) }
catch (error) { next(error) }
})
app.post('/api/levels', async (req, res, next) => {
try {
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || 'Untitled Investigation').trim()
const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-')
await pool.query('INSERT INTO osint.levels (id, title, subtitle) VALUES ($1, $2, $3)', [id, title, String(req.body?.subtitle || '')])
const level = await assembleLevel(id, `default:${id}`, true)
res.status(201).json(level)
} catch (error) { next(error) }
})
app.get('/api/assets/:id', async (req, res, next) => {
try {
const result = await pool.query<{ original_name: string; mime_type: string; byte_size: string; content: Buffer }>(
'SELECT original_name, mime_type, byte_size, content FROM osint.assets WHERE id = $1', [req.params.id],
)
const asset = result.rows[0]
if (!asset) return res.status(404).json({ error: 'Asset not found' })
const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/')
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream')
res.setHeader('Content-Length', asset.byte_size)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.send(asset.content)
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!req.file) return res.status(400).json({ error: 'A file is required' })
const client = await pool.connect()
try {
const level = await client.query('SELECT id FROM osint.levels WHERE id = $1', [req.params.id])
if (!level.rows[0]) return res.status(404).json({ error: 'Level not found' })
const assetId = `asset-${randomUUID()}`
const widgetId = `document-${randomUUID()}`
const checksum = createHash('sha256').update(req.file.buffer).digest('hex')
const kind = req.file.mimetype === 'application/pdf' ? 'PDF' : req.file.mimetype.startsWith('image/') ? 'IMAGE' : 'FILE'
const fileType: CaseDocument['fileType'] = req.file.mimetype.startsWith('image/') ? 'image' : req.file.mimetype === 'application/pdf' ? 'pdf' : 'file'
await client.query('BEGIN')
await client.query(`INSERT INTO osint.assets (id, level_id, original_name, mime_type, byte_size, content, checksum_sha256)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [assetId, req.params.id, req.file.originalname, req.file.mimetype || 'application/octet-stream', req.file.size, req.file.buffer, checksum])
await client.query(`INSERT INTO osint.widgets (id, level_id, widget_type, title, config, asset_id, sort_order)
VALUES ($1,$2,'document',$3,$4::jsonb,$5,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM osint.widgets WHERE level_id=$2 AND widget_type='document'))`,
[widgetId, req.params.id, req.file.originalname, JSON.stringify({ kind, body: [], fileType, metadata: {} }), assetId])
await client.query('UPDATE osint.levels SET updated_at = NOW() WHERE id = $1', [req.params.id])
await client.query('COMMIT')
res.status(201).json({ id: widgetId, title: req.file.originalname, kind, fileType, metadata: {}, date: '', body: [], regions: [], assetId,
fileName: req.file.originalname, mimeType: req.file.mimetype, fileSize: req.file.size })
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
})
app.get('/api/levels/:id', async (req, res, next) => {
try { const level = await assembleLevel(req.params.id, `default:${req.params.id}`, wantsEdit(req)); level ? res.json(level) : res.status(404).json({ error: 'Level not found' }) }
catch (error) { next(error) }
})
app.put('/api/levels/:id', async (req, res, next) => {
const state = req.body as CaseState
if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
const client = await pool.connect()
try {
await client.query('BEGIN')
if (wantsEdit(req)) await saveAuthoredLevel(client, state)
else await savePlaythrough(client, state)
await client.query('COMMIT'); res.json({ ok: true, mode: wantsEdit(req) ? 'author' : 'play' })
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
})
app.post('/api/levels/:id/reset', async (req, res, next) => {
const client = await pool.connect()
try {
const playthroughId = `default:${req.params.id}`
await client.query('BEGIN')
await client.query('DELETE FROM osint.playthroughs WHERE id = $1', [playthroughId])
await client.query('COMMIT')
const level = await assembleLevel(req.params.id); level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
} catch (error) { await client.query('ROLLBACK'); next(error) } finally { client.release() }
})
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof multer.MulterError) {
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
}
console.error(error); res.status(500).json({ error: 'Internal server error' })
})
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787)
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown)
+51
View File
@@ -0,0 +1,51 @@
import 'dotenv/config'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
const { Client } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('DATABASE_URL is required')
process.exit(1)
}
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
console.log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
console.log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
console.log('OSINT migrations complete.')
} finally {
await client.end()
}
+457
View File
@@ -0,0 +1,457 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
const BOARD_W = 2400
const BOARD_H = 1500
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
{ value: 'image', label: 'Image' }, { value: 'pdf', label: 'PDF' }, { value: 'web_capture', label: 'Web capture' },
{ value: 'email', label: 'Email' }, { value: 'article', label: 'Article' }, { value: 'filing', label: 'Company filing' },
{ value: 'price_list', label: 'Price list' }, { value: 'text', label: 'Text document' }, { value: 'file', label: 'Generic file' },
]
function uid(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }
function connectionPoint(item: Evidence) {
return item.type === 'note' ? { x: item.x + 54, y: item.y + 12 } : { x: item.x + item.width / 2, y: item.y + 68 }
}
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
function normalizeCase(state: CaseState): CaseState {
const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
const membership = new Map<string, string[]>()
for (const relation of relations.filter(relation => relation.type === 'contains').sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) membership.set(relation.fromWidgetId, [...(membership.get(relation.fromWidgetId) || []), relation.toWidgetId])
return { ...state, relations,
documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {} })),
evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: membership.get(widget.id) || [] })) }
}
function containedIds(state: CaseState, widgetId: string) {
return state.relations.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map(relation => relation.toWidgetId)
}
function folderIsOpen(folder: Evidence) { return folder.config?.open === true }
function relationPosition(state: CaseState, relation: WidgetRelation) {
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
const order = relation.sortOrder || 0
const configuredX = Number(relation.config?.x), configuredY = Number(relation.config?.y)
return {
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
}
}
export function App() {
const [caseState, setCaseState] = useState<CaseState | null>(null)
const [noLevels, setNoLevels] = useState(false)
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [linkFrom, setLinkFrom] = useState<string | null>(null)
const [docsOpen, setDocsOpen] = useState(true)
const [helpOpen, setHelpOpen] = useState(false)
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
const [clock, setClock] = useState('')
const [draggingFiles, setDraggingFiles] = useState(false)
const [uploading, setUploading] = useState(0)
const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
useEffect(() => {
const params = new URLSearchParams(window.location.search)
fetch('/api/levels').then(r => {
if (!r.ok) throw new Error('Server unavailable')
return r.json()
}).then(async (levels: { id: string }[]) => {
const levelId = params.get('level') || levels[0]?.id
if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
if (!response.ok) throw new Error('Level unavailable')
const data = await response.json()
setCaseState(normalizeCase(data)); setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
})
.catch(() => {
const cached = localStorage.getItem('gupi-osint-board:last')
if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
})
const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
tick(); const timer = window.setInterval(tick, 30000)
return () => clearInterval(timer)
}, [])
const update = useCallback((fn: (state: CaseState) => CaseState) => {
setCaseState(current => {
if (!current) return current
const next = fn(current)
localStorage.setItem('gupi-osint-board:last', JSON.stringify(next))
window.clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(() => {
const editQuery = requestedEditMode && next.editingAllowed ? '?edit=1' : ''
fetch(`/api/levels/${encodeURIComponent(next.id)}${editQuery}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(next) })
.then(r => { if (!r.ok) throw new Error(); setStatus('SAVED TO CASE ARCHIVE') })
.catch(() => setStatus('OFFLINE · SAVED LOCALLY'))
}, 450)
return next
})
}, [requestedEditMode])
const focusEvidence = (id: string) => {
if (!caseState) return
const ev = caseState.evidence.find(e => e.id === id)
if (!ev) return
setSelected(id)
update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
}
const extract = (doc: CaseDocument, regionId: string) => {
if (!caseState) return
const region = doc.regions.find(r => r.id === regionId)!
const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId)
if (existing) { setOpenDoc(null); focusEvidence(existing.id); return }
const ev: Evidence = {
id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false },
sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id],
x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
}
update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] }))
setOpenDoc(null); setSelected(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
}
const addNote = () => {
const content = window.prompt('What do you think this evidence means?')?.trim()
if (!content || !caseState) return
const { viewport } = caseState
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom), width: 108 }
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
}
const handleCardClick = (id: string) => {
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) {
update(s => ({ ...s, connections: [...s.connections, { id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: id }] }))
}
setLinkFrom(null); setSelected(id)
}
const reset = async () => {
if (!window.confirm('Reset the entire investigation board?')) return
const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
}
const uploadFiles = async (files: FileList | File[]) => {
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
const queue = Array.from(files)
setUploading(queue.length)
setDraggingFiles(false)
for (const file of queue) {
const form = new FormData()
form.append('file', file)
try {
const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const document: CaseDocument = await response.json()
update(s => ({ ...s, documents: [...s.documents, document] }))
setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
} catch (error) {
setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
} finally { setUploading(count => count - 1) }
}
}
if (noLevels) return <EmptyArchive canEdit={requestedEditMode} onCreated={level => { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?level=${encodeURIComponent(level.id)}&edit=1`) }} />
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
const temporalItems: TemporalItem[] = [
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
const document = documentById.get(documentId)
const date = document?.publishedAt || document?.date
const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId)
return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, documentId: document.id }] : []
})),
...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
].sort((a, b) => dateValue(a.date) - dateValue(b.date))
return <main className="desktop">
<header className="menubar">
<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 onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button><button onClick={() => setHelpOpen(true)}>HELP</button>
</nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header>
<section className="workspace">
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{caseState.documents.length}</sup></h2></div><button onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
<div className="search"><Search size={15}/><span>Search case archive</span></div>
{requestedEditMode && caseState.editingAllowed && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
<div className="doc-list">
{caseState.documents.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
<div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
</button>)}
</div>
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
</aside>
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { 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) 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>
<Board state={caseState} selected={selected} linkFrom={linkFrom} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} />
{!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
<div className="board-actions">
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
<span />
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
<span />
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.min(1.5, s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
</div>
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
</div>
</section>
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} selected={selected} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main>
}
function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (level: CaseState) => void }) {
const [creating, setCreating] = useState(false)
const createLevel = async () => {
const title = window.prompt('Name this investigation level:', 'Untitled Investigation')?.trim()
if (!title) return
setCreating(true)
try {
const response = await fetch('/api/levels', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) })
if (!response.ok) throw new Error('Could not create level')
onCreated(await response.json())
} finally { setCreating(false) }
}
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
}
function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile }: { state: CaseState; selected: string | null; linkFrom: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
useEffect(() => {
const board = boardRef.current
if (!board) return
const handlePinch = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
event.stopPropagation()
update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, Math.min(1.5, s.viewport.zoom - event.deltaY * .006)) } }))
}
board.addEventListener('wheel', handlePinch, { passive: false })
return () => board.removeEventListener('wheel', handlePinch)
}, [boardRef, update])
const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget' | 'relation'; id: string }) => {
if ((event.target as HTMLElement).closest('button')) return
const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
const position = relation ? relationPosition(state, relation) : undefined
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
event.currentTarget.setPointerCapture(event.pointerId)
}
const pointerMove = (event: React.PointerEvent) => {
if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
if (drag.current.kind === 'widget') update(s => ({ ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } : e) }))
else if (drag.current.kind === 'relation') update(s => ({ ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } } : relation) }))
else update(s => ({ ...s, viewport: { ...s.viewport, x: drag.current!.originX + dx, y: drag.current!.originY + dy } }))
}
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
return <div className={`board-viewport tool-${tool}`} ref={boardRef}
onPointerDown={e => { if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerMove={pointerMove} onPointerUp={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}>
{state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return <g key={c.id}><path d={`M ${p1.x} ${p1.y} C ${(p1.x+p2.x)/2} ${p1.y}, ${(p1.x+p2.x)/2} ${p2.y}, ${p2.x} ${p2.y}`}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
</svg>
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg>
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${ev.type} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
onPointerDown={e => { e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag() }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
<header><span>{ev.type === 'note' ? 'INVESTIGATOR / NOTE' : ev.type === 'folder' ? `EVIDENCE FOLDER / ${containedDocuments.length}` : ev.type.toUpperCase()}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
<div className="card-content"><h3>{ev.title}</h3><p>{ev.content}</p>
{ev.eventDate && <time>{ev.eventDate.replaceAll('-', ' / ')}</time>}
{ev.type === 'folder' && !folderIsOpen(ev) && <div className="folder-documents">{containedDocuments.slice(0, 3).map(document => <button key={document.id} onClick={() => onOpenSource(document.id)} title={document.title}><FileText size={12}/><span>{document.title}</span>{(document.publishedAt || document.date) && <time>{(document.publishedAt || document.date).slice(0, 10)}</time>}</button>)}{containedDocuments.length > 3 && <small>+ {containedDocuments.length - 3} MORE FILES</small>}</div>}
{ev.type === 'folder' && <div className="folder-actions"><button onClick={() => toggleFolder(ev.id)}>{folderIsOpen(ev) ? <Folder size={12}/> : <FolderOpen size={12}/>} {folderIsOpen(ev) ? 'CLOSE' : 'OPEN'}</button><button onClick={() => onEditFolder(ev.id)}><Pencil size={12}/> EDIT</button></div>}
{ev.type !== 'folder' && ev.sourceDocumentId && <button onClick={() => onOpenSource(ev.sourceDocumentId!)}><BookOpen size={13}/> VIEW SOURCE</button>}</div>
</article>})}
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
onPointerDown={event => { event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag() }} onDoubleClick={() => open && onOpenSource(document.id)}>
<header><span>{document.fileType.replaceAll('_', ' ').toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview">{document.fileType === 'image' && document.assetId ? <img draggable={false} src={`/api/assets/${encodeURIComponent(document.assetId)}`} alt=""/> : <div><ImageIcon size={35}/><small>{document.kind}</small></div>}</div>
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
<div className="source-file-actions"><button onClick={() => onOpenSource(document.id)}><BookOpen size={12}/> OPEN</button><button onClick={() => onEditFile(document.id)}><Pencil size={12}/> METADATA</button></div>
</article> })}
</div>
</div>
}
function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) {
const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([])
const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|')
useLayoutEffect(() => {
let frame = 0
let animateUntil = Date.now() + 500
const measure = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(function measureFrame() {
const sources = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-temporal-id]')).map(element => [element.dataset.temporalId, element]))
const markers = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-marker-id]')).map(element => [element.dataset.markerId, element]))
setLines(items.flatMap(item => {
const source = sources.get(item.sourceTemporalId), marker = markers.get(item.id)
if (!source || !marker) return []
const from = source.getBoundingClientRect(), to = marker.getBoundingClientRect()
const clip = source.closest<HTMLElement>('.board-viewport, .doc-list')?.getBoundingClientRect()
const visibleLeft = Math.max(from.left, clip?.left ?? 0), visibleRight = Math.min(from.right, clip?.right ?? window.innerWidth)
const visibleTop = Math.max(from.top, clip?.top ?? 0), visibleBottom = Math.min(from.bottom, clip?.bottom ?? window.innerHeight)
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) return []
return [{ id: item.id, x1: visibleLeft + (visibleRight - visibleLeft) / 2, y1: visibleBottom, x2: to.left + to.width / 2, y2: to.top + to.height / 2 }]
}))
if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
})
}
measure()
const observer = new ResizeObserver(measure)
document.querySelectorAll<HTMLElement>('.workspace, .timeline, [data-temporal-id], [data-marker-id]').forEach(element => observer.observe(element))
const handleResize = () => { animateUntil = Date.now() + 500; measure() }
window.addEventListener('resize', handleResize)
return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', handleResize) }
}, [itemsKey, layoutKey])
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
}
function dateValue(date: string) {
const parsed = Date.parse(date)
return Number.isFinite(parsed) ? parsed : 0
}
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
const itemYears = items.map(item => Number(item.date.slice(0, 4))).filter(Number.isFinite)
let startYear = itemYears.length ? Math.min(...itemYears) : new Date().getFullYear() - 2
let endYear = itemYears.length ? Math.max(...itemYears) : startYear + 4
if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear); startYear -= Math.floor(missing / 2); endYear += Math.ceil(missing / 2) }
const start = Date.UTC(startYear, 0, 1), end = Date.UTC(endYear, 11, 31)
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
const position = (date: string) => Math.max(0, Math.min(100, ((dateValue(date) - start) / (end - start)) * 100))
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
}
function localDateTime(value?: string) {
if (!value) return ''
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00`
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return ''
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, members: string[]) => void }) {
const [title, setTitle] = useState(folder.title)
const [content, setContent] = useState(folder.content)
const [members, setMembers] = useState(memberIds)
const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
const submit = (event: React.FormEvent) => {
event.preventDefault()
onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim(), containedDocumentIds: members }, members)
}
return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
<header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body">
<small>FOLDER WIDGET</small>
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
<label className="field"><span>ANNOTATION</span><textarea rows={3} value={content} onChange={event => setContent(event.target.value)}/></label>
<div className="folder-members-heading"><div><b>CONTAINED SOURCE FILES</b><small>{members.length} SELECTED</small></div><span>{canManageContents ? 'LEVEL CONTENT' : 'FIXED CONTENT'}</span></div>
<div className="folder-members">
{documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
<label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
<span className="folder-member-date">{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</span>
</div> })}
</div>
<p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE FOLDER</button></div>
</div>
</form></div>
}
function FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
const [title, setTitle] = useState(document.title)
const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt || document.date))
const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
const submit = (event: React.FormEvent) => {
event.preventDefault()
const publishedAt = publishedTime ? new Date(publishedTime).toISOString() : undefined
onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, date: publishedAt?.slice(0, 10) || '', metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
}
return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
<header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
<div className="file-editor-body">
<small>SOURCE FILE WIDGET</small>
<div className="file-editor-grid">
<label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
<label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
</div>
<label className="field"><span><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
</div>
</form></div>
}
function DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => void; onExtract: (id: string) => void; extracted: (string | undefined)[] }) {
const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
const [minimized, setMinimized] = useState(false)
const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
const startDrag = (e: React.PointerEvent<HTMLElement>) => {
if ((e.target as HTMLElement).closest('button')) return
drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
e.currentTarget.setPointerCapture(e.pointerId)
}
return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
<header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
{!minimized && <><nav>FILE&nbsp;&nbsp; EDIT&nbsp;&nbsp; EVIDENCE&nbsp;&nbsp; VIEW</nav>
<div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.kind}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
<footer><span>ARCHIVE ITEM · {doc.date}</span><span>PROVENANCE LOCKED</span></footer></>}
</section>
}
function DocumentAsset({ doc }: { doc: CaseDocument }) {
const source = `/api/assets/${encodeURIComponent(doc.assetId!)}`
if (doc.mimeType?.startsWith('image/')) return <img className="document-image" src={source} alt={doc.fileName || doc.title}/>
if (doc.mimeType === 'application/pdf' || doc.mimeType?.startsWith('text/')) return <iframe className="document-frame" src={source} title={doc.fileName || doc.title} sandbox="allow-same-origin"/>
return <div className="unsupported-file"><FileText size={42}/><b>{doc.fileName || doc.title}</b><span>{doc.mimeType || 'Unknown file type'} · {doc.fileSize ? `${Math.ceil(doc.fileSize / 1024)} KB` : ''}</span><a href={source} download={doc.fileName}>DOWNLOAD ORIGINAL</a></div>
}
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select a card, choose Connect, then select its target.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
+6
View File
@@ -0,0 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(<StrictMode><App /></StrictMode>)
+177
View File
@@ -0,0 +1,177 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Special+Elite&display=swap');
:root { font-family: Inter, sans-serif; color: #d8ded9; background: #071916; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; overflow: hidden; }
button { font: inherit; color: inherit; }
button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.desktop { height: 100vh; display: grid; grid-template-rows: 74px 1fr 112px; background: #071916; }
.menubar { display: grid; grid-template-columns: 310px 1fr auto; align-items: center; padding: 0 24px; border-bottom: 1px solid #315049; background: #0a211d; box-shadow: 0 5px 22px #0008; z-index: 10; }
.brand { display: flex; gap: 13px; align-items: center; font: 600 12px IBM Plex Mono; letter-spacing: .08em; }
.brand em { color: #78938c; font-style: normal; font-weight: 400; }
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #d88938; color: #e99a44; font: 600 11px IBM Plex Mono; transform: rotate(-3deg); }
.menubar nav { display: flex; height: 100%; align-items: stretch; }
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
.menubar nav button:hover { background: #112f2a; color: white; }
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }
.terminal-status span { color: #d9a05d; margin-left: 20px; font-size: 13px; }
.workspace { display: flex; min-height: 0; }
.documents-panel { width: 315px; flex: 0 0 auto; background: #102722; border-right: 1px solid #3c514b; transition: margin .25s; display: grid; grid-template-rows: auto auto auto 1fr auto; z-index: 5; box-shadow: 10px 0 40px #00100d66; }
.documents-panel.closed { margin-left: -315px; }
.panel-heading { padding: 25px 20px 17px; display: flex; justify-content: space-between; align-items: start; }
.panel-heading small, .case-heading small, .timeline small, .help small { font: 500 9px IBM Plex Mono; letter-spacing: .18em; color: #78958d; }
.panel-heading h2 { font: 600 17px IBM Plex Mono; letter-spacing: .04em; margin: 6px 0 0; }
.panel-heading sup { color: #d89043; font-size: 10px; }
.panel-heading button { background: none; border: 0; color: #6f8b83; cursor: pointer; }
.search { margin: 0 15px 12px; height: 36px; border: 1px solid #314a44; display: flex; align-items: center; gap: 9px; padding: 0 12px; color: #607d75; font-size: 11px; }
.import-document { margin: 0 15px 12px; height: 34px; border: 1px dashed #a26d3d; background: #19322c; color: #d79754; display: flex; justify-content: center; align-items: center; gap: 8px; font: 600 9px IBM Plex Mono; letter-spacing: .08em; cursor: pointer; }.import-document:hover { background: #244039; border-style: solid; }.file-input { display: none; }
.doc-list { overflow: auto; border-top: 1px solid #243d36; }
.doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; }
.doc-row:hover { background: #18342e; }
.doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; }
.doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; }
.doc-row > svg { color: #527068; }
.doc-icon { height: 49px; border: 1px solid #61736d; display: grid; place-items: center; position: relative; color: #ccd2cd; background: #263c36; box-shadow: 3px 3px #081a16; }
.doc-icon b { position: absolute; bottom: 2px; right: 2px; background: #d58e42; color: #14231f; font: 600 7px IBM Plex Mono; padding: 1px 3px; }
.tint-1 { background: #38352d; } .tint-2 { background: #263c3f; }
.panel-foot { height: 42px; border-top: 1px solid #354b45; padding: 0 16px; display: flex; align-items: center; gap: 8px; font: 9px IBM Plex Mono; color: #718d84; }
.panel-foot span { margin-left: auto; color: #b27b43; }
.board-shell { flex: 1; min-width: 0; position: relative; background: #0b1d19; }
.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; }
.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; }
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
.folder-bands { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.folder-bands line { stroke: #b63232; stroke-width: 7; opacity: .2; filter: drop-shadow(1px 1px 0 #050a08); transition: x2 .42s cubic-bezier(.2,.75,.2,1), y2 .42s cubic-bezier(.2,.75,.2,1), opacity .24s ease; }
.folder-bands line.closed { opacity: 0; }
.evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; }
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); }
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
.evidence-card h3 { font: 600 10px IBM Plex Mono; letter-spacing: .09em; margin: 12px 0 6px; color: #9a5d2e; }
.evidence-card p { font: 500 16px Special Elite, serif; line-height: 1.35; margin: 0 0 12px; }
.evidence-card time { display: inline-block; border: 1px solid #7c867e; padding: 3px 5px; font: 9px IBM Plex Mono; }
.evidence-card button { float: right; background: transparent; border: 0; color: #55635e; display: flex; gap: 4px; align-items: center; font: 600 8px IBM Plex Mono; padding: 4px 0; cursor: pointer; }
.evidence-card.folder { min-height: 166px; padding-top: 17px; background: linear-gradient(104deg, #c9a66a, #d4b579 58%, #b99055); border-color: #e0c68f; box-shadow: 7px 9px 0 #020b0980, inset 0 0 22px #65451f24, 0 0 0 1px #61492e; }
.evidence-card.folder::before { content: ''; position: absolute; z-index: -1; top: -13px; left: 13px; width: 42%; height: 20px; border: 1px solid #dfc38c; border-bottom: 0; background: #caa76c; clip-path: polygon(0 0, 80% 0, 100% 100%, 0 100%); }
.evidence-card.folder::after { background: #80643e; }
.evidence-card.folder header { border-color: #846e49; color: #594a33; }
.evidence-card.folder h3 { color: #66401f; }
.folder-documents { clear: both; margin-top: 9px; border-top: 1px dashed #826c49; padding-top: 6px; }
.folder-documents button { float: none; width: 100%; height: 23px; padding: 2px 0; display: grid; grid-template-columns: 14px 1fr auto; text-align: left; color: #493d2c; }
.folder-documents button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-documents button time { border: 0; padding: 0; font-size: 7px; }
.folder-documents small { display: block; padding: 4px 0 1px 17px; color: #69573d; font: 7px IBM Plex Mono; }
.folder-actions { clear: both; display: flex; justify-content: flex-end; gap: 12px; margin-top: 7px; border-top: 1px solid #9a7d50; padding-top: 5px; }
.folder-actions button { float: none; color: #60401f; }
.source-file-widget { position: absolute; z-index: 4; width: 174px; min-height: 145px; padding: 8px; color: #1a2421; background: #d9d8cc; border: 1px solid #f1efe2; box-shadow: 5px 7px 0 #020b0980, 0 0 0 1px #53615c; cursor: move; user-select: none; transition: left .42s cubic-bezier(.2,.75,.2,1), top .42s cubic-bezier(.2,.75,.2,1), opacity .28s ease, transform .42s cubic-bezier(.2,.75,.2,1); }
.source-file-widget.closed { opacity: 0; transform: scale(.18) rotate(-8deg); pointer-events: none; }
.source-file-widget.open { opacity: 1; transform: scale(1) rotate(.6deg); }
.source-file-widget header { height: 18px; display: flex; justify-content: space-between; border-bottom: 1px solid #8b938d; color: #59645f; font: 600 7px IBM Plex Mono; letter-spacing: .1em; }
.source-file-preview { height: 79px; margin: 7px 0; display: grid; place-items: center; overflow: hidden; background: #263a35; border: 1px solid #707a74; }
.source-file-preview img { width: 100%; height: 100%; object-fit: cover; }
.source-file-preview > div { display: grid; justify-items: center; gap: 5px; color: #b8c2bd; }
.source-file-preview small { font: 7px IBM Plex Mono; }
.source-file-widget > strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }
.source-file-widget > time { display: block; margin-top: 3px; color: #86603b; font: 7px IBM Plex Mono; }
.source-file-actions { display: flex; justify-content: space-between; margin-top: 6px; border-top: 1px dashed #969b94; padding-top: 4px; }
.source-file-actions button { display: flex; align-items: center; gap: 3px; border: 0; background: transparent; padding: 2px; color: #4e5d57; cursor: pointer; font: 600 6px IBM Plex Mono; }
.evidence-card.note { width: 108px !important; height: 154px; min-height: 154px; padding: 27px 10px 11px; background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; rotate: -2deg !important; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; z-index: 2; }
.evidence-card.note::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; }
.evidence-card.note::after { height: 4px; bottom: -1px; background: #745a37; }
.evidence-card.note header { position: absolute; left: 9px; right: 9px; top: 21px; padding-bottom: 3px; font-size: 6px; color: #5b472d; border-color: #7e6542; }
.evidence-card.note header i { display: none; }
.evidence-card.note .card-content { height: 108px; padding-top: 10px; overflow: hidden; transition: transform .22s ease; }
.evidence-card.note h3 { color: #5b3c24; font-size: 7px; margin: 2px 0 5px; }
.evidence-card.note p { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; font-family: Special Elite; font-size: 12px; line-height: 1.25; }
.evidence-card.note.selected { z-index: 12; outline: 1px dashed #e6b168; outline-offset: 5px; transform: rotate(90deg) scale(1.55); filter: drop-shadow(12px 8px 5px #0008); }
.evidence-card.note.selected .card-content { width: 142px; height: 94px; margin: 4px 0 0 -17px; overflow: visible; transform: rotate(-90deg); }
.evidence-card.note.selected p { display: block; overflow: visible; font-size: 13px; line-height: 1.32; }
.evidence-card.note.selected h3 { font-size: 7px; }
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
.board-actions button:disabled { opacity: .35; cursor: default; }
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
.open-files { position: absolute; top: 25px; left: 25px; z-index: 4; border: 1px solid #4c675f; background: #122e28; padding: 11px 13px; display: flex; gap: 8px; font: 9px IBM Plex Mono; cursor: pointer; }
.open-files b { color: #e19a4d; }
.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; }
.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: 7; 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; }
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; }
.timeline-track { height: 75px; margin: 0 43px; position: relative; }
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; }
.year::before { content: ''; position: absolute; left: 50%; top: -9px; height: 5px; border-left: 1px solid #6a7e78; }
.marker { position: absolute; width: 19px; height: 29px; transform: translateX(-50%); border: 0; background: transparent; cursor: pointer; padding: 0; }
.marker i { display: block; width: 10px; height: 10px; border: 2px solid #8eb3a7; background: #16352e; rotate: 45deg; box-shadow: 0 0 0 3px #0d231e; }
.marker.document i { border-radius: 50%; rotate: 0deg; width: 9px; height: 9px; border-color: #89948f; background: #263a34; }
.marker.selected i { border-color: #eea458; background: #eea458; }
.timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; }
.window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; }
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
.paper { margin: 17px; padding: 34px 43px; height: min(500px, 58vh); overflow: auto; background: #e8e5d8; box-shadow: inset 0 0 24px #9a968566; font-family: IBM Plex Mono; }
.paper.asset-paper { padding: 20px; display: flex; flex-direction: column; }.asset-paper .paper-meta { flex: 0 0 auto; margin-bottom: 14px; }.document-image { display: block; max-width: 100%; margin: auto; box-shadow: 0 2px 12px #0005; }.document-frame { width: 100%; flex: 1; min-height: 350px; border: 1px solid #81877f; background: white; }.unsupported-file { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 11px; color: #4a5651; }.unsupported-file b { font-size: 12px; }.unsupported-file span { font-size: 9px; color: #758079; }.unsupported-file a { margin-top: 9px; padding: 8px 11px; background: #244b40; color: white; text-decoration: none; font: 9px IBM Plex Mono; }
.paper-meta { display: flex; justify-content: space-between; font-size: 8px; letter-spacing: .1em; border-bottom: 2px solid #252d29; padding-bottom: 9px; margin-bottom: 28px; }.paper-meta b { color: #945b32; }
.paper p { font-size: 12px; line-height: 1.7; margin: 0; }.paper p:nth-child(4) { margin-top: 12px; }
.extracts { display: flex; flex-wrap: wrap; gap: 8px; border-top: 1px dashed #999b91; padding-top: 18px; margin-top: 28px; }
.extracts button { border: 1px solid #9b602f; background: #f0dbc0; color: #66391a; padding: 8px 10px; display: flex; align-items: center; gap: 6px; cursor: pointer; font: 600 9px IBM Plex Mono; text-transform: uppercase; }.extracts button:hover { background: #e8bd87; }.extracts button.done { border-color: #667a71; background: #d1d7ce; color: #42554d; }
.document-window > footer { height: 25px; border-top: 1px solid #737f78; display: flex; justify-content: space-between; padding: 6px 8px; font: 8px IBM Plex Mono; }
.modal-shade { position: fixed; z-index: 40; inset: 0; background: #020b09aa; display: grid; place-items: center; }
.help { width: 440px; }.help > div { padding: 30px 34px 34px; }.help h2 { font: 23px Special Elite; margin: 8px 0 22px; }.help ol { padding-left: 22px; font-size: 12px; line-height: 2; }.help p { font: 13px Special Elite; border-left: 3px solid #a56330; padding-left: 12px; }.primary { float: right; background: #163f35; color: white; border: 2px outset #608177; font: 9px IBM Plex Mono; padding: 10px 13px; cursor: pointer; }
.folder-editor { width: min(680px, 88vw); }
.folder-editor-body { padding: 24px 27px 22px; }
.folder-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
.field { display: grid; gap: 5px; margin-top: 13px; }
.field > span, .folder-members-heading b { font: 600 8px IBM Plex Mono; letter-spacing: .1em; color: #44504c; }
.field input, .field textarea, .field select, .metadata-row input { width: 100%; border: 1px solid #7d8780; background: #e8e5d8; color: #17231f; padding: 8px 9px; font: 11px IBM Plex Mono; resize: vertical; }
.folder-members-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
.folder-members-heading div { display: grid; gap: 3px; }
.folder-members-heading small { color: #8c5c31; font: 7px IBM Plex Mono; }
.folder-members-heading > span { display: flex; gap: 6px; align-items: center; color: #66716b; font: 7px IBM Plex Mono; }
.folder-members { max-height: 260px; overflow: auto; border: 1px solid #8a918b; border-top: 0; background: #d4d5cd; }
.folder-member { min-height: 56px; padding: 8px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 190px; gap: 14px; align-items: center; border-bottom: 1px solid #a1a69f; opacity: .67; }
.folder-member.included { background: #e2d7bd; opacity: 1; }
.folder-member label { min-width: 0; display: grid; grid-template-columns: 18px 18px minmax(0, 1fr); align-items: center; gap: 7px; cursor: pointer; }
.folder-member label span { min-width: 0; display: grid; gap: 3px; }
.folder-member label b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 10px IBM Plex Mono; }
.folder-member label small { color: #6c756f; font: 7px IBM Plex Mono; }
.folder-member-date { text-align: right; color: #72563a; font: 8px IBM Plex Mono; }
.folder-editor-note { margin: 12px 0; padding-left: 10px; border-left: 3px solid #9c6534; color: #555f5a; font: 10px/1.5 IBM Plex Mono; }
.folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.folder-editor-actions button { border: 1px outset #8c948e; padding: 9px 12px; cursor: pointer; font: 9px IBM Plex Mono; }
.folder-editor-actions .primary { float: none; }
.file-editor { width: min(620px, 88vw); }
.file-editor-body { padding: 24px 27px 22px; }
.file-editor-body > small { color: #805027; font: 600 8px IBM Plex Mono; letter-spacing: .16em; }
.file-editor-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
.field > span { display: flex; align-items: center; gap: 6px; }
.metadata-heading { margin-top: 20px; padding-bottom: 8px; border-bottom: 2px solid #59625d; display: flex; justify-content: space-between; align-items: end; }
.metadata-heading > div { display: grid; gap: 3px; }
.metadata-heading b { color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }
.metadata-heading small { color: #8c5c31; font: 7px IBM Plex Mono; }
.metadata-heading button { display: flex; align-items: center; gap: 5px; border: 1px outset #969d97; padding: 6px 8px; cursor: pointer; font: 8px IBM Plex Mono; }
.metadata-rows { max-height: 220px; overflow: auto; background: #d4d5cd; border: 1px solid #929991; border-top: 0; }
.metadata-rows > p { margin: 20px; text-align: center; color: #707a74; font: 8px IBM Plex Mono; }
.metadata-row { display: grid; grid-template-columns: 150px 1fr 28px; gap: 7px; padding: 7px; border-bottom: 1px solid #a1a69f; }
.metadata-row input { padding: 6px; font-size: 9px; }
.metadata-row button { display: grid; place-items: center; border: 0; background: #b8bcb5; color: #6c3a2c; cursor: pointer; }
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
+72
View File
@@ -0,0 +1,72 @@
export type EvidenceType = 'folder' | 'evidence' | 'note' | 'event'
export type SourceFileType = 'image' | 'pdf' | 'web_capture' | 'email' | 'article' | 'filing' | 'price_list' | 'text' | 'file'
export interface DocumentRegion {
id: string
label: string
excerpt: string
date?: string
}
export interface CaseDocument {
id: string
title: string
kind: string
date: string
publishedAt?: string
body: string[]
regions: DocumentRegion[]
assetId?: string
fileName?: string
mimeType?: string
fileSize?: number
fileType: SourceFileType
metadata: Record<string, string>
}
export interface Evidence {
id: string
type: EvidenceType
title: string
content: string
sourceDocumentId?: string
sourceRegionId?: string
eventDate?: string
x: number
y: number
width: number
config?: Record<string, unknown>
/** Compatibility projection for clients predating the generic relation graph. */
containedDocumentIds?: string[]
}
export interface WidgetRelation {
id: string
fromWidgetId: string
toWidgetId: string
type: string
sortOrder?: number
config?: Record<string, unknown>
}
export interface Connection {
id: string
fromEvidenceId: string
toEvidenceId: string
}
export interface Viewport { x: number; y: number; zoom: number }
export interface CaseState {
id: string
title: string
subtitle: string
documents: CaseDocument[]
evidence: Evidence[]
relations: WidgetRelation[]
connections: Connection[]
viewport: Viewport
updatedAt?: string
levelStatus?: string
editingAllowed?: boolean
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts", "server/**/*.ts", "src/types.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: { '/api': 'http://127.0.0.1:8787' },
},
})