45 Commits

Author SHA1 Message Date
a518c2e2eb chore: update service worker cache version to 1.6.0
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m18s
CI / build-backend (push) Successful in 32s
Build and Publish Docker Image / build (push) Successful in 9m52s
Build and Release / build (push) Successful in 20m37s
2026-01-01 01:46:34 -06:00
2553f3ab8c docs: update changelog for 1.6.0
Some checks failed
CI / scan-backend (push) Successful in 12s
CI / build-backend (push) Has been cancelled
CI / build-frontend (push) Has been cancelled
OSV-Scanner Scheduled Scan / scan-scheduled (push) Has been cancelled
2025-12-31 19:00:24 -06:00
5902896b9c chore: update package version to 1.6.0 2025-12-31 18:59:34 -06:00
e9471e9110 refactor: simplify +page.svelte by removing versioning logic and footer, enhancing layout structure 2025-12-31 18:59:29 -06:00
aea77cb4b6 fix: enhance service worker registration to ensure it only occurs over secure protocols (http/https) 2025-12-31 18:59:24 -06:00
59536df2ff feat: add new readonly constants for IndexedDB and component properties in ESLint configuration 2025-12-31 18:59:19 -06:00
44b1a1472f feat: add new constants for image storage and grid size configuration 2025-12-31 18:59:07 -06:00
5ac482f6dc feat: implement IndexedDB utility functions for storing and retrieving application data, including settings, images, and custom types 2025-12-31 18:59:02 -06:00
1da1e61cc5 feat: add theme configuration and utility functions for managing application themes 2025-12-31 18:58:57 -06:00
8feb48b044 feat: implement ToastManager for managing toast notifications with various types and automatic dismissal 2025-12-31 18:58:52 -06:00
2e8b01483e feat: introduce types for graph structure including nodes, links, and application themes 2025-12-31 18:58:47 -06:00
232b63ecff feat: enhance Toolbar component with link mode functionality and improved desktop/mobile toggle behavior 2025-12-31 18:58:42 -06:00
8c16350e08 feat: add ToastContainer component for displaying toast notifications with customizable styles and transitions 2025-12-31 18:58:36 -06:00
ad568ecc22 feat: add SettingsModal component for customizable settings management with grid and theme options 2025-12-31 18:58:31 -06:00
b99afb374f feat: add NodeInspector component for detailed node editing with customizable inputs and image handling 2025-12-31 18:58:26 -06:00
5911e3156f feat: add LinkEditModal component for editing relationship links with customizable inputs and mobile support 2025-12-31 18:58:21 -06:00
612d86127f refactor: streamline IdentityGraph component by removing unused IndexedDB functions and enhancing image handling with new utility functions 2025-12-31 18:58:15 -06:00
3dff39f062 feat: add FloatingWindow component for draggable, customizable floating windows with persistent positioning 2025-12-31 18:58:10 -06:00
8b3df40c9a feat: add CustomTypesModal component for managing custom types with image upload and editing functionality 2025-12-31 18:58:06 -06:00
0b11894b79 feat: add AddEntityModal component for creating new entities with image upload and notes support 2025-12-31 18:58:01 -06:00
5991451116 chore: update Wails build commands in Taskfile to include webkit2_41 tags for desktop applications
All checks were successful
CI / scan-backend (push) Successful in 11s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m25s
CI / build-frontend (push) Successful in 9m39s
CI / build-backend (push) Successful in 9m26s
2025-12-31 18:57:26 -06:00
2da685dd20 feat: add Toolbar component with mobile support and various action buttons 2025-12-31 16:35:28 -06:00
c1ed5ea92f feat: add tag handling for repository links
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 29s
CI / build-frontend (push) Successful in 55s
CI / scan-backend (push) Successful in 9m22s
CI / build-backend (push) Successful in 20s
2025-12-31 15:51:30 -06:00
3256ec63a2 chore: add Linux desktop build dependencies installation step to CI workflow
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 20s
CI / build-frontend (push) Successful in 51s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 20s
2025-12-31 15:49:38 -06:00
dd0ef88856 chore: add Wails installation step
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 27s
CI / build-frontend (push) Successful in 1m18s
CI / scan-backend (push) Successful in 9m20s
CI / build-backend (push) Successful in 21s
2025-12-31 15:45:08 -06:00
81869eb6d7 docs: update CHANGELOG for version 1.5.3, detailing CI/CD updates and UI/UX improvements
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 46s
Build and Release / build (push) Has been cancelled
CI / scan-backend (push) Successful in 9m19s
CI / build-backend (push) Successful in 24s
Build and Publish Docker Image / build (push) Successful in 10m37s
2025-12-31 15:37:53 -06:00
ab39ddca15 fix: pass VITE_APP_VERSION as an argument during frontend build in Dockerfile 2025-12-31 15:37:44 -06:00
579dc721bc chore: update version to 1.5.3 in package.json and service worker 2025-12-31 15:37:38 -06:00
4c83b97d60 feat: implement version determination logic for frontend and Docker workflows, enhancing VITE_APP_VERSION usage 2025-12-31 15:36:57 -06:00
a5e7784048 feat: enhance VITE_APP_VERSION handling in Taskfile for frontend, backend, and Docker builds 2025-12-31 15:36:50 -06:00
298b62fdc4 chore: simplify VITE_APP_VERSION handling in package.json scripts 2025-12-31 15:36:40 -06:00
936a7e51c3 feat: improve footer to display version information with conditional link to repository 2025-12-31 15:36:35 -06:00
e6cf656556 chore: update dependencies in pnpm-lock.yaml to latest versions
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 22s
2025-12-31 15:18:23 -06:00
74e0bd403e feat: add Trivy download and SBOM generation to build workflow 2025-12-31 15:16:46 -06:00
c14dc18a65 chore: remove SBOM generation workflow and associated output files 2025-12-31 15:16:38 -06:00
e9eb07ef52 Merge pull request 'Update https://git.quad4.io/actions/checkout action to v6' (#18) from renovate/https-git.quad4.io-actions-checkout-6.x into master
All checks were successful
CI / scan-backend (push) Successful in 12s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m23s
CI / build-frontend (push) Successful in 9m38s
CI / build-backend (push) Successful in 9m25s
Reviewed-on: #18
2025-12-31 21:06:51 +00:00
5ccc6846a7 feat: add settings modal and grid customization options to IdentityGraph component
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 22s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m18s
CI / build-backend (push) Successful in 21s
2025-12-31 08:58:22 -06:00
7e3c8e2b79 Auto-update SBOM [skip ci] 2025-12-31 14:46:32 +00:00
6563d75b48 chore: update CHANGELOG for version 1.5.2 with mobile enhancements and UI/UX improvements
Some checks failed
Generate SBOM / generate-sbom (push) Successful in 50s
Build and Release / build (push) Failing after 1m29s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 25s
CI / build-frontend (push) Successful in 1m6s
CI / scan-backend (push) Successful in 9m18s
CI / build-backend (push) Successful in 21s
Build and Publish Docker Image / build (push) Successful in 12m50s
2025-12-31 08:45:43 -06:00
0973b6f378 chore: bump version to 1.5.2 and update CACHE_VERSION in service worker
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 22s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 20s
2025-12-31 08:44:44 -06:00
51fd93c9a0 fix: update footer text to include 'Linking Tool - Created by' 2025-12-31 08:44:35 -06:00
97b023f1f4 feat: enhance IdentityGraph component with touch gesture support and mobile toolbar improvements 2025-12-31 08:44:30 -06:00
10bfb5d9e4 format: workflows
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m20s
CI / build-backend (push) Successful in 21s
2025-12-31 08:20:18 -06:00
b09e7f05fd refactor: header and footer structure in +page.svelte, removing the LinkIcon and simplifying layout 2025-12-31 08:20:17 -06:00
Renovate Bot
232d62e5f9 Update https://git.quad4.io/actions/checkout action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 17s
2025-12-31 00:01:37 +00:00
31 changed files with 5402 additions and 23264 deletions

View File

@@ -18,7 +18,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@a5b3063b1edaa6ba4911c8a1b1d5e1656fba3ea5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
@@ -27,8 +27,11 @@ jobs:
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
@@ -50,12 +53,22 @@ jobs:
- name: Build frontend
run: task build:frontend
env:
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@f50900cd786a0c549eed5a472b4f2c371ae8589f # v5
with:
go-version: '1.25.5'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@ac867f658730618b79b4fbea194ccbbbddac28ee # v2.11.0
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build server binaries
run: |
task build:backend
@@ -68,6 +81,16 @@ jobs:
- name: Build desktop Windows
run: task desktop-windows
- name: Download Trivy
run: |
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
- name: Generate SBOM (CycloneDX)
run: |
mkdir -p release-assets
trivy fs --format cyclonedx --include-dev-deps --output release-assets/sbom.cyclonedx.json .
- name: Prepare release assets
run: |
mkdir -p release-assets
@@ -80,14 +103,6 @@ jobs:
cp desktop/build/bin/linking-tool.exe release-assets/linking-tool-desktop-windows-amd64.exe
fi
- name: Download SBOM
run: |
git fetch origin master:master || true
git checkout master -- sbom/ || git checkout ${{ github.sha }} -- sbom/ || true
if [ -d sbom ]; then
cp sbom/*.json release-assets/ || true
fi
- name: Create Release
uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74 # v1
with:
@@ -97,7 +112,7 @@ jobs:
tag: ${{ steps.version.outputs.version }}
body: |
Release ${{ steps.version.outputs.version }}
## Assets
- Server binaries (Linux AMD64, Windows AMD64)
- Desktop applications (Linux AMD64, Windows AMD64)
@@ -105,4 +120,3 @@ jobs:
files: release-assets/*
draft: false
prerelease: false

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -29,8 +29,16 @@ jobs:
run: task lint
- name: Frontend checks
run: task check
- name: Determine version
id: version
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Build frontend
run: task build:frontend
env:
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
- name: Upload frontend assets
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
@@ -41,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
@@ -54,7 +62,7 @@ jobs:
needs: [build-frontend, scan-backend]
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download frontend assets
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:

View File

@@ -22,7 +22,19 @@ jobs:
steps:
- name: Checkout repository
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
@@ -62,3 +74,5 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VITE_APP_VERSION=${{ steps.version.outputs.version }}

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5

View File

@@ -1,63 +0,0 @@
name: Generate SBOM
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
generate-sbom:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 22
cache: pnpm
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.5'
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: task install:ci
- name: Download Trivy
run: |
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
- name: Generate SBOM
run: |
mkdir -p sbom
trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
- name: Commit and Push Changes
run: |
git config --global user.name "Gitea Action"
git config --global user.email "actions@noreply.quad4.io"
git remote set-url origin https://${{ secrets.GITEA_TOKEN }}@git.quad4.io/${{ github.repository }}.git
git fetch origin master
git checkout master
git add sbom/
git diff --quiet && git diff --staged --quiet || (git commit -m "Auto-update SBOM [skip ci]" && git push origin master)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -1,5 +1,77 @@
# Changelog
## 1.6.0 - 2026-01-01
Happy New Year!
### Features
- **Custom Entity Types**:
- Added ability to create, edit, and delete custom entity types via a new "Custom Types" modal.
- Custom types support user-defined names, colors, and optional custom icon images.
- Custom type icons are stored persistently in IndexedDB.
- **Node Customization**:
- Added **Node Color Override**: Individual nodes can now have their own custom color, overriding the default type color.
- Added a color picker to the Node Inspector for easy color customization.
- **Image Storage**:
- **IndexedDB Image Storage**: All node images and custom type icons are now stored as binary Blobs in IndexedDB increasing performance and reducing lag of selection tool.
- **Theming System**:
- Added support for 8 themes: Dark, Light, OLED Black, Midnight Blue, Sepia, Slate Gray, Cyberpunk, and Paper White.
- Theme selection available in Settings modal with visual preview.
- Theme preference is persisted across sessions.
- **Linking Features**:
- Added **Linking Mode** toggle in toolbar for easier link creation.
- Added **Auto-linking**: When enabled, nodes automatically link when dragged near each other (with 800ms hover delay).
- Auto-linking can be toggled in Settings.
- **Toast Notifications**:
- Added toast notification system for user feedback on actions (success, error, info, warning).
- Replaces alert() calls with non-intrusive toast messages.
- **Codebase Refactor**:
- **Component Breakdown**: Significantly refactored `IdentityGraph.svelte` by extracting logic into modular components: `Toolbar`, `NodeInspector`, `SettingsModal`, `AddEntityModal`, `LinkEditModal`, `CustomTypesModal`, `FloatingWindow`, and `ToastContainer`.
- **UI/UX**:
- Updated graph rendering to support custom type icons and node-specific color overrides.
- Updated search to include custom type names.
- Improved notes background rendering on the graph to dynamically match text width.
- Added desktop toolbar collapse/expand functionality.
- Moved footer into IdentityGraph component for better layout control.
### Fixes
- **Wails Desktop App Compatibility**:
- Fixed Service Worker registration error in Wails desktop app by checking for HTTP/HTTPS protocol before registration.
- Fixed IndexedDB object store errors in Wails by detecting and automatically recreating database when stores are missing.
## 1.5.3 - 2025-12-31
### CI/CD Updates
- Moved SBOM generation from `sbom.yml` workflow to `build.yml` workflow as release assets instead of auto-committing to source code
- Removed SPDX format, now only generating CycloneDX SBOM format (more popular and security-focused)
### UI/UX
- Updated version display logic: tag builds show tag version (e.g., `v1.5.2`), branch builds show commit SHA (e.g., `abc1234`), local dev shows `dev`
## 1.5.2 - 2025-12-31
### Features
- **Mobile Enhancements**:
- Added pinch-to-zoom support for graph navigation on touch devices.
- Redesigned mobile toolbar into a single row with a collapsible "More" menu.
- Added a responsive expand/collapse toggle for the mobile toolbar using chevron icons.
- Moved the "Add Node" action to a floating sticky button in the bottom-right on mobile for better accessibility.
- Optimized toolbar width and spacing for mobile screens.
- **UI/UX**:
- Removed top navbar/header to maximize workspace area.
- Simplified layout with a minimal footer.
- Updated footer branding to include "Linking Tool".
### Fixes
- Improved click-outside handling for mobile menus.
- Fixed various mobile layout and justification constraints.
## 1.5.1 - 2025-12-29
### Features
@@ -27,7 +99,7 @@
- Mass selection improvements (moving and linking multiple nodes at once).
- Codebase refactor to use Svelte 5 Runes.
- Mobile improvements
- Added SBOM generation, see `/sbom/` for the generated SBOMs.
- Added SBOM generation as release assets
### Dependency Updates
@@ -39,7 +111,6 @@
- `vite`: ^7.2.6 -> ^7.3.0
- Added `eslint-plugin-security`: ^3.0.1
### Major Codebase Changes
- Moved from `npm` to `pnpm`

View File

@@ -19,7 +19,19 @@ tasks:
build:frontend:
desc: Build frontend only
cmds:
- pnpm run build
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
build:backend:
desc: Build backend binary only
@@ -31,7 +43,19 @@ tasks:
desc: Build the single binary web server
cmds:
- pnpm install
- pnpm run build
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
- mkdir -p {{.BUILD_DIR}}
- CGO_ENABLED=0 go build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} main.go
@@ -93,9 +117,14 @@ tasks:
- GOOS=freebsd GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-amd64 main.go
docker-build:
desc: Build Docker image
desc: Build Docker image (VITE_APP_VERSION env var will be passed as build arg if set)
cmds:
- docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
- |
if [ -n "$VITE_APP_VERSION" ]; then
docker build --build-arg VITE_APP_VERSION="$VITE_APP_VERSION" -f docker/Dockerfile -t {{.BINARY_NAME}} .
else
docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
fi
docker-run:
desc: Run Docker container
@@ -133,7 +162,7 @@ tasks:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s
- cd desktop && wails build -s -tags webkit2_41
desktop-linux:
desc: Build desktop application for Linux
@@ -142,7 +171,7 @@ tasks:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -platform linux/amd64
- cd desktop && wails build -s -platform linux/amd64 -tags webkit2_41
desktop-windows:
desc: Build desktop application for Windows

View File

@@ -1,5 +1,6 @@
# Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
ARG VITE_APP_VERSION
WORKDIR /app
USER root
RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
@@ -7,7 +8,7 @@ USER node
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node . .
RUN pnpm run build
RUN VITE_APP_VERSION=${VITE_APP_VERSION} pnpm run build
# Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder

View File

@@ -64,6 +64,15 @@ export default [
XMLSerializer: 'readonly',
Image: 'readonly',
FileReader: 'readonly',
IDBRequest: 'readonly',
IDBCursorWithValue: 'readonly',
$state: 'readonly',
$derived: 'readonly',
$effect: 'readonly',
$props: 'readonly',
$bindable: 'readonly',
$inspect: 'readonly',
$host: 'readonly',
},
},
plugins: {

View File

@@ -1,6 +1,6 @@
{
"name": "@quad4/linking-tool",
"version": "1.5.1",
"version": "1.6.0",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
@@ -27,9 +27,9 @@
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"dev": "VITE_APP_VERSION=dev vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"build": "VITE_APP_VERSION=${VITE_APP_VERSION:-$(node -p \"require('./package.json').version\")} vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",

1790
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: iconMap and typeColors access uses keys from controlled constant array (nodeTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import {
nodeTypes,
iconMap,
typeColors,
ALLOWED_IMAGE_TYPES,
MAX_IMAGE_BYTES,
} from '$lib/constants';
import type { NodeType } from '$lib/constants';
import type { CustomType } from '$lib/types';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
onClose: () => void;
onAdd: (data: { label: string; type: NodeType | string; image: string; notes: string }) => void;
isLight: boolean;
theme: string;
surfaceClass: string;
inputClass: string;
modalBackdropClass: string;
customTypes: CustomType[];
imageObjects: Map<string, string>;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
onClose,
onAdd,
isLight,
theme,
surfaceClass,
inputClass,
modalBackdropClass,
customTypes = [],
imageObjects,
zIndex = 50,
onFocus,
}: Props = $props();
let label = $state('');
let type = $state<NodeType | string>('person');
let image = $state('');
let notes = $state('');
let imageError = $state('');
let inputRef = $state<HTMLInputElement | null>(null);
let fileInputRef = $state<HTMLInputElement | null>(null);
const mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
function handleAdd() {
if (!label.trim()) return;
onAdd({ label: label.trim(), type, image: image.trim(), notes: notes.trim() });
label = '';
type = 'person';
image = '';
notes = '';
imageError = '';
onClose();
}
function triggerImageUpload() {
fileInputRef?.click();
}
function handleImageFileSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
imageError = 'Only PNG, JPG, or WebP images are allowed.';
input.value = '';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
imageError = 'Image must be under 2MB.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
image = reader.result as string;
imageError = '';
input.value = '';
};
reader.onerror = () => {
imageError = 'Failed to read image.';
input.value = '';
};
reader.readAsDataURL(file);
}
function clearImage() {
image = '';
imageError = '';
}
$effect(() => {
if (open && inputRef) {
setTimeout(() => {
if (inputRef) {
inputRef.focus();
}
}, 10);
}
});
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Add Entity"
>
<div class={`w-full max-w-md rounded-xl border p-6 shadow-2xl ${surfaceClass}`}>
{@render addEntityContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="add-entity"
title="Add Entity"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
{zIndex}
{onFocus}
>
{@render addEntityContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet addEntityContent()}
<h4 class={`text-lg font-semibold mb-4 ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Add Entity
</h4>
<div class="space-y-4">
<div>
<label for="nodeLabel" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Label / Name</label
>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={inputRef}
id="nodeLabel"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="e.g. John Doe"
bind:value={label}
onkeydown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div>
<label for="nodeType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Type</label>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as nodeType}
{@const IconComponent = iconMap[nodeType]}
<button
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
(type === nodeType
? 'border-rose-500 bg-rose-500/10'
: isLight
? 'border-amber-300 bg-amber-50 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800/50 hover:border-neutral-700')}
onclick={() => (type = nodeType)}
title={nodeType}
>
<IconComponent size={20} color={typeColors[nodeType]} />
<span
class={`text-[10px] capitalize truncate w-full text-center ${isLight ? 'text-neutral-700' : 'text-neutral-400'}`}
>
{nodeType}
</span>
</button>
{/each}
{#each customTypes as customType}
<button
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
(type === customType.id
? 'border-rose-500 bg-rose-500/10'
: isLight
? 'border-amber-300 bg-amber-50 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800/50 hover:border-neutral-700')}
onclick={() => (type = customType.id)}
title={customType.name}
>
<div
class="w-5 h-5 rounded-full flex items-center justify-center overflow-hidden"
style={`background-color: ${customType.color}22; border: 1.5px solid ${customType.color}`}
>
{#if customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-1.5 h-1.5 rounded-full"
style={`background-color: ${customType.color}`}
></div>
{/if}
</div>
<span
class={`text-[10px] capitalize truncate w-full text-center ${isLight ? 'text-neutral-700' : 'text-neutral-400'}`}
>
{customType.name}
</span>
</button>
{/each}
</div>
</div>
<div>
<label for="nodeImage" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Custom Image URL</label
>
<input
id="nodeImage"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="https://..."
bind:value={image}
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors hover:brightness-110 ${inputClass}`}
type="button"
onclick={triggerImageUpload}
>
Upload Image
</button>
{#if image}
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors hover:brightness-110 ${inputClass}`}
type="button"
onclick={clearImage}
>
Remove
</button>
{/if}
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
bind:this={fileInputRef}
onchange={handleImageFileSelected}
/>
<p class={`mt-1 text-[11px] ${mutedTextClass}`}>
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
</p>
{#if imageError}
<p class={`mt-1 text-[11px] ${isLight ? 'text-rose-600' : 'text-rose-300'}`}>
{imageError}
</p>
{/if}
</div>
<div>
<label for="nodeNotes" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Notes</label
>
<textarea
id="nodeNotes"
rows="3"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 resize-none ${inputClass}`}
placeholder="Short intel notes, context, identifiers..."
bind:value={notes}
></textarea>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class={`rounded-lg px-3 py-2 text-sm transition-colors hover:brightness-110 ${inputClass}`}
onclick={onClose}>Cancel</button
>
<button
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={handleAdd}>Add Entity</button
>
</div>
</div>
{/snippet}

View File

@@ -0,0 +1,468 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
import { untrack } from 'svelte';
import { X, Plus, Trash2, Upload, ImageIcon, Pencil } from 'lucide-svelte';
import type { CustomType } from '$lib/types';
import {
saveCustomType,
deleteCustomType,
storeImageBlob,
loadImageBlob,
deleteImageBlob,
} from '$lib/db';
import { ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES } from '$lib/constants';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
isLight: boolean;
theme: string;
dividerClass: string;
inputClass: string;
modalBackdropClass: string;
surfaceClass: string;
customTypes: CustomType[];
onClose: () => void;
onUpdate: () => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
isLight,
theme,
dividerClass,
inputClass,
modalBackdropClass,
surfaceClass,
customTypes,
onClose,
onUpdate,
zIndex = 60,
onFocus,
}: Props = $props();
let newTypeName = $state('');
let newTypeColor = $state('#ef4444');
let newTypeIconBlob = $state<Blob | null>(null);
let newTypeIconUrl = $state<string | null>(null);
let editingType = $state<CustomType | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let error = $state('');
const mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
async function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
error = 'Invalid file type. Please use PNG, JPEG or WEBP.';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
error = 'File too large. Max size is 2MB.';
return;
}
if (editingType) {
const iconId = 'icon-' + editingType.id + '-' + Math.random().toString(36).slice(2, 7);
if (editingType.iconId) {
await deleteImageBlob(editingType.iconId);
}
await storeImageBlob(iconId, file);
editingType.iconId = iconId;
if (iconUrls[iconId]) URL.revokeObjectURL(iconUrls[iconId]);
iconUrls[iconId] = URL.createObjectURL(file);
} else {
newTypeIconBlob = file;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = URL.createObjectURL(file);
}
error = '';
}
async function saveType() {
if (editingType) {
if (!editingType.name.trim()) {
error = 'Type name is required.';
return;
}
await saveCustomType($state.snapshot(editingType));
editingType = null;
} else {
if (!newTypeName.trim()) {
error = 'Type name is required.';
return;
}
const id = 'ct-' + Math.random().toString(36).slice(2, 11);
let iconId: string | undefined;
if (newTypeIconBlob) {
iconId = 'icon-' + id;
await storeImageBlob(iconId, newTypeIconBlob);
}
const newType: CustomType = {
id,
name: newTypeName.trim(),
color: newTypeColor,
iconId,
};
await saveCustomType(newType);
newTypeName = '';
newTypeColor = '#ef4444';
newTypeIconBlob = null;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = null;
}
error = '';
onUpdate();
}
async function removeCustomType(type: CustomType) {
if (type.iconId) {
await deleteImageBlob(type.iconId);
}
await deleteCustomType(type.id);
onUpdate();
}
let iconUrls = $state<Record<string, string>>({});
$effect(() => {
if (open) {
customTypes.forEach(async (type) => {
const hasIcon = untrack(() => type.iconId && iconUrls[type.iconId]);
if (type.iconId && !hasIcon) {
const blob = await loadImageBlob(type.iconId);
if (blob) {
const url = URL.createObjectURL(blob);
iconUrls[type.iconId] = url;
}
}
});
}
return () => {
Object.values(untrack(() => iconUrls)).forEach(URL.revokeObjectURL);
untrack(() => {
iconUrls = {};
});
};
});
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-[60] flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => e.target === e.currentTarget && onClose()}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
aria-modal="true"
>
<div
class={`w-full max-w-2xl rounded-xl border p-6 shadow-2xl overflow-hidden flex flex-col max-h-[90vh] ${surfaceClass}`}
>
{@render customTypesContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="custom-types"
title="Manage Custom Types"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
minWidth={500}
{zIndex}
{onFocus}
>
{@render customTypesContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet customTypesContent()}
{#if isMobile}
<div class="flex items-center justify-between mb-6">
<h4 class={`text-lg font-semibold ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Manage Custom Types
</h4>
<button
class={`p-1 rounded transition-colors ${
isLight
? 'text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
>
<X size={20} />
</button>
</div>
{/if}
<div class="flex-1 overflow-y-auto space-y-6 pr-2">
<!-- New/Edit Type Form -->
<div
class={`p-4 rounded-lg border transition-colors ${isLight ? 'bg-amber-50 border-amber-200' : 'bg-neutral-800/30 border-neutral-700'}`}
>
<h5 class={`text-sm font-semibold mb-4 uppercase tracking-wider ${mutedTextClass}`}>
{editingType ? 'Edit Type' : 'Create New Type'}
</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label for="custom-type-name" class={`text-xs font-medium ${mutedTextClass}`}
>Type Name</label
>
{#if editingType}
<input
id="custom-type-name"
type="text"
bind:value={editingType.name}
placeholder="e.g. Asset, Evidence..."
class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 transition-colors ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{:else}
<input
id="custom-type-name"
type="text"
bind:value={newTypeName}
placeholder="e.g. Asset, Evidence..."
class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 transition-colors ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{/if}
</div>
<div class="space-y-2">
<label
for="custom-type-color"
class={`text-xs font-medium ${isLight ? 'text-neutral-600' : 'text-neutral-400'}`}
>Color</label
>
<div class="flex gap-2">
{#if editingType}
<input
id="custom-type-color"
type="color"
bind:value={editingType.color}
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
/>
<input
type="text"
bind:value={editingType.color}
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{:else}
<input
id="custom-type-color"
type="color"
bind:value={newTypeColor}
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
/>
<input
type="text"
bind:value={newTypeColor}
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{/if}
</div>
</div>
<div class="md:col-span-2 space-y-2">
<label
for="custom-type-icon"
class={`text-xs font-medium ${isLight ? 'text-neutral-600' : 'text-neutral-400'}`}
>Custom Icon (Optional)</label
>
<div class="flex items-center gap-4">
<button
onclick={() => fileInput?.click()}
class={`flex items-center gap-2 px-4 py-2 rounded-lg border text-sm transition-colors hover:brightness-110 ${inputClass}`}
>
<Upload size={16} />
{editingType && editingType.iconId ? 'Change Icon' : 'Upload Icon'}
</button>
<input
id="custom-type-icon"
type="file"
bind:this={fileInput}
onchange={handleFileChange}
accept="image/*"
class="hidden"
/>
{#if editingType && editingType.iconId && iconUrls[editingType.iconId]}
<div class="relative w-10 h-10 rounded-lg overflow-hidden border border-neutral-700">
<img
src={iconUrls[editingType.iconId]}
alt="Preview"
class="w-full h-full object-cover"
/>
<button
onclick={async () => {
if (editingType?.iconId) {
await deleteImageBlob(editingType.iconId);
if (iconUrls[editingType.iconId])
URL.revokeObjectURL(iconUrls[editingType.iconId]);
delete iconUrls[editingType.iconId];
editingType.iconId = undefined;
}
}}
class="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 flex items-center justify-center transition-opacity"
>
<X size={14} class="text-white" />
</button>
</div>
{:else if newTypeIconUrl}
<div class="relative w-10 h-10 rounded-lg overflow-hidden border border-neutral-700">
<img src={newTypeIconUrl} alt="Preview" class="w-full h-full object-cover" />
<button
onclick={() => {
newTypeIconBlob = null;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = null;
}}
class="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 flex items-center justify-center transition-opacity"
>
<X size={14} class="text-white" />
</button>
</div>
{:else}
<div
class={`w-10 h-10 rounded-lg border-2 border-dashed flex items-center justify-center ${isLight ? 'border-amber-200' : 'border-neutral-700 text-neutral-600'}`}
>
<ImageIcon size={20} />
</div>
{/if}
</div>
</div>
</div>
{#if error}
<p class="text-xs text-rose-500 mt-2">{error}</p>
{/if}
<div class="flex gap-2 mt-4">
{#if editingType}
<button
onclick={() => {
editingType = null;
error = '';
}}
class={`flex-1 flex items-center justify-center gap-2 font-medium py-2 rounded-lg transition-colors border hover:brightness-110 ${inputClass}`}
>
Cancel
</button>
{/if}
<button
onclick={saveType}
class="flex-[2] flex items-center justify-center gap-2 bg-rose-600 hover:bg-rose-500 text-white font-medium py-2 rounded-lg transition-colors shadow-lg shadow-rose-900/20"
>
<Plus size={18} />
{editingType ? 'Save Changes' : 'Add Custom Type'}
</button>
</div>
</div>
<!-- Existing Types List -->
<div class="space-y-3">
<h5 class={`text-sm font-semibold uppercase tracking-wider ${mutedTextClass}`}>
Existing Custom Types ({customTypes.length})
</h5>
{#if customTypes.length === 0}
<p class={`text-sm italic ${isLight ? 'text-neutral-500' : 'text-neutral-500'}`}>
No custom types created yet.
</p>
{:else}
<div class="grid grid-cols-1 gap-2">
{#each customTypes as type}
<div
class={`flex items-center justify-between p-3 rounded-lg border ${
isLight ? 'bg-white border-amber-200' : 'bg-neutral-900 border-neutral-800'
} ${editingType?.id === type.id ? 'border-rose-500 ring-1 ring-rose-500' : ''}`}
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center border-2"
style={`background-color: ${type.color}22; border-color: ${type.color}`}
>
{#if type.iconId && iconUrls[type.iconId]}
<img
src={iconUrls[type.iconId]}
alt={type.name}
class="w-5 h-5 object-cover rounded"
/>
{:else}
<div
class="w-2 h-2 rounded-full"
style={`background-color: ${type.color}`}
></div>
{/if}
</div>
<span class={`font-medium ${isLight ? 'text-neutral-800' : 'text-neutral-200'}`}>
{type.name}
</span>
</div>
<div class="flex items-center gap-1">
<button
onclick={() => {
editingType = { ...type };
error = '';
}}
class="p-2 text-neutral-500 hover:text-rose-500 transition-colors"
title="Edit Type"
>
<Pencil size={18} />
</button>
<button
onclick={() => removeCustomType(type)}
class="p-2 text-neutral-500 hover:text-rose-500 transition-colors"
title="Delete Type"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div
class={`flex justify-end mt-6 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}
>
<button
class={`px-6 py-2 rounded-lg font-medium transition-colors hover:brightness-110 ${inputClass}`}
onclick={onClose}
>
Done
</button>
</div>
{/snippet}

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { X, GripHorizontal } from 'lucide-svelte';
import { onMount, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import { saveSetting, loadSetting } from '$lib/db';
interface Props {
id: string;
title: string;
open: boolean;
isLight: boolean;
theme: string;
surfaceClass: string;
onClose: () => void;
onFocus?: () => void;
children: Snippet;
defaultPosition?: { x: number; y: number };
minWidth?: number;
zIndex?: number;
}
let {
id,
title,
open,
isLight,
theme,
surfaceClass,
onClose,
onFocus,
children,
defaultPosition = { x: 100, y: 100 },
minWidth = 320,
zIndex = 50,
}: Props = $props();
let pos = $state(untrack(() => ({ x: defaultPosition.x, y: defaultPosition.y })));
let isReady = $state(false);
let isDragging = $state(false);
let dragOffset = { x: 0, y: 0 };
let windowElement = $state<HTMLDivElement | null>(null);
onMount(async () => {
const savedPos = await loadSetting(`window_pos_${id}`);
if (savedPos && typeof savedPos === 'string') {
try {
const parsed = JSON.parse(savedPos);
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
// Ensure it's within bounds
pos.x = Math.max(0, Math.min(parsed.x, window.innerWidth - 100));
pos.y = Math.max(0, Math.min(parsed.y, window.innerHeight - 100));
}
} catch (e) {
console.error(`Failed to parse saved position for ${id}`, e);
}
}
isReady = true;
});
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
isDragging = true;
dragOffset = {
x: e.clientX - pos.x,
y: e.clientY - pos.y,
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
let newX = e.clientX - dragOffset.x;
let newY = e.clientY - dragOffset.y;
// Keep on screen
if (windowElement) {
const rect = windowElement.getBoundingClientRect();
newX = Math.max(0, Math.min(newX, window.innerWidth - rect.width));
newY = Math.max(0, Math.min(newY, window.innerHeight - rect.height));
}
pos.x = newX;
pos.y = newY;
}
async function handleMouseUp() {
isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
await saveSetting(`window_pos_${id}`, JSON.stringify(pos));
}
// Function to reset position (called via event or prop if needed)
export async function resetPosition() {
pos.x = defaultPosition.x;
pos.y = defaultPosition.y;
await saveSetting(`window_pos_${id}`, JSON.stringify(pos));
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={windowElement}
class={`fixed rounded-xl border shadow-2xl overflow-hidden flex flex-col transition-opacity duration-150 ${surfaceClass}`}
style="left: {pos.x}px; top: {pos.y}px; min-width: {minWidth}px; z-index: {zIndex}; max-height: 90vh; opacity: {isReady
? 1
: 0}; pointer-events: {isReady ? 'auto' : 'none'};"
onmousedown={() => onFocus?.()}
>
<!-- Header / Drag Handle -->
<div
class={`flex items-center justify-between p-3 cursor-move border-b select-none ${
theme === 'cyberpunk'
? 'bg-[#1a1a2e] border-cyan-500/30'
: theme === 'oled'
? 'bg-black border-neutral-800'
: isLight
? 'bg-amber-50/50 border-amber-200'
: 'bg-neutral-900/50 border-neutral-800'
}`}
onmousedown={handleMouseDown}
role="presentation"
>
<div class="flex items-center gap-2">
<GripHorizontal size={14} class="text-neutral-500" />
<h4 class={`text-sm font-semibold ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
{title}
</h4>
</div>
<button
class={`p-1 rounded transition-colors ${
theme === 'cyberpunk'
? 'text-cyan-400 hover:text-fuchsia-400 hover:bg-fuchsia-500/10'
: isLight
? 'text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
aria-label="Close window"
>
<X size={16} />
</button>
</div>
<!-- Content -->
<div class="overflow-y-auto p-4 flex-1">
{@render children()}
</div>
</div>
{/if}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: RELATIONSHIP_COLORS access uses keys from controlled constant array (relationshipTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import {
RELATIONSHIP_COLORS,
relationshipTypes,
relationshipStrengths,
type RelationshipType,
type RelationshipStrength,
} from '$lib/constants';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
editingLinkLabel: string;
editingLinkType: string;
editingLinkStrength: RelationshipStrength;
editingLinkTypeManuallyEdited: boolean;
linkEditInput: HTMLInputElement | null;
linkTypeEditInput: HTMLInputElement | null;
isLight: boolean;
theme: string;
mutedTextClass: string;
inputClass: string;
modalBackdropClass: string;
surfaceClass: string;
onLabelChange: (value: string) => void;
onTypeChange: (value: string) => void;
onTypeManuallyEdited: () => void;
onTypeButtonClick: (relType: RelationshipType) => void;
onStrengthChange: (strength: RelationshipStrength) => void;
onSave: () => void;
onCancel: () => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
editingLinkLabel,
editingLinkType,
editingLinkStrength,
linkEditInput,
linkTypeEditInput,
isLight,
theme,
mutedTextClass,
inputClass,
modalBackdropClass,
surfaceClass,
onLabelChange,
onTypeChange,
onTypeManuallyEdited,
onTypeButtonClick,
onStrengthChange,
onSave,
onCancel,
zIndex = 50,
onFocus,
}: Props = $props();
function handleLabelInput(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onLabelChange(target?.value ?? '');
}
function handleTypeInput(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onTypeChange(target?.value ?? '');
onTypeManuallyEdited();
}
function handleKeydown(event: KeyboardEvent, action: 'save' | 'cancel') {
if (event.key === 'Enter' && action === 'save') {
onSave();
}
if (event.key === 'Escape') {
onCancel();
}
}
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => {
if (e.target === e.currentTarget) {
onCancel();
}
}}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
aria-label="Edit Link"
>
<div
class={`rounded-lg shadow-xl p-6 w-full max-w-md border ${surfaceClass}`}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
>
{@render linkEditContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="edit-link"
title="Edit Relationship"
{open}
{isLight}
{theme}
{surfaceClass}
onClose={onCancel}
{zIndex}
{onFocus}
>
{@render linkEditContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet linkEditContent()}
<h4 class={`text-lg font-semibold mb-4 ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Edit Relationship
</h4>
<div class="space-y-4">
<div>
<label for="linkLabel" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Label</label
>
<input
id="linkLabel"
bind:this={linkEditInput}
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="Relationship label"
value={editingLinkLabel}
oninput={handleLabelInput}
onkeydown={(e) => handleKeydown(e, 'save')}
/>
</div>
<div>
<label for="linkType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Relationship Type</label
>
<input
id="linkType"
bind:this={linkTypeEditInput}
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass} mb-2`}
placeholder="Relationship type"
value={editingLinkType}
oninput={handleTypeInput}
onkeydown={(e) => handleKeydown(e, 'save')}
/>
<div class="grid grid-cols-3 gap-2" role="group" aria-label="Relationship Type">
{#each relationshipTypes as relType}
{@const relColor = RELATIONSHIP_COLORS[relType]}
<button
class={'rounded-lg border px-2 py-1.5 text-xs transition hover:brightness-110 ' +
(editingLinkType === relType
? 'border-rose-500 bg-rose-500/10 text-rose-500'
: inputClass)}
onclick={() => onTypeButtonClick(relType)}
style={editingLinkType === relType ? `border-color: ${relColor}` : ''}
type="button"
>
{relType}
</button>
{/each}
</div>
</div>
<div>
<div class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Strength</div>
<div class="flex gap-2" role="group" aria-label="Relationship Strength">
{#each relationshipStrengths as strength}
<button
class={'flex-1 rounded-lg border px-3 py-2 text-xs transition hover:brightness-110 ' +
(editingLinkStrength === strength
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: inputClass)}
onclick={() => onStrengthChange(strength)}
>
{strength.charAt(0).toUpperCase() + strength.slice(1)}
</button>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class={`rounded-lg px-3 py-2 text-sm transition-colors hover:brightness-110 ${inputClass}`}
onclick={onCancel}>Cancel</button
>
<button
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={onSave}>Save</button
>
</div>
</div>
{/snippet}

View File

@@ -0,0 +1,499 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: iconMap and typeColors access uses keys from controlled constant array (nodeTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import { X } from 'lucide-svelte';
import {
iconMap,
nodeTypes,
typeColors,
ALLOWED_IMAGE_TYPES,
MAX_IMAGE_BYTES,
} from '$lib/constants';
import type { NodeType } from '$lib/constants';
import type { Node, ConnectedEdge, CustomType } from '$lib/types';
interface Props {
node: Node | null;
connectedEdges: ConnectedEdge[];
imageObjects: Map<string, string>;
customTypes: CustomType[];
isLight: boolean;
editLabel: string;
editType: NodeType | string;
editImageUrl: string;
editColor: string;
editNotes: string;
editShowLabel: boolean;
editShowType: boolean;
editShowNotes: boolean;
imageUploadError: string;
imageUploadInput: HTMLInputElement | null;
mutedTextClass: string;
inputClass: string;
dividerClass: string;
surfaceClass: string;
onClose: () => void;
onLabelChange: (value: string) => void;
onTypeChange: (type: NodeType | string) => void;
onImageUrlChange: (value: string) => void;
onColorChange: (value: string) => void;
onNotesChange: (value: string) => void;
onToggleShowLabel: () => void;
onToggleShowType: () => void;
onToggleShowNotes: () => void;
onTriggerImageUpload: () => void;
onImageFileSelected: (event: Event) => void;
onClearImage: () => void;
onDelete: () => void;
}
let {
node,
connectedEdges,
imageObjects,
customTypes = [],
isLight,
editLabel,
editType,
editImageUrl,
editColor,
editNotes,
editShowLabel,
editShowType,
editShowNotes,
imageUploadError,
imageUploadInput = $bindable(),
mutedTextClass,
inputClass,
dividerClass,
surfaceClass,
onClose,
onLabelChange,
onTypeChange,
onImageUrlChange,
onColorChange,
onNotesChange,
onToggleShowLabel,
onToggleShowType,
onToggleShowNotes,
onTriggerImageUpload,
onImageFileSelected,
onClearImage,
onDelete,
}: Props = $props();
function handleLabelInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onLabelChange(target?.value ?? '');
}
function handleImageUrlInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onImageUrlChange(target?.value ?? '');
}
function handleColorInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onColorChange(target?.value ?? '');
}
function handleNotesInputEvent(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement | null;
onNotesChange(target?.value ?? '');
}
const maxImageSizeMB = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
const allowedImageTypesStr = ALLOWED_IMAGE_TYPES.map((t) => t.split('/')[1].toUpperCase()).join(
'/'
);
</script>
{#if node}
<div
class={`absolute top-0 right-0 h-full w-full max-w-sm border-l backdrop-blur pointer-events-auto z-20 transition-colors ${surfaceClass}`}
>
<div
class={`flex items-center justify-between border-b px-4 py-3 transition-colors ${dividerClass.replace(
'hidden md:block md:',
''
)}`}
>
<div>
<div class={`text-xs uppercase tracking-[0.3em] ${mutedTextClass}`}>Entity</div>
<div class={`text-lg font-semibold ${isLight ? 'text-neutral-900' : 'text-white'}`}>
{node.label || 'Untitled'}
</div>
</div>
<button
class={`rounded-lg border p-1.5 transition-colors ${
isLight
? 'border-amber-300 text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'border-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
>
<X size={16} />
</button>
</div>
<div class="flex h-[calc(100%-64px)] flex-col overflow-y-auto px-4 py-4 gap-4">
<div>
<div class="flex items-center gap-3">
{#if node.imageUrl || (node.imageId && imageObjects.get(node.imageId))}
<img
src={node.imageUrl || imageObjects.get(node.imageId!)}
alt={node.label}
class={`h-16 w-16 rounded-2xl border object-cover shadow-lg ${dividerClass.replace('hidden md:block md:', '')}`}
/>
{:else}
{@const customType = customTypes.find((t) => t.id === node.type)}
<div
class={`h-16 w-16 rounded-2xl border flex items-center justify-center shadow-lg ${inputClass} ${dividerClass.replace('hidden md:block md:', '')}`}
>
{#if customType && customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt={customType.name}
class="h-10 w-10 object-cover rounded"
/>
{:else if customType}
<div
class="w-8 h-8 rounded-full border-2"
style={`background-color: ${customType.color}22; border-color: ${customType.color}`}
></div>
{:else}
{#snippet icon()}
{@const IconComponent = iconMap[node.type as NodeType]}
<IconComponent size={28} color={typeColors[node.type as NodeType]} />
{/snippet}
{@render icon()}
{/if}
</div>
{/if}
<div>
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Type</div>
<div class="text-sm font-semibold text-neutral-200 capitalize">
{customTypes.find((t) => t.id === node.type)?.name || node.type}
</div>
{#if node.notes}
<div class="mt-1 text-xs text-amber-200 break-words">{node.notes}</div>
{/if}
</div>
</div>
</div>
<div class="space-y-3">
<div>
<label
for="inspector-label-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Label</label
>
<input
id="inspector-label-input"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editLabel}
oninput={handleLabelInputEvent}
placeholder="Entity Label"
/>
</div>
<div>
<label
for="inspector-color-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Node Color Override</label
>
<div class="flex gap-2">
<input
id="inspector-color-input"
type="color"
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
value={editColor ||
(editType && customTypes.find((t) => t.id === editType)?.color) ||
typeColors[editType as NodeType] ||
'#ef4444'}
oninput={handleColorInputEvent}
/>
<input
type="text"
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editColor}
oninput={handleColorInputEvent}
placeholder="Custom Hex Color"
/>
{#if editColor}
<button
class={`px-2 py-1 rounded text-xs transition-colors ${
isLight
? 'bg-amber-100 text-neutral-600 hover:bg-amber-200'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
onclick={() => onColorChange('')}
>
Clear
</button>
{/if}
</div>
</div>
<div>
<div class={`text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}>
Type
</div>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as type}
<button
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
(editType === type
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700')}
type="button"
onclick={() => onTypeChange(type)}
title={type}
>
{#snippet typeButton()}
{@const IconComponent = iconMap[type]}
<IconComponent size={18} color={typeColors[type]} />
<span class="truncate w-full text-center">{type}</span>
{/snippet}
{@render typeButton()}
</button>
{/each}
{#each customTypes as customType}
<button
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
(editType === customType.id
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700')}
type="button"
onclick={() => onTypeChange(customType.id)}
title={customType.name}
>
<div
class="w-5 h-5 rounded-full flex items-center justify-center overflow-hidden"
style={`background-color: ${customType.color}22; border: 1.5px solid ${customType.color}`}
>
{#if customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-1.5 h-1.5 rounded-full"
style={`background-color: ${customType.color}`}
></div>
{/if}
</div>
<span class="truncate w-full text-center">{customType.name}</span>
</button>
{/each}
</div>
</div>
<div>
<label
for="inspector-image-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Custom Image URL</label
>
<input
id="inspector-image-input"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editImageUrl}
oninput={handleImageUrlInputEvent}
placeholder="https://..."
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:bg-amber-100'
: 'border-neutral-800 bg-neutral-900 text-neutral-300 hover:bg-neutral-800'
}`}
type="button"
onclick={onTriggerImageUpload}
>
Upload Image
</button>
{#if node?.imageUrl || node?.imageId}
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:bg-amber-100'
: 'border-neutral-800 bg-neutral-900 text-neutral-300 hover:bg-neutral-800'
}`}
type="button"
onclick={onClearImage}
>
Remove Image
</button>
{/if}
</div>
<input
type="file"
accept={ALLOWED_IMAGE_TYPES.join(',')}
class="hidden"
bind:this={imageUploadInput}
onchange={onImageFileSelected}
/>
<p class={`mt-1 text-[11px] ${mutedTextClass}`}>
Paste a URL or upload a {allowedImageTypesStr} ({maxImageSizeMB}MB max).
</p>
{#if imageUploadError}
<p class={`mt-1 text-[11px] ${isLight ? 'text-rose-600' : 'text-rose-300'}`}>
{imageUploadError}
</p>
{/if}
</div>
<div>
<label
for="inspector-notes-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Notes</label
>
<textarea
id="inspector-notes-input"
rows="4"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none resize-none ${inputClass}`}
value={editNotes}
oninput={handleNotesInputEvent}
placeholder="Add analyst notes, identifiers, context..."
></textarea>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-2">
Display Options
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Label</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowLabel
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowLabel}
role="switch"
aria-checked={editShowLabel}
aria-label="Toggle show label"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowLabel ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Type Icon</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowType
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowType}
role="switch"
aria-checked={editShowType}
aria-label="Toggle show type icon"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowType ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Notes</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowNotes
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowNotes}
role="switch"
aria-checked={editShowNotes}
aria-label="Toggle show notes"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowNotes ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
</div>
</div>
</div>
<div
class={`border rounded-xl p-3 transition-colors ${
isLight ? 'border-amber-200 bg-amber-50/60' : 'border-neutral-900 bg-neutral-900/40'
}`}
>
<div class={`text-xs uppercase tracking-[0.3em] mb-2 ${mutedTextClass}`}>Connections</div>
{#if connectedEdges.length > 0}
<div class="space-y-2">
{#each connectedEdges as edge}
<div
class={`rounded-lg border px-3 py-2 transition-colors ${
isLight
? 'border-amber-200 bg-amber-50/50'
: 'border-neutral-800 bg-neutral-900/40'
}`}
>
<div
class={`text-sm font-semibold ${isLight ? 'text-neutral-900' : 'text-neutral-100'}`}
>
{edge.otherNode?.label || 'Unknown entity'}
</div>
<div class={`text-xs capitalize ${mutedTextClass}`}>
{edge.otherNode?.type || 'entity'} • {edge.label}
</div>
</div>
{/each}
</div>
{:else}
<div class={`text-xs ${mutedTextClass}`}>
No linked entities yet. Shift + drag from this node to create relationships.
</div>
{/if}
</div>
<div class={`mt-auto pt-2 text-[11px] text-center ${mutedTextClass}`}>
Changes are applied and saved automatically.
</div>
<button
class={`rounded-lg border px-3 py-2 text-sm transition ${
isLight
? 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100'
: 'border-rose-900/40 bg-rose-900/10 text-rose-200 hover:bg-rose-900/20'
}`}
onclick={onDelete}
>
Delete Entity
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,283 @@
<script lang="ts">
import { X, Sun, Moon, RotateCcw } from 'lucide-svelte';
import { GRID_SIZE } from '$lib/constants';
import type { AppTheme } from '$lib/types';
import { themeConfigs, themeNames, getThemeIcon } from '$lib/themes.svelte';
import FloatingWindow from './FloatingWindow.svelte';
import { clearWindowPositions } from '$lib/db';
import { toast } from '$lib/toast.svelte';
import type { Component } from 'svelte';
interface Props {
open: boolean;
isMobile: boolean;
showGrid: boolean;
gridOpacityMultiplier: number;
snapToGrid: boolean;
autoLinkingEnabled: boolean;
theme: AppTheme;
isLight: boolean;
modalBackdropClass: string;
surfaceClass: string;
dividerClass: string;
inputClass: string;
onClose: () => void;
onShowGridChange: (value: boolean) => void;
onGridOpacityChange: (value: number) => void;
onSnapToGridChange: (value: boolean) => void;
onAutoLinkingChange: (value: boolean) => void;
onThemeToggle: () => void;
onThemeChange: (theme: AppTheme) => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
showGrid,
gridOpacityMultiplier,
snapToGrid,
autoLinkingEnabled,
theme,
isLight,
modalBackdropClass,
surfaceClass,
dividerClass,
inputClass,
onClose,
onShowGridChange,
onGridOpacityChange,
onSnapToGridChange,
onAutoLinkingChange,
onThemeToggle,
onThemeChange,
zIndex = 50,
onFocus,
}: Props = $props();
async function handleResetPositions() {
await clearWindowPositions();
toast.success('Window positions reset. Please refresh or reopen windows.');
}
/* eslint-disable security/detect-object-injection */
const themes: { id: AppTheme; name: string; icon: Component }[] = (
Object.keys(themeConfigs) as AppTheme[]
).map((id) => ({
id,
name: themeNames[id],
icon: getThemeIcon(id) as unknown as Component,
}));
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Settings"
>
<div class={`w-full max-w-md rounded-xl border p-6 shadow-2xl ${surfaceClass}`}>
{@render settingsContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="settings"
title="Settings"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
{zIndex}
{onFocus}
>
{@render settingsContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet settingsContent()}
<div class="flex items-center justify-between mb-6">
<h4
class={`text-lg font-semibold transition-colors ${isLight ? 'text-neutral-900' : 'text-white'}`}
>
Settings
</h4>
{#if isMobile}
<button
class={`p-1 rounded transition-colors ${
isLight
? 'text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
>
<X size={20} />
</button>
{/if}
</div>
<div class="space-y-6">
<div class="space-y-3">
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">
Grid Settings
</div>
<div class="flex items-center justify-between">
<span
class={`text-sm transition-colors ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Grid Squares</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
showGrid
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onShowGridChange(!showGrid)}
role="switch"
aria-checked={showGrid}
aria-label="Toggle grid squares"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
showGrid ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Grid Opacity</span
>
<span class="text-xs font-mono text-neutral-500"
>{Math.round(gridOpacityMultiplier * 100)}%</span
>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={gridOpacityMultiplier}
oninput={(e) => onGridOpacityChange(Number((e.currentTarget as HTMLInputElement).value))}
class={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-rose-500 transition-colors ${inputClass}`}
/>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Snap to Grid ({GRID_SIZE}px)</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
snapToGrid
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onSnapToGridChange(!snapToGrid)}
role="switch"
aria-checked={snapToGrid}
aria-label="Toggle snap to grid"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
snapToGrid ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Disable Auto Linking</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
!autoLinkingEnabled
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onAutoLinkingChange(!autoLinkingEnabled)}
role="switch"
aria-checked={!autoLinkingEnabled}
aria-label="Toggle auto linking"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
!autoLinkingEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
</div>
<div class={`space-y-3 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">Theme</div>
<div class="grid grid-cols-2 gap-2">
{#each themes as t}
<button
class={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm ${
theme === t.id
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: `${inputClass} hover:brightness-110`
}`}
onclick={() => onThemeChange(t.id)}
>
<t.icon size={16} />
{t.name}
</button>
{/each}
</div>
<button
class={`w-full flex items-center justify-between px-4 py-2 mt-2 rounded-lg border transition-colors ${inputClass} hover:brightness-110`}
onclick={onThemeToggle}
>
<span class="text-xs opacity-70">Cycle Themes</span>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{themes.find((t) => t.id === theme)?.name}</span>
{#if isLight}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
</div>
</button>
</div>
<div class={`space-y-3 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">System</div>
<button
class={`w-full flex items-center justify-between px-4 py-2 rounded-lg border transition-colors ${inputClass} hover:brightness-110`}
onclick={handleResetPositions}
>
<span class="text-xs font-medium">Reset Window Positions</span>
<RotateCcw size={14} class="text-rose-500" />
</button>
</div>
</div>
<div class="flex justify-end mt-8">
<button
class="rounded-lg bg-rose-600 px-6 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={onClose}
>
Close
</button>
</div>
{/snippet}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
import { themeConfigs } from '$lib/themes.svelte';
import type { AppTheme } from '$lib/types';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
import { fly } from 'svelte/transition';
interface Props {
theme: AppTheme;
}
/* eslint-disable security/detect-object-injection */
let { theme }: Props = $props();
let config = $derived(themeConfigs[theme]);
const typeStyles = {
success: {
icon: CheckCircle,
color: 'text-green-500',
bg: 'border-green-500/30',
},
error: {
icon: AlertCircle,
color: 'text-rose-500',
bg: 'border-rose-500/30',
},
warning: {
icon: AlertTriangle,
color: 'text-amber-500',
bg: 'border-amber-500/30',
},
info: {
icon: Info,
color: 'text-cyan-500',
bg: 'border-cyan-500/30',
},
};
</script>
<div
class="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none w-full max-w-sm px-4 md:bottom-6"
>
{#each toast.toasts as t (t.id)}
{@const style = typeStyles[t.type]}
<div
in:fly={{ y: 20, duration: 300 }}
out:fly={{ y: -20, duration: 200 }}
class={`pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border shadow-2xl backdrop-blur-md transition-all ${config.surface} ${style.bg}`}
>
<style.icon size={18} class={style.color} />
<p class="text-sm flex-1 font-medium">{t.message}</p>
<button
onclick={() => toast.remove(t.id)}
class={`p-1 rounded-lg hover:bg-white/10 transition-colors ${config.muted}`}
>
<X size={14} />
</button>
</div>
{/each}
</div>

View File

@@ -0,0 +1,349 @@
<script lang="ts">
import {
Plus,
Link2,
Upload,
Download,
Undo2,
Redo2,
Maximize,
Trash2,
Share2,
HelpCircle,
Settings,
Layers,
MoreVertical,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
Sun,
Moon,
} from 'lucide-svelte';
interface Props {
isMobile: boolean;
mobileToolbarCollapsed: boolean;
desktopToolbarCollapsed: boolean;
isLinkMode: boolean;
panelClass: string;
iconButtonClass: string;
dividerClass: string;
undoCount: number;
redoCount: number;
isLight: boolean;
showMoreMenu: boolean;
moreMenuRef: HTMLDivElement | null;
onUndo: () => void;
onRedo: () => void;
onCenterView: () => void;
onImportGraph: () => void;
onExportGraph: () => void;
onShareGraph: () => void;
onClearGraph: () => void;
onShowSettings: () => void;
onShowCustomTypes: () => void;
onShowAddModal: () => void;
onShowShortcuts: () => void;
onToggleLinkMode: () => void;
onToggleMobileToolbar: () => void;
onToggleDesktopToolbar: () => void;
onToggleMoreMenu: (e: MouseEvent) => void;
onToggleTheme: () => void;
}
let {
isMobile,
mobileToolbarCollapsed,
desktopToolbarCollapsed,
isLinkMode,
panelClass,
iconButtonClass,
dividerClass,
undoCount,
redoCount,
isLight,
showMoreMenu = $bindable(),
moreMenuRef = $bindable(),
onUndo,
onRedo,
onCenterView,
onImportGraph,
onExportGraph,
onShareGraph,
onClearGraph,
onShowSettings,
onShowCustomTypes,
onShowAddModal,
onShowShortcuts,
onToggleLinkMode,
onToggleMobileToolbar,
onToggleDesktopToolbar,
onToggleMoreMenu,
onToggleTheme,
}: Props = $props();
</script>
<div
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 md:left-4 md:translate-x-0 max-h-[calc(100vh-120px)] md:max-h-none mobile-landscape:flex-row mobile-landscape:left-1/2 mobile-landscape:-translate-x-1/2 mobile-landscape:top-auto mobile-landscape:bottom-2 mobile-landscape:max-h-none mobile-landscape:gap-1 mobile-landscape:max-w-[calc(100vw-1rem)] mobile-landscape:w-auto transition-all duration-300 {mobileToolbarCollapsed
? 'top-3 right-2 md:right-auto'
: 'top-3 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 w-[calc(100vw-0.25rem)] md:w-auto'} {desktopToolbarCollapsed
? 'md:top-0'
: 'md:top-4'}"
>
{#if (isMobile && !mobileToolbarCollapsed) || (!isMobile && !desktopToolbarCollapsed)}
<div
class={`rounded-lg p-2 mobile-landscape:p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass} max-h-full overflow-visible mobile-landscape:max-h-none mobile-landscape:overflow-visible w-full md:w-auto transition-all ${
mobileToolbarCollapsed ? 'hidden md:block' : ''
}`}
>
<div
class="flex flex-row flex-nowrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-2 md:gap-1.5 mobile-landscape:gap-1 justify-start md:justify-center mobile-landscape:justify-center w-full md:w-auto mobile-landscape:w-auto overflow-visible md:overflow-visible items-center"
>
<button class={iconButtonClass} title="Toggle Theme" onclick={onToggleTheme}>
{#if !isLight}
<Sun size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{:else}
<Moon size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{/if}
</button>
<button
class={`${iconButtonClass} ${isLinkMode ? 'bg-rose-500/20 text-rose-500 border-rose-500/50' : ''}`}
title="Linking Mode"
onclick={onToggleLinkMode}
>
<Link2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Settings" onclick={onShowSettings}>
<Settings size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Custom Types" onclick={onShowCustomTypes}>
<Layers size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Add Node"
onclick={onShowAddModal}
>
<Plus size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={`${dividerClass} hidden md:block mobile-landscape:block`}></div>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Import Graph"
onclick={onImportGraph}
>
<Upload size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Export JSON"
onclick={onExportGraph}
>
<Download size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Share Link"
onclick={onShareGraph}
>
<Share2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Keyboard Shortcuts (?)"
onclick={onShowShortcuts}
>
<HelpCircle size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
class={iconButtonClass}
title="Undo (Ctrl+Z)"
onclick={onUndo}
disabled={undoCount === 0}
class:opacity-50={undoCount === 0}
>
<Undo2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={iconButtonClass}
title="Redo (Ctrl+Y)"
onclick={onRedo}
disabled={redoCount === 0}
class:opacity-50={redoCount === 0}
>
<Redo2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Fit to Screen" onclick={onCenterView}>
<Maximize size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Clear Graph"
onclick={onClearGraph}
>
<Trash2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={`${dividerClass} hidden md:block mobile-landscape:block`}></div>
<div class="relative md:hidden mobile-landscape:hidden">
<button
class={iconButtonClass}
title="More options"
data-more-menu-button
onclick={onToggleMoreMenu}
>
<MoreVertical size={18} />
</button>
{#if showMoreMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={moreMenuRef}
class={`absolute top-full right-0 mt-1 rounded-lg shadow-lg border ${panelClass} z-[100] min-w-[180px] pointer-events-auto`}
onclick={(e) => e.stopPropagation()}
role="menu"
tabindex="-1"
>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowAddModal();
showMoreMenu = false;
}}
>
<Plus size={16} />
Add Node
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onImportGraph();
showMoreMenu = false;
}}
>
<Upload size={16} />
Import Graph
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onExportGraph();
showMoreMenu = false;
}}
>
<Download size={16} />
Export JSON
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShareGraph();
showMoreMenu = false;
}}
>
<Share2 size={16} />
Share Link
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowSettings();
showMoreMenu = false;
}}
>
<Settings size={16} />
Settings
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowCustomTypes();
showMoreMenu = false;
}}
>
<Layers size={16} />
Custom Types
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onClearGraph();
showMoreMenu = false;
}}
>
<Trash2 size={16} />
Clear Graph
</button>
</div>
{/if}
</div>
<button
class={`${iconButtonClass} md:hidden mobile-landscape:hidden ml-auto`}
title="Collapse toolbar"
onclick={onToggleMobileToolbar}
>
<ChevronLeft size={18} />
</button>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:hidden`}
title="Collapse toolbar"
onclick={onToggleDesktopToolbar}
>
<ChevronUp size={18} />
</button>
</div>
</div>
{/if}
{#if mobileToolbarCollapsed}
<button
class={`${iconButtonClass} md:hidden mobile-landscape:hidden pointer-events-auto rounded-lg p-2 shadow-lg border ${panelClass} w-auto`}
title="Expand toolbar"
onclick={onToggleMobileToolbar}
>
<ChevronRight size={18} />
</button>
{/if}
{#if desktopToolbarCollapsed}
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:hidden pointer-events-auto rounded-lg p-2 shadow-lg border ${panelClass} w-auto`}
title="Expand toolbar"
onclick={onToggleDesktopToolbar}
>
<ChevronDown size={18} />
</button>
{/if}
</div>

View File

@@ -4,6 +4,8 @@ export const DB_NAME = 'quad4-linking-db';
export const DB_VERSION = 2;
export const STORE_NAME = 'graphs';
export const SETTINGS_STORE = 'settings';
export const IMAGE_STORE = 'images';
export const CUSTOM_TYPES_STORE = 'custom_types';
export const UNDO_STORE = 'undo_stack';
export const REDO_STORE = 'redo_stack';
@@ -12,6 +14,8 @@ export const MAX_HISTORY = 100;
export const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
export const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
export const GRID_SIZE = 40;
export type RelationshipType =
| 'Linked'
| 'Works For'

390
src/lib/db.ts Normal file
View File

@@ -0,0 +1,390 @@
/* global IDBDatabase, IDBOpenDBRequest, IDBKeyRange */
import {
DB_NAME,
DB_VERSION,
STORE_NAME,
SETTINGS_STORE,
IMAGE_STORE,
CUSTOM_TYPES_STORE,
UNDO_STORE,
REDO_STORE,
MAX_HISTORY,
} from './constants';
import type { StoredGraphData, GraphState, CustomType } from './types';
let db: IDBDatabase | null = null;
export async function initDB(): Promise<IDBDatabase> {
if (db) return db;
if (typeof window === 'undefined' || !window.indexedDB) {
throw new Error('IndexedDB is not available');
}
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const requiredStores = [
STORE_NAME,
SETTINGS_STORE,
UNDO_STORE,
REDO_STORE,
IMAGE_STORE,
CUSTOM_TYPES_STORE,
];
const missingStores = requiredStores.filter(
(store) => !database.objectStoreNames.contains(store)
);
if (missingStores.length > 0) {
database.close();
const deleteRequest = window.indexedDB.deleteDatabase(DB_NAME);
deleteRequest.onsuccess = () => {
const recreateRequest = window.indexedDB.open(DB_NAME, DB_VERSION);
recreateRequest.onerror = () => reject(recreateRequest.error);
recreateRequest.onsuccess = () => {
db = recreateRequest.result;
resolve(db);
};
recreateRequest.onupgradeneeded = (event) => {
const target = event.target;
if (!target) return;
const newDatabase = (target as IDBOpenDBRequest).result;
if (!newDatabase.objectStoreNames.contains(STORE_NAME)) {
newDatabase.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
if (!newDatabase.objectStoreNames.contains(SETTINGS_STORE)) {
newDatabase.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
}
if (!newDatabase.objectStoreNames.contains(UNDO_STORE)) {
newDatabase.createObjectStore(UNDO_STORE, { keyPath: 'index' });
}
if (!newDatabase.objectStoreNames.contains(REDO_STORE)) {
newDatabase.createObjectStore(REDO_STORE, { keyPath: 'index' });
}
if (!newDatabase.objectStoreNames.contains(IMAGE_STORE)) {
newDatabase.createObjectStore(IMAGE_STORE, { keyPath: 'id' });
}
if (!newDatabase.objectStoreNames.contains(CUSTOM_TYPES_STORE)) {
newDatabase.createObjectStore(CUSTOM_TYPES_STORE, { keyPath: 'id' });
}
};
};
deleteRequest.onerror = () => reject(deleteRequest.error);
} else {
db = database;
resolve(db);
}
};
request.onupgradeneeded = (event) => {
const target = event.target;
if (!target) return;
const database = (target as IDBOpenDBRequest).result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(SETTINGS_STORE)) {
database.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
}
if (!database.objectStoreNames.contains(UNDO_STORE)) {
database.createObjectStore(UNDO_STORE, { keyPath: 'index' });
}
if (!database.objectStoreNames.contains(REDO_STORE)) {
database.createObjectStore(REDO_STORE, { keyPath: 'index' });
}
if (!database.objectStoreNames.contains(IMAGE_STORE)) {
database.createObjectStore(IMAGE_STORE, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(CUSTOM_TYPES_STORE)) {
database.createObjectStore(CUSTOM_TYPES_STORE, { keyPath: 'id' });
}
};
});
}
export async function saveToIndexedDB(key: string, data: StoredGraphData): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const record = {
id: key,
data,
timestamp: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save to IndexedDB:', err);
}
}
export async function loadFromIndexedDB(key: string): Promise<StoredGraphData | null> {
try {
const database = await initDB();
const transaction = database.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.data : null);
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load from IndexedDB:', err);
return null;
}
}
export async function saveSetting(key: string, value: string | number | boolean): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readwrite');
const store = transaction.objectStore(SETTINGS_STORE);
const record = { key, value };
return new Promise((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save setting:', err);
}
}
export async function loadSetting(key: string): Promise<string | number | boolean | null> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readonly');
const store = transaction.objectStore(SETTINGS_STORE);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load setting:', err);
return null;
}
}
export async function clearWindowPositions(): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readwrite');
const store = transaction.objectStore(SETTINGS_STORE);
return new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
if (typeof cursor.key === 'string' && cursor.key.startsWith('window_pos_')) {
cursor.delete();
}
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to clear window positions:', err);
}
}
export async function storeImageBlob(id: string, blob: Blob): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.put({ id, blob, timestamp: new Date().toISOString() });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to store image blob:', err);
}
}
export async function loadImageBlob(id: string): Promise<Blob | null> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readonly');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result ? request.result.blob : null);
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load image blob:', err);
return null;
}
}
export async function deleteImageBlob(id: string): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to delete image blob:', err);
}
}
export async function saveCustomType(customType: CustomType): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readwrite');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.put(customType);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save custom type:', err);
}
}
export async function loadCustomTypes(): Promise<CustomType[]> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readonly');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load custom types:', err);
return [];
}
}
export async function deleteCustomType(id: string): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readwrite');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to delete custom type:', err);
}
}
export async function pushToStack(storeName: string, state: GraphState) {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise<void>((resolve, reject) => {
const countRequest = store.count();
countRequest.onsuccess = () => {
const count = countRequest.result;
const putRequest = store.put({ index: count, state });
putRequest.onsuccess = () => {
if (count + 1 > MAX_HISTORY) {
const deleteRequest = store.delete(IDBKeyRange.upperBound(count - MAX_HISTORY));
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
} else {
resolve();
}
};
putRequest.onerror = () => reject(putRequest.error);
};
countRequest.onerror = () => reject(countRequest.error);
});
}
export async function popFromStack(storeName: string): Promise<GraphState | null> {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const countRequest = store.count();
countRequest.onsuccess = () => {
const count = countRequest.result;
if (count === 0) {
resolve(null);
return;
}
const getRequest = store.get(count - 1);
getRequest.onsuccess = () => {
const result = getRequest.result;
const deleteRequest = store.delete(count - 1);
deleteRequest.onsuccess = () => resolve(result ? result.state : null);
deleteRequest.onerror = () => reject(deleteRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
};
countRequest.onerror = () => reject(countRequest.error);
});
}
export async function clearStack(storeName: string) {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getStackCount(storeName: string): Promise<number> {
const database = await initDB();
const transaction = database.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

154
src/lib/themes.svelte.ts Normal file
View File

@@ -0,0 +1,154 @@
import { Sun, Moon, Palette } from 'lucide-svelte';
import type { AppTheme } from './types';
export interface ThemeConfig {
bg: string;
surface: string;
panel: string;
input: string;
muted: string;
iconButton: string;
divider: string;
gridColor: string;
backdrop: string;
nodeBg: string;
nodeLabelBg: string;
}
export const themeConfigs: Record<AppTheme, ThemeConfig> = {
dark: {
bg: 'bg-neutral-950',
surface: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg',
panel: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg',
input: 'bg-neutral-800 border-neutral-700 text-neutral-100 placeholder-neutral-500',
muted: 'text-neutral-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-neutral-800 md:my-1',
gridColor: '255, 255, 255',
backdrop: 'bg-black/60',
nodeBg: '#171717',
nodeLabelBg: 'rgba(0, 0, 0, 0.5)',
},
light: {
bg: 'bg-amber-50',
surface: 'bg-white border-amber-200 text-neutral-900 shadow-amber-900/20',
panel: 'bg-white/95 border-amber-200 text-neutral-800 shadow-amber-900/10',
input: 'bg-amber-50 border-amber-300 text-neutral-900 placeholder-neutral-500',
muted: 'text-neutral-600',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100',
divider: 'hidden md:block md:h-px bg-neutral-300 md:my-1',
gridColor: '0, 0, 0',
backdrop: 'bg-black/30',
nodeBg: '#ffffff',
nodeLabelBg: 'rgba(255, 255, 255, 0.9)',
},
oled: {
bg: 'bg-black',
surface: 'bg-neutral-950 border-neutral-800 text-neutral-200 shadow-lg',
panel: 'bg-neutral-950 border-neutral-800 text-neutral-200 shadow-lg',
input: 'bg-neutral-900 border-neutral-800 text-neutral-100 placeholder-neutral-500',
muted: 'text-neutral-600',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-900 rounded text-neutral-500 hover:text-white',
divider: 'hidden md:block md:h-px bg-neutral-900 md:my-1',
gridColor: '255, 255, 255',
backdrop: 'bg-black/80',
nodeBg: '#000000',
nodeLabelBg: 'rgba(0, 0, 0, 0.8)',
},
midnight: {
bg: 'bg-slate-950',
surface: 'bg-slate-900 border-slate-800 text-slate-200 shadow-lg',
panel: 'bg-slate-900/95 border-slate-800 text-slate-200 shadow-lg',
input: 'bg-slate-800 border-slate-700 text-slate-100 placeholder-slate-500',
muted: 'text-slate-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-slate-800 md:my-1',
gridColor: '148, 163, 184',
backdrop: 'bg-slate-950/70',
nodeBg: '#0f172a',
nodeLabelBg: 'rgba(15, 23, 42, 0.7)',
},
sepia: {
bg: 'bg-[#f4ecd8]',
surface: 'bg-[#fdf6e3] border-[#d3c6aa] text-[#5c6a72] shadow-sm',
panel: 'bg-[#fdf6e3]/95 border-[#d3c6aa] text-[#5c6a72] shadow-sm',
input: 'bg-[#f4ecd8] border-[#d3c6aa] text-[#5c6a72] placeholder-[#a6b0a0]',
muted: 'text-[#939f91]',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-[#5c6a72] hover:text-[#333] hover:bg-[#e9e0ca]',
divider: 'hidden md:block md:h-px bg-[#d3c6aa] md:my-1',
gridColor: '92, 106, 114',
backdrop: 'bg-[#5c6a72]/20',
nodeBg: '#fdf6e3',
nodeLabelBg: 'rgba(253, 246, 227, 0.9)',
},
slate: {
bg: 'bg-zinc-950',
surface: 'bg-zinc-900 border-zinc-800 text-zinc-200 shadow-lg',
panel: 'bg-zinc-900/95 border-zinc-800 text-zinc-200 shadow-lg',
input: 'bg-zinc-800 border-zinc-700 text-zinc-100 placeholder-zinc-500',
muted: 'text-zinc-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-zinc-800 md:my-1',
gridColor: '161, 161, 170',
backdrop: 'bg-zinc-950/70',
nodeBg: '#18181b',
nodeLabelBg: 'rgba(24, 24, 27, 0.7)',
},
cyberpunk: {
bg: 'bg-[#0a0a12]',
surface:
'bg-[#1a1a2e]/95 border-[#ff00ff]/30 text-cyan-400 shadow-[0_0_15px_rgba(255,0,255,0.1)]',
panel:
'bg-[#1a1a2e]/95 border-[#ff00ff]/30 text-cyan-400 shadow-[0_0_15px_rgba(255,0,255,0.1)]',
input: 'bg-[#0f0f1a] border-cyan-500/50 text-yellow-400 placeholder-cyan-900',
muted: 'text-fuchsia-500/70',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-fuchsia-500/10 rounded text-cyan-500 hover:text-fuchsia-400',
divider: 'hidden md:block md:h-px bg-cyan-900 md:my-1',
gridColor: '0, 255, 255',
backdrop: 'bg-[#0a0a12]/80',
nodeBg: '#0a0a12',
nodeLabelBg: 'rgba(10, 10, 18, 0.8)',
},
paper: {
bg: 'bg-white',
surface: 'bg-white border-neutral-300 text-neutral-900 shadow-md',
panel: 'bg-white/95 border-neutral-300 text-neutral-900 shadow-sm',
input: 'bg-neutral-50 border-neutral-300 text-neutral-900 placeholder-neutral-400',
muted: 'text-neutral-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-black hover:bg-neutral-100',
divider: 'hidden md:block md:h-px bg-neutral-200 md:my-1',
gridColor: '0, 0, 0',
backdrop: 'bg-black/10',
nodeBg: '#ffffff',
nodeLabelBg: 'rgba(255, 255, 255, 0.9)',
},
};
export const themeNames: Record<AppTheme, string> = {
dark: 'Dark',
light: 'Light',
oled: 'OLED Black',
midnight: 'Midnight Blue',
sepia: 'Sepia',
slate: 'Slate Gray',
cyberpunk: 'Cyberpunk',
paper: 'Paper White',
};
export function getIsLight(theme: AppTheme): boolean {
return theme === 'light' || theme === 'sepia' || theme === 'paper';
}
export function getThemeIcon(theme: AppTheme) {
if (theme === 'sepia' || theme === 'cyberpunk') return Palette;
if (theme === 'light' || theme === 'paper') return Sun;
return Moon;
}

50
src/lib/toast.svelte.ts Normal file
View File

@@ -0,0 +1,50 @@
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
class ToastManager {
#toasts = $state<Toast[]>([]);
get toasts() {
return this.#toasts;
}
add(message: string, type: ToastType = 'info', duration = 3000) {
const id = Math.random().toString(36).slice(2, 9);
const toast: Toast = { id, message, type, duration };
this.#toasts.push(toast);
if (duration > 0) {
setTimeout(() => {
this.remove(id);
}, duration);
}
}
remove(id: string) {
this.#toasts = this.#toasts.filter((t) => t.id !== id);
}
success(message: string, duration?: number) {
this.add(message, 'success', duration);
}
error(message: string, duration?: number) {
this.add(message, 'error', duration);
}
info(message: string, duration?: number) {
this.add(message, 'info', duration);
}
warning(message: string, duration?: number) {
this.add(message, 'warning', duration);
}
}
export const toast = new ToastManager();

59
src/lib/types.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { NodeType, RelationshipType, RelationshipStrength } from './constants';
export type Node = {
id: string;
label: string;
type: NodeType | string;
x: number;
y: number;
notes?: string;
imageUrl?: string;
imageId?: string;
color?: string;
showLabel?: boolean;
showType?: boolean;
showNotes?: boolean;
};
export type CustomType = {
id: string;
name: string;
color: string;
iconId?: string; // Stored in IMAGE_STORE
};
export type Link = {
id: string;
source: string;
target: string;
label: string;
type?: RelationshipType;
strength?: RelationshipStrength;
};
export type GraphState = {
nodes: Node[];
links: Link[];
};
export type StoredGraphData = {
nodes: Node[];
links: Link[];
transform: { x: number; y: number; k: number };
};
export type ConnectedEdge = {
id: string;
label: string;
otherNode: Node | null;
};
export type AppTheme =
| 'dark'
| 'light'
| 'oled'
| 'midnight'
| 'sepia'
| 'slate'
| 'cyberpunk'
| 'paper';

View File

@@ -21,7 +21,11 @@
}
onMount(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:')
) {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {

View File

@@ -1,7 +1,5 @@
<script lang="ts">
import IdentityGraph from '../components/IdentityGraph.svelte';
import { APP_VERSION } from '$lib/version';
import { Link as LinkIcon, GitBranch } from 'lucide-svelte';
</script>
<svelte:head>
@@ -20,43 +18,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</svelte:head>
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<header
class="bg-neutral-950 border-b border-neutral-800 px-2 sm:px-6 py-1.5 sm:py-3 flex flex-col sm:flex-row justify-between items-center gap-1 sm:gap-2 flex-shrink-0 shadow-lg"
>
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-sm sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2 hover:text-accent-red-dark transition-colors"
>
<div
class="h-4 w-4 sm:h-5 sm:w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
>
<LinkIcon size={12} class="sm:w-[14px] sm:h-[14px] text-accent-red-light" />
</div>
Linking Tool
</a>
<div class="text-text-secondary text-[10px] sm:text-sm flex items-center gap-1 sm:gap-2">
<span
>Created by <a
href="https://quad4.io"
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors">Quad4</a
>
-
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors inline-flex items-center gap-1"
>v{APP_VERSION} <GitBranch size={12} /></a
></span
>
</div>
</header>
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<div class="flex flex-col h-screen h-[100dvh] bg-bg-primary text-text-primary overflow-hidden">
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0">
<IdentityGraph />
</main>
</div>

View File

@@ -1,4 +1,4 @@
const CACHE_VERSION = '1.5.1';
const CACHE_VERSION = '1.6.0';
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];