Initial commit
This commit is contained in:
+18
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
Generated
+3990
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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 EDIT EVIDENCE 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> }
|
||||
@@ -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
@@ -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; } }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user