168 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
db82c15c51 Auto-update SBOM [skip ci] 2025-12-30 03:41:17 +00:00
ec38a69c57 Add mkdir command to create frontend_dist directory in desktop build tasks
All checks were successful
CI / scan-backend (push) Successful in 11s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m29s
CI / build-frontend (push) Successful in 9m40s
CI / build-backend (push) Successful in 19s
2025-12-29 21:31:20 -06:00
ae08e4dc5f Refactor build workflow
All checks were successful
CI / scan-backend (push) Successful in 10s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m25s
CI / build-frontend (push) Successful in 9m40s
CI / build-backend (push) Successful in 9m27s
2025-12-29 21:18:30 -06:00
cd3d9862c3 Update download-artifact action version in build workflow to v3.0.2
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 18s
CI / build-frontend (push) Successful in 42s
CI / build-backend (push) Has been cancelled
CI / scan-backend (push) Has been cancelled
2025-12-29 21:15:06 -06:00
0f383d7f44 Update README 2025-12-29 17:36:44 -06:00
1180804025 Add Podman tasks to Taskfile.yml for building and running containers 2025-12-29 17:34:32 -06:00
c22e1af86f Update CACHE_VERSION to 1.5.1 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 9m22s
CI / build-backend (push) Successful in 19s
2025-12-29 16:42:30 -06:00
09c62bed71 Add flake.lock and update flake.nix with new dependencies and hashes 2025-12-29 16:42:24 -06:00
c780fe040a Update README 2025-12-29 16:36:51 -06:00
1e521b0c59 Add task to build desktop application for Linux in Taskfile.yml 2025-12-29 16:35:42 -06:00
832afe7b90 Add build and release workflow configuration 2025-12-29 16:35:37 -06:00
6d0069a8d3 Add flake.nix 2025-12-29 16:30:53 -06:00
12e3cf9354 Fix SBOM workflow by adding ref parameter for checkout and ensuring push to master branch
All checks were successful
CI / scan-backend (push) Successful in 12s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m22s
CI / build-frontend (push) Successful in 9m37s
CI / build-backend (push) Successful in 9m28s
2025-12-29 16:12:46 -06:00
7ba1cfe6f7 Update SBOM workflow to trigger on version tags instead of branches
Some checks failed
CI / scan-backend (push) Successful in 11s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m23s
CI / build-frontend (push) Successful in 9m38s
Build and Publish Docker Image / build (push) Successful in 10m10s
CI / build-backend (push) Successful in 9m27s
Generate SBOM / generate-sbom (push) Failing after 9m28s
2025-12-29 14:28:22 -06:00
15d697c946 Auto-update SBOM [skip ci] 2025-12-29 20:25:57 +00:00
a8a4405946 1.5.1
All checks were successful
CI / scan-backend (push) Successful in 17s
Generate SBOM / generate-sbom (push) Successful in 23s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m23s
CI / build-frontend (push) Successful in 9m38s
CI / build-backend (push) Successful in 24s
2025-12-29 14:25:31 -06:00
2ddd0bf9fd 1.5.1 2025-12-29 14:25:24 -06:00
c3e10b3945 Auto-update SBOM [skip ci] 2025-12-29 20:15:06 +00:00
6da7b31269 Add HOST environment variable to Dockerfile for host binding
All checks were successful
CI / scan-backend (push) Successful in 17s
Generate SBOM / generate-sbom (push) Successful in 36s
CI / build-frontend (push) Successful in 9m37s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m29s
CI / build-backend (push) Successful in 9m27s
2025-12-29 14:14:25 -06:00
4b553d67d4 Update main.go to allow HOST environment variable for host binding and improve error handling in API response. Update app.go to enforce stricter file permissions and ensure valid file paths when loading files. 2025-12-29 14:14:20 -06:00
3b5807a480 Auto-update SBOM [skip ci] 2025-12-29 19:56:07 +00:00
e6c0387cdd Update Dockerfile path in CI workflow to use ./docker/Dockerfile
All checks were successful
CI / scan-backend (push) Successful in 22s
Generate SBOM / generate-sbom (push) Successful in 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m23s
CI / build-frontend (push) Successful in 9m38s
CI / build-backend (push) Successful in 26s
2025-12-29 13:55:39 -06:00
ce65f05bd4 Auto-update SBOM [skip ci] 2025-12-29 19:52:12 +00:00
3190c6f119 Update README.md
All checks were successful
CI / scan-backend (push) Successful in 19s
Generate SBOM / generate-sbom (push) Successful in 25s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m30s
CI / build-frontend (push) Successful in 9m36s
CI / build-backend (push) Successful in 28s
2025-12-29 13:51:47 -06:00
7a725a505f Change default host binding from '0.0.0.0' to '127.0.0.1' in main.go 2025-12-29 13:51:09 -06:00
6a1667b34d Auto-update SBOM [skip ci] 2025-12-29 19:38:07 +00:00
2f1bf6a05a Fix output format for CycloneDX in SBOM generation step of CI workflow
All checks were successful
CI / scan-backend (push) Successful in 22s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 38s
Generate SBOM / generate-sbom (push) Successful in 43s
CI / build-frontend (push) Successful in 49s
CI / build-backend (push) Successful in 22s
2025-12-29 13:37:17 -06:00
3662bda009 Specify version '3.46.3' for Setup Task action in CI workflow
Some checks failed
CI / scan-backend (push) Successful in 20s
Generate SBOM / generate-sbom (push) Failing after 23s
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-29 13:35:29 -06:00
0757ca64f9 Update CI workflow by adding linting step to frontend
Some checks failed
Generate SBOM / generate-sbom (push) Failing after 13s
CI / scan-backend (push) Successful in 15s
CI / build-frontend (push) Has been cancelled
CI / build-backend (push) Has been cancelled
OSV-Scanner Scheduled Scan / scan-scheduled (push) Has been cancelled
2025-12-29 13:32:46 -06:00
d2856b27a8 Update
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 20s
CI / build-frontend (push) Successful in 40s
Generate SBOM / generate-sbom (push) Has been cancelled
CI / build-backend (push) Successful in 21s
CI / lint-backend (push) Successful in 9m25s
2025-12-29 13:30:05 -06:00
1532bcae31 Update CI workflow to specify version for Setup Task action 2025-12-29 13:29:59 -06:00
c9627a71ea Add GitHub Action workflow to generate Software Bill of Materials (SBOM) using Trivy
Some checks failed
Generate SBOM / generate-sbom (push) Failing after 48s
CI / build-frontend (push) Failing after 4m44s
CI / build-backend (push) Has been skipped
CI / lint-backend (push) Successful in 12s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Has been cancelled
2025-12-29 13:22:20 -06:00
8ed205375b Remove duplicate import
Some checks failed
CI / lint-backend (push) Successful in 24s
CI / build-frontend (push) Failing after 4m41s
CI / build-backend (push) Has been skipped
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m25s
2025-12-29 13:11:17 -06:00
0bf731df66 Update CHANGELOG.md
Some checks failed
CI / build-frontend (push) Failing after 14s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / build-backend (push) Has been cancelled
CI / lint-backend (push) Has been cancelled
2025-12-29 13:10:15 -06:00
2064760ea9 Add new tasks to Taskfile.yml for building frontend and backend, setting up development environment, and installing dependencies for CI. 2025-12-29 13:08:15 -06:00
06300f08e3 Update CI workflow by integrating Task 2025-12-29 13:08:08 -06:00
c463eb1d94 Refactor IdentityGraph component to normalize links and improve data handling. Added normalizeLinks function to ensure link types and strengths are validated against predefined lists. 2025-12-29 13:07:55 -06:00
add7f6e530 Update SECURITY.md to include PNPM in vulnerability scanning and add SAST tools for Go and JavaScript code. 2025-12-29 12:58:48 -06:00
7a3b3ca054 Add eslint-plugin-security to ESLint configuration and update package.json and pnpm-lock.yaml 2025-12-29 12:58:37 -06:00
540f9712db Add cookie version override in package.json and pnpm-lock.yaml
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 14s
CI / build-frontend (push) Successful in 9m37s
CI / build-backend (push) Has been cancelled
2025-12-29 12:53:54 -06:00
6ac2968b73 Update CI workflow to use pnpm for dependency management and upgrade Go version to 1.25.5
All checks were successful
CI / build-frontend (push) Successful in 39s
CI / build-backend (push) Successful in 20s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m25s
2025-12-29 12:49:44 -06:00
217b10b1fd Update CHANGELOG.md for version 1.5.0
Some checks failed
CI / build-frontend (push) Failing after 3s
CI / build-backend (push) Has been skipped
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
2025-12-29 12:48:32 -06:00
3b6331faea Remove 2025-12-29 12:43:02 -06:00
8eb12a7087 Remove redundant line 2025-12-29 12:42:54 -06:00
5fac643e86 Update IdentityGraph component by adding canvasElement for improved touch event handling and updating event listeners for better state management. 2025-12-29 12:42:37 -06:00
d862b1d222 Update ESLint configuration to add readonly globals for Element and EventListener 2025-12-29 12:28:04 -06:00
46bc6bdb22 Add SECURITY.md 2025-12-29 12:27:56 -06:00
0b90005bf7 refactor IdentityGraph component to improve touch event handling and state management with IndexedDB 2025-12-29 12:27:30 -06:00
bab846cd83 Refactor layout component to use props for children and update event handling for improved readability and functionality. 2025-12-29 12:12:05 -06:00
59030ba2a3 Refactor IdentityGraph component to utilize centralized constants and implement undo/redo functionality with IndexedDB for state management. 2025-12-29 12:12:00 -06:00
9468010981 Improve README 2025-12-29 12:11:41 -06:00
38887b1de2 Remove 2025-12-29 12:11:25 -06:00
2c65a17b12 Remove 2025-12-29 12:11:19 -06:00
c1c823d2b1 Remove 2025-12-29 12:11:11 -06:00
9e7a9f6d2c Add constants and types for database, relationships, and node types in constants.ts 2025-12-29 12:11:07 -06:00
a3a78ae117 Remove unnecessary newline at the end of inject-sw-version.js for cleaner code. 2025-12-29 12:11:00 -06:00
51ce1cbc40 Update build script to use pnpm instead of npm for building the app 2025-12-29 12:10:55 -06:00
1202652e93 Add compiler options in svelte.config.js to manage runes for external dependencies 2025-12-29 12:10:47 -06:00
3b9b8e0a65 Refactor ESLint configuration by formatting the ignores array for improved readability. 2025-12-29 12:10:40 -06:00
44ccc672fc Update package version to 1.5.0, add author and license information, and update dependencies in package.json. Introduce pnpm-lock.yaml for dependency management. Update service worker cache version to 1.5.0. 2025-12-29 12:10:27 -06:00
625dcc11f8 Fix formatting in svelte.config.js by adding a missing comma and ensuring proper structure. 2025-12-29 11:33:57 -06:00
62f3f34e10 Update frontend package manager from npm to pnpm in wails.json 2025-12-29 11:33:51 -06:00
aad59ffe43 Fix indentation in renovate.json schema declaration 2025-12-29 11:33:42 -06:00
4b20bf540e Update Docker workflow 2025-12-29 11:33:36 -06:00
dd5e24ae26 Remove 2025-12-29 11:33:21 -06:00
bc20b06fe1 Remove package-lock 2025-12-29 11:33:11 -06:00
e7801735fa Update Go version to 1.25.5 2025-12-29 11:33:03 -06:00
fbbb6a5e9c Update LICENSE 2025-12-29 11:32:46 -06:00
ebdbd02599 Move Dockerfile 2025-12-29 11:32:19 -06:00
312fe5e746 Replace Makefile with Taskfile 2025-12-29 11:31:58 -06:00
469b59a561 Add IndexedDB support for graph and settings storage
- Implemented functions to initialize and interact with IndexedDB for storing graph data and user settings.
- Migrated existing localStorage data to IndexedDB on application load.
- Updated save and load functions to utilize IndexedDB instead of localStorage.
- Enhanced graph rendering with curved paths for links between nodes.
- Improved theme management by saving user preferences in IndexedDB.
2025-12-29 11:30:49 -06:00
bc5b12b23c Update workflow hashes
All checks were successful
CI / build-frontend (push) Successful in 38s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m26s
CI / build-backend (push) Successful in 9m33s
2025-12-29 00:03:21 -06:00
5f5de2272b Update Gitea workflows to use custom action URLs for checkout, setup-node, setup-go, and other actions
All checks were successful
CI / build-frontend (push) Successful in 53s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 31s
CI / build-backend (push) Successful in 20s
2025-12-28 21:08:07 -06:00
7b4598c5ca Update Gitea workflow to use custom action URLs for checkout and setup-go 2025-12-28 21:07:46 -06:00
ab2dd4e4ed Remove renovate workflow 2025-12-28 21:07:41 -06:00
ivan
e366ba8f1b Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11' (#5) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
renovate / renovate (push) Failing after 10s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 29s
CI / build-frontend (push) Successful in 9m37s
CI / build-backend (push) Successful in 21s
Reviewed-on: #5
2025-12-29 00:21:44 +00:00
Renovate Bot
6971796c00 Update ghcr.io/renovatebot/renovate Docker tag to v42.66.11
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 19s
2025-12-29 00:02:10 +00:00
ivan
d85d960e3d Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v42' (#4) from renovate/ghcr.io-renovatebot-renovate-42.x into master
Some checks failed
renovate / renovate (push) Failing after 1m14s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 10m0s
CI / build-frontend (push) Successful in 10m19s
CI / build-backend (push) Successful in 1m57s
Reviewed-on: #4
2025-12-28 05:30:32 +00:00
Renovate Bot
d99fce9b24 Update ghcr.io/renovatebot/renovate Docker tag to v42
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 18s
2025-12-28 00:00:48 +00:00
ivan
d770914aae Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7' (#2) from renovate/ghcr.io-renovatebot-renovate-37.x into master
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
renovate / renovate (push) Failing after 14s
CI / build-frontend (push) Successful in 9m32s
CI / build-backend (push) Successful in 9m27s
Reviewed-on: #2
2025-12-27 22:44:20 +00:00
Renovate Bot
02d745f9ae Update ghcr.io/renovatebot/renovate Docker tag to v37.440.7
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m45s
2025-12-27 22:29:35 +00:00
ivan
5c7c6c4ca5 Merge pull request 'Configure Renovate' (#1) from renovate/configure into master
Some checks failed
CI / build-frontend (push) Successful in 41s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 18s
CI / build-backend (push) Successful in 24s
renovate / renovate (push) Failing after 19s
Reviewed-on: #1
2025-12-27 20:52:32 +00:00
Renovate Bot
e6bc79ec27 Add renovate.json
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 23s
2025-12-27 20:29:30 +00:00
03f0c71aae Add Renovate workflow for automated dependency management
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 23s
CI / build-frontend (push) Successful in 46s
CI / build-backend (push) Successful in 28s
renovate / renovate (push) Failing after 11s
2025-12-27 14:28:38 -06:00
4923619b51 Update CI workflows and OSV scan script for improved functionality
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 39s
CI / build-backend (push) Successful in 21s
- Updated the 'checkout' action to version 4 for consistency across workflows.
- Added a 'Setup Go' step to initialize the Go environment using the latest action version.
- Modified the OSV scan script to generalize vulnerability reporting, removing specific severity checks for a broader output.
2025-12-27 12:46:16 -06:00
7e75064ae5 Update CI workflows to use specific action versions
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / build-frontend (push) Successful in 53s
CI / build-backend (push) Successful in 29s
- Updated the 'checkout' action to version 4.3.1 across multiple workflows for consistency.
- Updated various actions in the Docker, npm publish, and OSV workflows to their respective latest versions for improved functionality and security.
2025-12-26 21:53:14 -06:00
7c13aee0a7 Update package script to include versioning for build process
- Modified the 'package' script in package.json to set the VITE_APP_VERSION environment variable using the current package version, ensuring the build process incorporates the correct version information.
2025-12-26 21:53:07 -06:00
d5b37ed53f Update Makefile to include new 'package' target
- Added a 'package' target that depends on the 'build' target to streamline the build process.
- Updated the .PHONY section to include the new 'package' target for better organization.
2025-12-26 21:52:59 -06:00
bbc4fd4c32 Fix CI workflow
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 12s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 35s
Publish NPM Package / publish (push) Failing after 21s
Build and Publish Docker Image / build (push) Successful in 10m25s
- Added a step to upload frontend build artifacts after the build process.
- Included a step to download the frontend assets in the backend build job, ensuring the backend has access to the latest frontend build.
2025-12-26 21:29:55 -06:00
06f3e6fa5a Update package version to 1.4.0 and switch Svelte adapter to static
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / build-backend (push) Failing after 1m7s
CI / build-frontend (push) Successful in 1m14s
- Bumped the package version from 1.3.0 to 1.4.0 in both package.json and package-lock.json.
- Replaced '@sveltejs/adapter-node' with '@sveltejs/adapter-static' for improved deployment options.
- Added new scripts for desktop development and building.
2025-12-26 21:22:09 -06:00
548d5dbc35 Add script to inject service worker version from package.json
- Created a new script that reads the version from package.json and injects it into the service worker file (sw.js).
- This ensures the service worker uses the correct version for cache management and updates.
2025-12-26 21:21:21 -06:00
78c07e1c6b Update service worker to use versioning and improve cache management
- Changed CACHE_NAME to include a version number for better cache control.
- Enhanced cache deletion logic to target specific cache names.
- Added message event listener to allow clients to skip waiting for updates.
- Improved fetch handling to ensure offline support and better response caching.
2025-12-26 21:21:12 -06:00
4afe001117 Add Window interface to app.d.ts for file handling and logging
- Extended the Window interface to include a go property with methods for saving and loading files, as well as logging messages from the frontend.
- This enhancement supports improved interaction with the application’s file management features.
2025-12-26 21:21:01 -06:00
826e7d10d1 Add service worker update notifications and reload functionality
- Implemented logic to check for service worker updates and notify users when a new version is available.
- Added buttons to reload the application or dismiss the update notification.
- Enhanced service worker registration to handle updates and online status checks.
2025-12-26 21:20:52 -06:00
4646423f1d Change Svelte adapter from Node to Static
- Updated the Svelte configuration to use '@sveltejs/adapter-static' instead of '@sveltejs/adapter-node'.
- Configured adapter options for output directories and fallback handling.
2025-12-26 21:20:42 -06:00
a3a8e29a6d Update README 2025-12-26 21:20:26 -06:00
a2411bd176 Refactor CI workflow for frontend and backend builds
- Renamed jobs for clarity: 'check' to 'build-frontend' and 'build' to 'build-backend'.
- Updated action versions for checkout and setup-node.
- Added frontend build step and consolidated backend build process with Go setup.
- Enhanced frontend checks and build scripts for improved CI pipeline.
2025-12-26 21:20:19 -06:00
2465e2e42b Add file import/export functionality for Wails desktop and web browser
- Implemented file saving and loading capabilities for the IdentityGraph component, supporting both Wails desktop and web browser environments.
- Enhanced error handling for JSON parsing and validation of graph data format.
- Added logging for application startup and Wails environment detection.
2025-12-26 21:20:11 -06:00
3d81697538 Add wails.json configuration for Linking Tool
- Created wails.json to define project metadata, asset directory, and frontend build commands.
- Included author information for project attribution.
2025-12-26 21:20:03 -06:00
5896cd7064 Add main application and desktop API server implementation
- Introduced main.go for the server with CORS middleware and static asset handling.
- Added desktop/app.go for local API server with logging and file handling capabilities.
- Implemented desktop/main.go to initialize the application with Wails framework and asset management.
2025-12-26 21:19:57 -06:00
9ffce1e12f Update Makefile 2025-12-26 21:19:44 -06:00
b4c65bf30b Add go.mod and go.sum files to manage dependencies for the linking tool 2025-12-26 21:19:30 -06:00
7d2eb81e0f Update ESLint configuration 2025-12-26 21:19:24 -06:00
837c5c471d Update Dockerfile to streamline multi-stage builds for frontend and Go binary 2025-12-26 21:19:14 -06:00
4b9a972706 Update .dockerignore and .gitignore 2025-12-26 21:18:41 -06:00
9dd65dbff8 Update README
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 21s
CI / build (push) Successful in 34s
Publish NPM Package / publish (push) Successful in 44s
Build and Publish Docker Image / build (push) Successful in 8m18s
2025-12-25 16:09:43 -06:00
5eb10386de Refactor header layout in +page.svelte for improved responsiveness and accessibility, including link updates and style adjustments. 2025-12-25 16:07:11 -06:00
c3b0173da3 Remove unnecessary eslint directive from service worker file to streamline code. 2025-12-25 16:07:11 -06:00
0a4a5f7634 Refactor APP_VERSION definition to support multiple sources, including a global variable and npm package version, enhancing flexibility in version management. 2025-12-25 16:07:11 -06:00
ecc1253937 Update IdentityGraph component with link selection and mobile support. 2025-12-25 16:07:10 -06:00
de392d52ea Add linking tool script to set environment variables and import main application module 2025-12-25 16:07:10 -06:00
204dceeff7 Add workflow for publishing NPM packages, including setup for Node.js, dependency installation, packaging, and publishing to a custom registry. 2025-12-25 16:07:10 -06:00
410448b35d Add GitHub Actions workflow for building and publishing Docker images, including setup for QEMU, Docker Buildx, and metadata extraction. 2025-12-25 16:07:10 -06:00
119177d64c Add app version definition in Vite configuration using environment variable or package version 2025-12-25 16:07:10 -06:00
df9ed9465b Add mobile landscape screen breakpoint to Tailwind configuration for responsive design 2025-12-25 16:07:10 -06:00
c2de35082f Update Svelte configuration to use node adapter instead of auto adapter for improved environment compatibility. 2025-12-25 16:07:10 -06:00
bc63b4a42c Update package version to 1.3.0, rename package to @quad4/linking-tool, and add new fields for main entry, binary, and engines in package.json. Enhance package-lock.json with additional dependencies and metadata. 2025-12-25 16:07:09 -06:00
8d4e8cde81 Update Makefile with Docker support and additional npm commands for packaging and publishing 2025-12-25 16:07:09 -06:00
c9053cb0c6 Update ESLint configuration 2025-12-25 16:07:09 -06:00
8d2d520122 Add npm registry configuration and authentication token to .npmrc for package management 2025-12-25 16:07:09 -06:00
ivan
d6d8e8240f Update README.md
All checks were successful
CI / check (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build (push) Successful in 30s
2025-12-25 04:59:48 +00:00
ivan
a16e96355f Upload files to "showcase"
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 20s
CI / build (push) Successful in 35s
2025-12-25 04:58:56 +00:00
ea21931650 Update meta tags in +page.svelte for improved clarity and consistency in descriptions and titles.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / check (push) Successful in 19s
CI / build (push) Successful in 32s
2025-12-24 22:09:54 -06:00
57 changed files with 10054 additions and 5667 deletions

View File

@@ -1,14 +1,32 @@
node_modules
# Dependencies
node_modules/
vendor/
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# SvelteKit & Vite
.svelte-kit/
build/
dist/
.output/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OS
# Go & Binaries
bin/
linking-tool
tmp/
# Wails Desktop
desktop/frontend_dist/
desktop/build/
wailsjs/
# Git
.git
.gitignore
# IDE & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
@@ -16,8 +34,3 @@ Thumbs.db
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

122
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,122 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0)'
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
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
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- 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: 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
task build-linux-amd64
task build-windows-amd64
- name: Build desktop Linux
run: task desktop-linux
- 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
cp bin/linking-tool-linux-amd64 release-assets/
cp bin/linking-tool-windows-amd64.exe release-assets/
if [ -f desktop/build/bin/linking-tool ]; then
cp desktop/build/bin/linking-tool release-assets/linking-tool-desktop-linux-amd64
fi
if [ -f desktop/build/bin/linking-tool.exe ]; then
cp desktop/build/bin/linking-tool.exe release-assets/linking-tool-desktop-windows-amd64.exe
fi
- name: Create Release
uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74 # v1
with:
api_url: ${{ secrets.GITEA_API_URL }}
gitea_token: ${{ secrets.GITEA_TOKEN }}
title: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.version }}
body: |
Release ${{ steps.version.outputs.version }}
## Assets
- Server binaries (Linux AMD64, Windows AMD64)
- Desktop applications (Linux AMD64, Windows AMD64)
- SBOM files
files: release-assets/*
draft: false
prerelease: false

View File

@@ -7,33 +7,74 @@ on:
workflow_dispatch:
jobs:
check:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache: pnpm
- 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: npm ci
- name: Svelte check (fail on warnings)
run: bash scripts/check.sh
run: task install:ci
- name: Lint
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
build:
- 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:
name: frontend-build
path: build/
scan-backend:
runs-on: ubuntu-latest
needs: check
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: bash scripts/build.sh
go-version: '1.25.5'
- name: Run gosec security scan
uses: https://git.quad4.io/actions/gosec@424fc4cd9c82ea0fd6bee9cd49c2db2c3cc0c93f # v2.22.11
build-backend:
runs-on: ubuntu-latest
needs: [build-frontend, scan-backend]
steps:
- name: Checkout
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:
name: frontend-build
path: build/
- 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: Build backend
run: task build:backend

View File

@@ -0,0 +1,78 @@
name: Build and Publish Docker Image
on:
workflow_dispatch:
push:
tags:
- 'v*'
env:
REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/linking-tool
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_digest: ${{ steps.build.outputs.digest }}
image_tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
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
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to the Container registry
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
id: build
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
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,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan
run: bash scripts/osv_scan.sh

View File

@@ -14,7 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: 'go.mod'
- name: OSV scan
run: bash scripts/osv_scan.sh

40
.gitignore vendored
View File

@@ -1,23 +1,35 @@
node_modules
# Dependencies
node_modules/
vendor/
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# SvelteKit & Vite
.svelte-kit/
build/
dist/
.output/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OS
# Go & Binaries
bin/
linking-tool
tmp/
# Wails Desktop
desktop/frontend_dist/
desktop/build/bin/
desktop/build/
wailsjs/
# IDE & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
*.swp
*.swo
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

3
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@quad4:registry=https://git.quad4.io/api/packages/quad4-software/npm/
//git.quad4.io/api/packages/quad4-software/npm/:_authToken=${NPM_TOKEN}

128
CHANGELOG.md Normal file
View File

@@ -0,0 +1,128 @@
# 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
- Added HOST environment variable support for configuring server host binding
### Security
- Fixed unhandled error in HTTP response writing (G104)
- Fixed file write permissions to use more restrictive 0600 instead of 0644 (G306)
- Fixed potential file inclusion vulnerability by adding path validation in file operations (G304)
### Docker
- Added HOST environment variable to Dockerfile (defaults to 0.0.0.0, make sure to set it properly in production)
## 1.5.0 - 2025-12-29
### Features
- Move to IndexedDB for saving graph data (from localStorage)
- Add multiple links support between nodes.
- Increase undo/redo history to 100 steps.
- Move undo/redo operations to IndexedDB instead of memory.
- Mass selection improvements (moving and linking multiple nodes at once).
- Codebase refactor to use Svelte 5 Runes.
- Mobile improvements
- Added SBOM generation as release assets
### Dependency Updates
- `@sveltejs/kit`: ^2.49.1 -> ^2.49.2
- `@typescript-eslint/eslint-plugin`: ^8.50.1 -> ^8.51.0
- `@typescript-eslint/parser`: ^8.50.1 -> ^8.51.0
- `svelte`: ^5.45.6 -> ^5.46.1
- `svelte-check`: ^4.3.4 -> ^4.3.5
- `vite`: ^7.2.6 -> ^7.3.0
- Added `eslint-plugin-security`: ^3.0.1
### Major Codebase Changes
- Moved from `npm` to `pnpm`
- Updated license from `MIT` to `BSD-3-Clause`
- Moved from `Makefile` to `Taskfile`
- Codebase organization and structure changes
### CI/CD Updates
- Updated CI workflows to use `task` commands instead of bash scripts
- Added gosec security scanning to backend build pipeline
### Security
- Overrode `cookie` package to latest version (1.1.1) due to low severity vulnerability in default version.

View File

@@ -1,33 +0,0 @@
FROM cgr.dev/chainguard/node:latest-dev AS builder
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
RUN npm install --save-dev @sveltejs/adapter-node@latest
COPY --chown=node:node . .
COPY --chown=node:node svelte.config.docker.js svelte.config.js
RUN npm run build
FROM cgr.dev/chainguard/node:latest AS runtime
WORKDIR /app
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev && \
npm cache clean --force
COPY --from=builder --chown=node:node /app/build ./build
COPY --from=builder --chown=node:node /app/package.json ./
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
CMD ["build/index.js"]

38
LICENSE
View File

@@ -1,22 +1,28 @@
MIT License
BSD 3-Clause License
Copyright (c) 2025 Quad4.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,32 +0,0 @@
.PHONY: help install dev build preview check lint format clean
help:
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install:
npm install
dev:
npm run dev
build:
npm run build
preview:
npm run preview
check:
npm run check
lint:
npm run lint
format:
npm run format
clean:
rm -rf .svelte-kit build node_modules/.vite

136
README.md
View File

@@ -1,42 +1,142 @@
# Quad4 Linking Tool
A client-side web linking tool for mapping relationships between entities.
A web linking tool for mapping relationships between entities.
<img src="showcase/linkingtool.png" alt="showcase image" width="900">
Desktop apps for Windows, macOS, and Linux are coming soon...
## Quick Start
### Using the Binary
1. Build the binary:
```sh
task build
```
2. Run the server:
```sh
./bin/linking-tool --port 8080
```
3. Open your browser at `http://localhost:8080`
### Using Docker
```sh
docker run -p 8080:8080 git.quad4.io/quad4-software/linking-tool
```
Then open your browser at `http://localhost:8080`
## Features
- Interactive graph visualization
- Multiple entity types (person, email, phone, address, domain, org, IP, social)
- Auto-save to localStorage
- Auto-save to IndexedDB
- Import/Export JSON
- Share link via base64 for smaller graphs
- Undo/Redo support
- PWA support (installable, offline-capable)
- Self-hostable
- Native desktop app support (via Wails)
- Single-binary lightweight web server
- Support for 32-bit and 64-bit architectures (runs on old Raspberry Pi Zero W)
## Installation Options
### Self-Hosted Web Server
The easiest way to self-host is using the single binary:
```sh
task build
./bin/linking-tool --port 8080
```
The binary will be located in `bin/` after building.
### Desktop Application
Build the desktop application for your platform:
```sh
task desktop-build
```
The binary will be located in `bin/` after building.
## Development
### NPM
### Prerequisites
- Go `1.25.5`
- Node.js
- pnpm
- Wails (for desktop app development)
### Setup
```sh
npm install
npm run dev
git clone https://git.quad4.io/quad4-software/linking-tool.git
cd linking-tool
pnpm install
```
### Makefile
### Task
```sh
make dev
The project uses [Task](https://taskfile.dev/) for all development tasks.
```
| Task | Description |
|---------------------|-------------------------------------------|
| default | Show available tasks |
| dev | Run development servers (Go & SvelteKit) |
| build | Build the single binary web server |
| build:frontend | Build frontend only |
| build:backend | Build backend binary only |
| package | Package the application |
| release | Build binaries for all platforms |
| build-linux-amd64 | Build Linux AMD64 binary |
| build-linux-arm64 | Build Linux ARM64 binary |
| build-linux-armv6 | Build Linux ARMv6 binary |
| build-linux-armv7 | Build Linux ARMv7 binary |
| build-windows-amd64 | Build Windows AMD64 binary |
| build-darwin-amd64 | Build Darwin AMD64 binary |
| build-darwin-arm64 | Build Darwin ARM64 binary |
| build-freebsd-amd64 | Build FreeBSD AMD64 binary |
| docker-build | Build Docker image |
| docker-run | Run Docker container |
| docker-builder | Build and extract binaries using Docker |
| podman-build | Build Podman image |
| podman-run | Run Podman container |
| podman | Build and run Podman container |
| desktop-build | Build desktop application |
| desktop-linux | Build desktop application for Linux |
| desktop-windows | Build desktop application for Windows |
| desktop-darwin | Build desktop application for Darwin |
| desktop-dev | Run desktop app in development mode |
| clean | Clean build artifacts |
| setup | Setup development environment |
| install | Install dependencies |
| install:ci | Install dependencies for CI (frozen lock) |
| preview | Preview production build |
| check | Run type checking |
| lint | Run linter |
| format | Format code |
| version:minor | Bump minor version in package.json |
| version:major | Bump major version in package.json |
example: task dev
you might to set alias alias task=`go-task`
```
## Docker
## Contributing
Uses Chainguard Images which are rootless and very minimal images.
Send us an email at [team@quad4.io](mailto:team@quad4.io) for any issues or feedback.
```sh
docker build -t quad4-linking-tool .
docker run -p 3000:3000 quad4-linking-tool
```
## License
## LICENSE
[MIT](LICENSE)
[BSD 3-Clause](LICENSE)

17
SECURITY.md Normal file
View File

@@ -0,0 +1,17 @@
# Security Policy
If you have discovered a security vulnerability, please refer to [our website](https://quad4.io/security) for the latest security reporting procedures and guidelines.
## Vulnerability Management
- We use PNPM and [OSV](https://osv.dev/) to scan for package vulnerabilities in our dependencies.
## SAST
- Gosec for Go code.
- ESLint with eslint-plugin-security for JavaScript code.
## Dependency and Supply Chain
- All GitHub Actions used are forked and hosted on our Gitea instance, view them here https://git.quad4.io/actions.
- Actions are referenced using full URLs and cryptographically pinned to specific commit hashes for enhanced supply chain security.

251
Taskfile.yml Normal file
View File

@@ -0,0 +1,251 @@
version: '3'
vars:
BINARY_NAME: linking-tool
BUILD_DIR: bin
tasks:
default:
desc: Show available tasks
cmds:
- task --list
dev:
desc: Run development servers (Go & SvelteKit)
cmds:
- pnpm install
- pnpm run dev
build:frontend:
desc: Build frontend only
cmds:
- |
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
cmds:
- mkdir -p {{.BUILD_DIR}}
- CGO_ENABLED=0 go build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} main.go
build:
desc: Build the single binary web server
cmds:
- pnpm install
- |
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
package:
desc: Package the application
deps: [build]
release:
desc: Build binaries for all platforms
deps: [build]
cmds:
- task: build-linux-amd64
- task: build-linux-arm64
- task: build-linux-armv6
- task: build-linux-armv7
- task: build-windows-amd64
- task: build-darwin-amd64
- task: build-darwin-arm64
- task: build-freebsd-amd64
build-linux-amd64:
desc: Build Linux AMD64 binary
cmds:
- GOOS=linux GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 main.go
build-linux-arm64:
desc: Build Linux ARM64 binary
cmds:
- GOOS=linux GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm64 main.go
build-linux-armv6:
desc: Build Linux ARMv6 binary
cmds:
- GOOS=linux GOARCH=arm GOARM=6 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-armv6 main.go
build-linux-armv7:
desc: Build Linux ARMv7 binary
cmds:
- GOOS=linux GOARCH=arm GOARM=7 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-armv7 main.go
build-windows-amd64:
desc: Build Windows AMD64 binary
cmds:
- GOOS=windows GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe main.go
build-darwin-amd64:
desc: Build Darwin AMD64 binary
cmds:
- GOOS=darwin GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 main.go
build-darwin-arm64:
desc: Build Darwin ARM64 binary
cmds:
- GOOS=darwin GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 main.go
build-freebsd-amd64:
desc: Build FreeBSD AMD64 binary
cmds:
- GOOS=freebsd GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-amd64 main.go
docker-build:
desc: Build Docker image (VITE_APP_VERSION env var will be passed as build arg if set)
cmds:
- |
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
cmds:
- docker run -p 8080:8080 {{.BINARY_NAME}}
docker-builder:
desc: Build and extract binaries using Docker
cmds:
- docker build -f docker/Dockerfile.build -t {{.BINARY_NAME}}-build .
- docker create --name {{.BINARY_NAME}}-temp {{.BINARY_NAME}}-build
- mkdir -p {{.BUILD_DIR}}
- docker cp {{.BINARY_NAME}}-temp:/bin/. {{.BUILD_DIR}}/
- docker cp {{.BINARY_NAME}}-temp:/desktop-bin/. {{.BUILD_DIR}}/
- docker rm {{.BINARY_NAME}}-temp
podman-build:
desc: Build Podman image
cmds:
- podman build -f docker/Dockerfile -t surveilled .
podman-run:
desc: Run Podman container
cmds:
- podman run --rm -p 3000:3000 surveilled
podman:
desc: Build and run Podman container
deps: [podman-build, podman-run]
desktop-build:
desc: Build desktop application
deps: [build]
cmds:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -tags webkit2_41
desktop-linux:
desc: Build desktop application for Linux
deps: [build]
cmds:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -platform linux/amd64 -tags webkit2_41
desktop-windows:
desc: Build desktop application for Windows
deps: [build]
cmds:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -platform windows/amd64
desktop-darwin:
desc: Build desktop application for Darwin
deps: [build]
cmds:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -platform darwin/universal
desktop-dev:
desc: Run desktop application in development mode
deps: [build]
cmds:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails dev
clean:
desc: Clean build artifacts
cmds:
- rm -rf .svelte-kit build node_modules node_modules/.vite dist package linking-tool tmp {{.BUILD_DIR}}
setup:
desc: Setup development environment
cmds:
- corepack enable
install:
desc: Install dependencies
cmds:
- pnpm install
install:ci:
desc: Install dependencies for CI (frozen lockfile)
cmds:
- pnpm install --frozen-lockfile
preview:
desc: Preview production build
cmds:
- pnpm run preview
check:
desc: Run type checking
cmds:
- pnpm run check
lint:
desc: Run linter
cmds:
- pnpm run lint
format:
desc: Format code
cmds:
- pnpm run format
version:minor:
desc: Bump minor version in package.json
cmds:
- pnpm version minor --no-git-tag-version
version:major:
desc: Bump major version in package.json
cmds:
- pnpm version major --no-git-tag-version

165
desktop/app.go Normal file
View File

@@ -0,0 +1,165 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App struct
type App struct {
ctx context.Context
port int
debug bool
}
// NewApp creates a new App struct
func NewApp(debug bool) *App {
return &App{
debug: debug,
}
}
func (a *App) logDebug(format string, args ...any) {
if a != nil && a.debug {
fmt.Printf("[debug] "+format+"\n", args...)
}
}
// logHandler wraps HTTP handlers to log requests when debug is enabled.
func (a *App) logHandler(next http.Handler) http.Handler {
if !a.debug {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("[debug] http %s %s %dms\n", r.Method, r.URL.Path, time.Since(start).Milliseconds())
})
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.logDebug("startup begin")
// Start local API server on a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Printf("Error starting local server: %v\n", err)
return
}
a.port = listener.Addr().(*net.TCPAddr).Port
a.logDebug("local API listener bound on %s", listener.Addr().String())
mux := http.NewServeMux()
// CORS middleware for local desktop API
cors := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
}
}
mux.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok"}`)
}))
server := &http.Server{
Addr: listener.Addr().String(),
Handler: a.logHandler(mux),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
fmt.Printf("Error serving desktop API: %v\n", err)
}
}()
fmt.Printf("Desktop API server started on port %d\n", a.port)
a.logDebug("startup complete")
}
// GetAPIPort returns the port the local server is running on
func (a *App) GetAPIPort() int {
a.logDebug("GetAPIPort -> %d", a.port)
return a.port
}
// LogFrontend allows the frontend to log to the terminal
func (a *App) LogFrontend(message string) {
fmt.Printf("[frontend] %s\n", message)
}
// SaveFile shows a save dialog and writes the content to the selected file
func (a *App) SaveFile(filename string, content string) error {
a.logDebug("SaveFile filename=%s", filename)
filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: filename,
Title: "Save Graph",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return err
}
if filePath == "" {
return nil // Cancelled
}
return os.WriteFile(filePath, []byte(content), 0600)
}
// LoadFile shows an open dialog and returns the content of the selected file
func (a *App) LoadFile() (string, error) {
a.logDebug("LoadFile")
filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Open Graph",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return "", err
}
if filePath == "" {
return "", nil // Cancelled
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", fmt.Errorf("invalid file path: %w", err)
}
cleanPath := filepath.Clean(absPath)
content, err := os.ReadFile(cleanPath)
if err != nil {
return "", err
}
return string(content), nil
}

52
desktop/main.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"embed"
"os"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend_dist
var assets embed.FS
func debugEnabled() bool {
for _, arg := range os.Args[1:] {
if arg == "--debug" || arg == "-d" {
return true
}
}
return false
}
func main() {
debug := debugEnabled()
if debug {
println("Debug logging enabled")
}
// Create an instance of the app structure
app := NewApp(debug)
// Create application with options
err := wails.Run(&options.App{
Title: "Linking Tool",
Width: 1280,
Height: 800,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
EnableDefaultContextMenu: true,
})
if err != nil {
println("Error:", err.Error())
}
}

14
desktop/wails.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "Linking Tool",
"assetdir": "frontend_dist",
"frontend:dir": "..",
"frontend:install": "pnpm install",
"frontend:build": "pnpm run build",
"frontend:dev:watcher": "pnpm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "linking-tool",
"author": {
"name": "Quad4",
"email": "dev@quad4.io"
}
}

36
docker/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# 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
USER node
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node . .
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
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=node-builder /app/build ./build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o linking-tool main.go
# Stage 3: Minimal runtime image
FROM cgr.dev/chainguard/wolfi-base:latest
WORKDIR /app
COPY --from=go-builder /app/linking-tool .
RUN apk add --no-cache ca-certificates
EXPOSE 8080
ENV PORT=8080
ENV HOST=0.0.0.0
ENV NODE_ENV=production
USER 65532
CMD ["./linking-tool"]

View File

@@ -1,6 +1,7 @@
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import securityPlugin from 'eslint-plugin-security';
import sveltePlugin from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
@@ -19,6 +20,8 @@ export default [
caches: 'readonly',
URL: 'readonly',
console: 'readonly',
Element: 'readonly',
EventListener: 'readonly',
HTMLElement: 'readonly',
HTMLImageElement: 'readonly',
HTMLInputElement: 'readonly',
@@ -32,12 +35,28 @@ export default [
Blob: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',
TouchEvent: 'readonly',
Touch: 'readonly',
WheelEvent: 'readonly',
KeyboardEvent: 'readonly',
URLSearchParams: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
Response: 'readonly',
Request: 'readonly',
Headers: 'readonly',
FormData: 'readonly',
ServiceWorkerRegistration: 'readonly',
location: 'readonly',
history: 'readonly',
addEventListener: 'readonly',
removeEventListener: 'readonly',
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
atob: 'readonly',
btoa: 'readonly',
alert: 'readonly',
@@ -45,14 +64,25 @@ 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: {
'@typescript-eslint': tsPlugin,
security: securityPlugin,
svelte: sveltePlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
...securityPlugin.configs.recommended.rules,
},
},
{
@@ -71,6 +101,36 @@ export default [
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**'],
files: ['bin/**/*.js'],
languageOptions: {
globals: {
process: 'readonly',
},
},
},
{
files: ['static/sw.js'],
languageOptions: {
globals: {
self: 'readonly',
caches: 'readonly',
fetch: 'readonly',
URL: 'readonly',
console: 'readonly',
Response: 'readonly',
Request: 'readonly',
},
},
},
{
ignores: [
'node_modules/**',
'.svelte-kit/**',
'build/**',
'dist/**',
'archive/**',
'desktop/frontend_dist/**',
'wailsjs/**',
],
},
];

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766902085,
"narHash": "sha256-coBu0ONtFzlwwVBzmjacUQwj3G+lybcZ1oeNSQkgC0M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0b0e0fddf73fd517c3471e546c0df87a42d53f4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

94
flake.nix Normal file
View File

@@ -0,0 +1,94 @@
{
description = "Quad4 Linking Tool development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
go = pkgs.go_1_25;
task = pkgs.buildGoModule rec {
pname = "task";
version = "3.46.3";
src = pkgs.fetchFromGitHub {
owner = "go-task";
repo = "task";
rev = "v${version}";
hash = "sha256-1bS8ZZAcemgRG7PTeGTFfd49T9u6U6CxxrbotwCM15A=";
};
vendorHash = "sha256-Tm0tqureCRwcP5KKDTa9TO1yZ3Px3ulf9/jKQDDTjDw=";
subPackages = [ "cmd/task" ];
doCheck = false;
meta = with pkgs.lib; {
description = "A task runner / simpler Make alternative written in Go";
homepage = "https://taskfile.dev/";
license = licenses.mit;
maintainers = with maintainers; [ ];
};
};
wailsSrc = pkgs.fetchFromGitHub {
owner = "wailsapp";
repo = "wails";
rev = "v2.11.0";
hash = "sha256-H1Nml2vhCx4IB/CT+kDro5joAw8ewpxoQjDgvqamAr8=";
};
wails = pkgs.buildGoModule rec {
pname = "wails";
version = "2.11.0";
src = pkgs.runCommand "${pname}-${version}-src" {} ''
cp -r ${wailsSrc}/v2 $out
chmod -R +w $out
'';
vendorHash = "sha256-HAIKhMKRTNI4hsm8Hvn5pUhnCTcitRxiw+WkVmxpfiU=";
subPackages = [ "cmd/wails" ];
doCheck = false;
meta = with pkgs.lib; {
description = "Build applications using Go + HTML + CSS + JS";
homepage = "https://wails.io/";
license = licenses.mit;
maintainers = with maintainers; [ ];
};
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
task
nodejs_20
nodePackages.pnpm
wails
gcc
pkg-config
];
shellHook = ''
echo "Quad4 Linking Tool Development Environment"
echo "Go version: $(go version)"
echo "Task version: $(task --version 2>/dev/null || echo 'installed')"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
echo "Wails version: $(wails version 2>/dev/null || echo 'installed')"
'';
};
});
}

35
go.mod Normal file
View File

@@ -0,0 +1,35 @@
module git.quad4.io/Quad4-Software/linking-tool
go 1.25.5
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

81
go.sum Normal file
View File

@@ -0,0 +1,81 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

146
main.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"embed"
"flag"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)
//go:embed build/*
var buildAssets embed.FS
func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
allowed := false
if len(allowedOrigins) == 0 {
allowed = true
} else {
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
if allowed {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusForbidden)
}
return
}
if !allowed && len(allowedOrigins) > 0 {
log.Printf("Blocked CORS request from origin: %s", origin)
http.Error(w, "CORS Origin Not Allowed", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
}
}
func main() {
frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)")
host := flag.String("host", "127.0.0.1", "Host to bind the server to")
port := flag.String("port", "", "Port to listen on (overrides PORT env var)")
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
flag.Parse()
var allowedOrigins []string
if *allowedOriginsStr != "" {
origins := strings.Split(*allowedOriginsStr, ",")
for _, o := range origins {
allowedOrigins = append(allowedOrigins, strings.TrimSpace(o))
}
}
if hostEnv := os.Getenv("HOST"); hostEnv != "" {
*host = hostEnv
}
if *port == "" {
*port = os.Getenv("PORT")
if *port == "" {
*port = "8080"
}
}
// Middleware chains
cors := corsMiddleware(allowedOrigins)
http.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
log.Printf("Error writing response: %v", err)
}
}))
// Static Assets
var staticFS fs.FS
if *frontendPath != "" {
log.Printf("Using custom frontend from: %s\n", *frontendPath)
staticFS = os.DirFS(*frontendPath)
} else {
sub, err := fs.Sub(buildAssets, "build")
if err != nil {
log.Fatal(err)
}
staticFS = sub
}
fileServer := http.FileServer(http.FS(staticFS))
// SPA Handler
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
_, err := staticFS.Open(path)
if err != nil {
// If file doesn't exist, serve index.html for SPA routing
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
addr := net.JoinHostPort(*host, *port)
log.Printf("Linking Tool server starting on %s...\n", addr)
server := &http.Server{
Addr: addr,
Handler: nil,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}

4125
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,61 @@
{
"name": "quad4-linking-tool",
"private": true,
"version": "1.2.1",
"name": "@quad4/linking-tool",
"version": "1.6.0",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
"main": "./build/index.js",
"bin": {
"linking-tool": "./bin/linking-tool.js"
},
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@10.25.0",
"publishConfig": {
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
},
"pnpm": {
"overrides": {
"cookie": "1.1.1"
}
},
"files": [
"build/**/*",
"bin/**/*",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"dev": "VITE_APP_VERSION=dev vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"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",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"package": "svelte-kit sync && VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"desktop:dev": "task desktop-dev",
"desktop:build": "task desktop-build"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"eslint": "^9.39.2",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-svelte": "^3.13.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"svelte-eslint-parser": "^1.4.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
"vite": "^7.3.0"
},
"dependencies": {
"autoprefixer": "^10.4.23",

3383
pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -2,5 +2,5 @@
set -euo pipefail
echo "Building app..."
VITE_APP_VERSION=$(node -p "require('./package.json').version") npm run build
VITE_APP_VERSION=$(node -p "require('./package.json').version") pnpm run build

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'));
const version = packageJson.version;
const swPath = join(rootDir, 'static', 'sw.js');
let swContent = readFileSync(swPath, 'utf-8');
swContent = swContent.replace(
/const CACHE_VERSION = ['"](.*?)['"];/,
`const CACHE_VERSION = '${version}';`
);
writeFileSync(swPath, swContent);
console.log(`Injected version ${version} into service worker`);

View File

@@ -23,20 +23,16 @@ VULNS=$(jq -r '
.results[]? |
.source as $src |
.vulns[]? |
select(
(.database_specific.severity // "" | ascii_upcase | test("HIGH|CRITICAL")) or
(.severity[]?.score // "" | tostring | split("/")[0] | tonumber? // 0 | . >= 7.0)
) |
"\(.id) (source: \($src))"
' "$OSV_JSON")
if [ -n "$VULNS" ]; then
echo "OSV scan found HIGH/CRITICAL vulnerabilities:"
echo "OSV scan found vulnerabilities:"
echo "$VULNS" | while IFS= read -r line; do
echo " - $line"
done
exit 1
else
echo "OSV scan: no HIGH/CRITICAL vulnerabilities found."
echo "OSV scan: no vulnerabilities found."
fi

BIN
showcase/linkingtool.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

13
src/app.d.ts vendored
View File

@@ -8,6 +8,19 @@ declare global {
// interface PageState {}
// interface Platform {}
}
interface Window {
go?: {
main: {
App: {
SaveFile(filename: string, content: string): Promise<string>;
LoadFile(): Promise<string>;
LogFrontend(message: string): void;
};
};
};
runtime?: unknown;
}
}
export {};

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>

84
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,84 @@
import { User, Mail, Phone, MapPin, Globe, Building2, Network, AtSign } from 'lucide-svelte';
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';
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'
| 'Knows'
| 'Owns'
| 'Associated With'
| 'Related To'
| 'Connected To';
export type RelationshipStrength = 'weak' | 'medium' | 'strong';
export const RELATIONSHIP_COLORS: Record<RelationshipType, string> = {
Linked: '#525252',
'Works For': '#3b82f6',
Knows: '#10b981',
Owns: '#f59e0b',
'Associated With': '#8b5cf6',
'Related To': '#ec4899',
'Connected To': '#06b6d4',
};
export const relationshipTypes: RelationshipType[] = [
'Linked',
'Works For',
'Knows',
'Owns',
'Associated With',
'Related To',
'Connected To',
];
export const relationshipStrengths: RelationshipStrength[] = ['weak', 'medium', 'strong'];
export type NodeType =
| 'person'
| 'email'
| 'phone'
| 'address'
| 'domain'
| 'org'
| 'ip'
| 'social';
export const iconMap: Record<NodeType, typeof User> = {
person: User,
email: Mail,
phone: Phone,
address: MapPin,
domain: Globe,
org: Building2,
ip: Network,
social: AtSign,
};
export const nodeTypes = Object.keys(iconMap) as NodeType[];
export const typeColors: Record<NodeType, string> = {
person: '#ef4444',
email: '#f97316',
phone: '#eab308',
address: '#10b981',
domain: '#f43f5e',
org: '#be123c',
ip: '#71717a',
social: '#db2777',
};

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);
});
}

View File

@@ -1,36 +0,0 @@
import { User, Mail, Phone, MapPin, Globe, Building2, Network, AtSign } from 'lucide-svelte';
import type { ComponentType } from 'svelte';
export type NodeType =
| 'person'
| 'email'
| 'phone'
| 'address'
| 'domain'
| 'org'
| 'ip'
| 'social';
export const iconMap: Record<NodeType, ComponentType> = {
person: User,
email: Mail,
phone: Phone,
address: MapPin,
domain: Globe,
org: Building2,
ip: Network,
social: AtSign,
};
export const nodeTypes = Object.keys(iconMap) as NodeType[];
export const typeColors: Record<NodeType, string> = {
person: '#ef4444',
email: '#f97316',
phone: '#eab308',
address: '#10b981',
domain: '#f43f5e',
org: '#be123c',
ip: '#71717a',
social: '#db2777',
};

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

@@ -1 +1,20 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev';
declare const __APP_VERSION__: string | undefined;
type ProcessLike = {
env?: Record<string, string | undefined>;
};
const definedVersion =
typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ ? __APP_VERSION__ : undefined;
const processEnv =
typeof globalThis === 'object' && globalThis !== null
? ((globalThis as unknown as { process?: ProcessLike }).process?.env ?? undefined)
: undefined;
const envVersion =
typeof processEnv?.npm_package_version === 'string' && processEnv.npm_package_version
? processEnv.npm_package_version
: undefined;
export const APP_VERSION = definedVersion ?? envVersion ?? 'dev';

View File

@@ -1,13 +1,68 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let showUpdateAvailable = $state(false);
let registration: ServiceWorkerRegistration | null = null;
function checkForUpdates() {
if (registration && navigator.onLine) {
registration.update().catch(() => {});
}
}
function reloadApp() {
if (registration && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
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((registration) => {
console.log('Service Worker registered:', registration);
.then((reg) => {
registration = reg;
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (reg.waiting) {
showUpdateAvailable = true;
} else if (navigator.serviceWorker.controller) {
showUpdateAvailable = true;
}
}
});
}
});
if (reg.waiting) {
showUpdateAvailable = true;
}
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
if (navigator.onLine) {
setInterval(() => {
checkForUpdates();
}, 60000);
}
window.addEventListener('online', () => {
checkForUpdates();
});
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
@@ -16,4 +71,43 @@
});
</script>
<slot />
{#if showUpdateAvailable}
<div
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 bg-neutral-900 border border-neutral-800 rounded-lg shadow-lg p-4 max-w-md mx-4"
>
<div class="flex items-center gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-white">Update Available</p>
<p class="text-xs text-neutral-400 mt-1">A new version is available. Reload to update.</p>
</div>
<button
onclick={reloadApp}
class="px-4 py-2 bg-accent-red text-white rounded-md text-sm font-medium hover:bg-accent-red-dark transition-colors"
>
Reload
</button>
<button
onclick={() => (showUpdateAvailable = false)}
class="text-neutral-400 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
{/if}
{@render children()}

View File

@@ -1,57 +1,25 @@
<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>
<title>Linking Tool - Identity Graph</title>
<meta
name="description"
content="Linking Tool - A client-side identity graph visualization tool for mapping relationships between entities."
content="Linking Tool - A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Linking Tool - Identity Graph" />
<meta property="og:title" content="Linking Tool" />
<meta
property="og:description"
content="A client-side identity graph visualization tool for mapping relationships between entities."
content="A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:image" content="/favicon.svg" />
<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-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0 shadow-lg"
>
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
<div
class="h-5 w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
>
<LinkIcon size={14} class="text-accent-red-light" />
</div>
Linking Tool
</h1>
<div class="text-text-secondary text-xs sm:text-sm flex items-center 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-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,5 +1,5 @@
/* eslint-env serviceworker */
const CACHE_NAME = 'quad4-linking-tool-v1';
const CACHE_VERSION = '1.6.0';
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
self.addEventListener('install', (event) => {
@@ -18,7 +18,7 @@ self.addEventListener('activate', (event) => {
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
if (cacheName !== CACHE_NAME && cacheName.startsWith('quad4-linking-tool-')) {
return caches.delete(cacheName);
}
})
@@ -28,26 +28,40 @@ self.addEventListener('activate', (event) => {
self.clients.claim();
});
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return fetch(event.request)
.then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
})
.catch(() => {
return caches.match('/') || new Response('Offline', { status: 503 });
});
return response;
});
})
);
});

View File

@@ -1,11 +0,0 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View File

@@ -1,17 +1,28 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
compilerOptions: (id) => {
if (id && id.includes('node_modules')) {
return {
runes: false, // Disable runes for external dependencies that might not support it yet (lucide-svelte)
};
}
return {
runes: true,
};
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true,
}),
},
};

View File

@@ -16,6 +16,9 @@ export default {
fontFamily: {
sans: ['Nunito', 'Inter', 'Segoe UI', 'system-ui', '-apple-system', 'sans-serif'],
},
screens: {
'mobile-landscape': { raw: '(max-width: 767px) and (orientation: landscape)' },
},
},
},
plugins: [],

View File

@@ -1,6 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import pkg from './package.json' with { type: 'json' };
declare const process: {
env: Record<string, string | undefined>;
};
const appVersion = process.env.VITE_APP_VERSION ?? pkg.version ?? 'dev';
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
plugins: [sveltekit()],
});