133 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
51 changed files with 9055 additions and 5809 deletions

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

@@ -11,40 +11,70 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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
run: task install:ci
- name: Lint
run: task lint
- name: Frontend checks
run: bash scripts/check.sh
run: task check
- name: Determine version
id: version
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Build frontend
run: bash scripts/build.sh
run: task build:frontend
env:
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
- name: Upload frontend assets
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
name: frontend-build
path: build/
build-backend:
scan-backend:
runs-on: ubuntu-latest
needs: build-frontend
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
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: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: frontend-build
path: build/
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.4'
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: |
mkdir -p bin
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/linking-tool main.go
run: task build:backend

View File

@@ -22,18 +22,30 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
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: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to the Container registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
@@ -41,7 +53,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -54,11 +66,13 @@ jobs:
- name: Build and push Docker image
id: build
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
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

@@ -1,36 +0,0 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --registry=https://registry.npmjs.org/
- name: Package
run: make package
- name: Configure npm for publishing
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

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

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,41 +0,0 @@
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
COPY --chown=node:node . .
RUN npm run build
FROM golang:alpine AS builder
# Install dependencies for Wails on Alpine
# Added webkit2gtk-4.1-dev which is the modern package name in Alpine
RUN apk add --no-cache \
git \
make \
gcc \
musl-dev \
pkgconfig \
gtk+3.0-dev \
webkit2gtk-4.1-dev \
curl
# Install Wails
RUN go install github.com/wailsapp/wails/v2/cmd/wails@latest
ENV PATH=$PATH:/root/go/bin
WORKDIR /app
COPY . .
COPY --from=node-builder /app/build ./build
# Build the Go server
RUN mkdir -p bin && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/linking-tool main.go
# Build desktop apps
RUN mkdir -p desktop/frontend_dist && \
cp -r build/* desktop/frontend_dist/ && \
cd desktop && wails build -s -platform linux/amd64 -o linking-tool-linux
FROM scratch
COPY --from=builder /app/bin /bin
COPY --from=builder /app/desktop/build/bin /desktop-bin

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,92 +0,0 @@
BINARY_NAME=linking-tool
BUILD_DIR=bin
.PHONY: help install dev build preview check lint format clean docker-build docker-run docker-builder release build-linux-amd64 build-linux-arm64 build-linux-armv6 build-linux-armv7 build-windows-amd64 build-darwin-amd64 build-darwin-arm64 build-freebsd-amd64 desktop-build desktop-windows desktop-darwin desktop-dev
help:
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
dev:
npm install
npm run dev
build:
npm install
npm run build
mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) main.go
release: build
mkdir -p $(BUILD_DIR)
@$(MAKE) build-linux-amd64
@$(MAKE) build-linux-arm64
@$(MAKE) build-linux-armv6
@$(MAKE) build-linux-armv7
@$(MAKE) build-windows-amd64
@$(MAKE) build-darwin-amd64
@$(MAKE) build-darwin-arm64
@$(MAKE) build-freebsd-amd64
build-linux-amd64:
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 main.go
build-linux-arm64:
GOOS=linux GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 main.go
build-linux-armv6:
GOOS=linux GOARCH=arm GOARM=6 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv6 main.go
build-linux-armv7:
GOOS=linux GOARCH=arm GOARM=7 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 main.go
build-windows-amd64:
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe main.go
build-darwin-amd64:
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 main.go
build-darwin-arm64:
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 main.go
build-freebsd-amd64:
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
docker-build:
docker build -t $(BINARY_NAME) .
docker-run:
docker run -p 8080:8080 $(BINARY_NAME)
docker-builder:
docker build -f 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
desktop-build: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s
desktop-windows: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s -platform windows/amd64
desktop-darwin: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s -platform darwin/universal
desktop-dev: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails dev
clean:
rm -rf .svelte-kit build node_modules/.vite dist package linking-tool tmp $(BUILD_DIR)

141
README.md
View File

@@ -1,87 +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">
## Features
Desktop apps for Windows, macOS, and Linux are coming soon...
- Interactive graph visualization
- Multiple entity types (person, email, phone, address, domain, org, IP, social)
- Auto-save to localStorage
- Import/Export JSON
- Share link via base64 for smaller graphs
- Undo/Redo support
- PWA support (installable, offline-capable)
- Desktop App support (via Wails)
- Single Binary Web Server (via Go)
- Mobile support
## Quick Start
## Self-Hosting
### Using the Binary
### Go Binary
The easiest way to self-host is using the single binary:
1. Build the binary:
```sh
./linking-tool --port 8080
task build
```
### NPM
2. Run the server:
```sh
npm config set @quad4:registry https://git.quad4.io/api/packages/quad4-software/npm/
npm install -g @quad4/linking-tool
linking-tool
./bin/linking-tool --port 8080
```
### Docker
3. Open your browser at `http://localhost:8080`
### Using Docker
```sh
docker run -p 8080:8080 git.quad4.io/quad4-software/linking-tool
```
## Desktop Application
Then open your browser at `http://localhost:8080`
You can build the desktop application for your platform using Wails:
## Features
- Interactive graph visualization
- Multiple entity types (person, email, phone, address, domain, org, IP, social)
- Auto-save to IndexedDB
- Import/Export JSON
- Share link via base64 for smaller graphs
- Undo/Redo support
- PWA support (installable, offline-capable)
- 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
make desktop-build
task build
./bin/linking-tool --port 8080
```
The binary will be located in `bin/`.
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
### Prerequisites
- Go `1.25.5`
- Node.js
- pnpm
- Wails (for desktop app development)
### Setup
```sh
git clone https://git.quad4.io/quad4-software/linking-tool.git
cd linking-tool
pnpm install
```
### Makefile
### Task
The project uses a Makefile for all common tasks:
The project uses [Task](https://taskfile.dev/) for all development tasks.
```sh
make dev # Run development servers (Go & SvelteKit)
make build # Build the single binary web server
make help # List all available targets
```
| 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 |
### Docker Build & Artifact Extraction
If you don't have the development environment (Go, Node, Wails) installed locally, you can build and extract binaries using Docker:
```sh
make docker-builder
example: task dev
you might to set alias alias task=`go-task`
```
This will build the server and desktop application inside a container and copy the resulting binaries to the `bin/` directory on your host machine.
## Contributing
Send us an email at [team@quad4.io](mailto:team@quad4.io) for any issues or feedback.
Send us an email at [team@quad4.io](mailto:team@quad4.io) for any issues or feedback.
## 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

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env node
process.env.HOST = process.env.HOST || '127.0.0.1';
process.env.PORT = process.env.PORT || '3000';
import '../build/index.js';

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
@@ -128,7 +129,7 @@ func (a *App) SaveFile(filename string, content string) error {
return nil // Cancelled
}
return os.WriteFile(filePath, []byte(content), 0644)
return os.WriteFile(filePath, []byte(content), 0600)
}
// LoadFile shows an open dialog and returns the content of the selected file
@@ -150,10 +151,15 @@ func (a *App) LoadFile() (string, error) {
return "", nil // Cancelled
}
content, err := os.ReadFile(filePath)
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
}

View File

@@ -50,4 +50,3 @@ func main() {
println("Error:", err.Error())
}
}

View File

@@ -1,15 +1,14 @@
{
"name": "Linking Tool",
"assetdir": "frontend_dist",
"frontend:dir": "..",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "linking-tool",
"author": {
"name": "Quad4",
"email": "dev@quad4.io"
}
"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"
}
}

View File

@@ -1,10 +1,14 @@
# Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
ARG VITE_APP_VERSION
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
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 npm run build
RUN VITE_APP_VERSION=${VITE_APP_VERSION} pnpm run build
# Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder
@@ -23,8 +27,10 @@ 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',
@@ -61,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,
},
},
{
@@ -109,6 +123,14 @@ export default [
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**', 'desktop/frontend_dist/**', 'wailsjs/**'],
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')"
'';
};
});
}

2
go.mod
View File

@@ -1,6 +1,6 @@
module git.quad4.io/Quad4-Software/linking-tool
go 1.24
go 1.25.5
require github.com/wailsapp/wails/v2 v2.11.0

11
main.go
View File

@@ -64,7 +64,7 @@ func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.Handler
func main() {
frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)")
host := flag.String("host", "0.0.0.0", "Host to bind the server to")
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")
@@ -78,6 +78,10 @@ func main() {
}
}
if hostEnv := os.Getenv("HOST"); hostEnv != "" {
*host = hostEnv
}
if *port == "" {
*port = os.Getenv("PORT")
if *port == "" {
@@ -90,7 +94,9 @@ func main() {
http.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
log.Printf("Error writing response: %v", err)
}
}))
// Static Assets
@@ -138,4 +144,3 @@ func main() {
log.Fatal(err)
}
}

4131
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
{
"name": "@quad4/linking-tool",
"version": "1.4.0",
"version": "1.6.0",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
"main": "./build/index.js",
"bin": {
@@ -9,9 +11,15 @@
"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/**/*",
@@ -19,34 +27,35 @@
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"dev": "VITE_APP_VERSION=dev vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"build": "VITE_APP_VERSION=${VITE_APP_VERSION:-$(node -p \"require('./package.json').version\")} vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint .",
"package": "svelte-kit sync && vite build",
"desktop:dev": "make desktop-dev",
"desktop:build": "make desktop-build"
"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-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@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",

View File

@@ -1 +0,0 @@
6da4cdcafa6966a9d35d5e1ce48583eb

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

@@ -21,4 +21,3 @@ swContent = swContent.replace(
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

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,14 +1,15 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let showUpdateAvailable = false;
let { children }: { children: Snippet } = $props();
let showUpdateAvailable = $state(false);
let registration: ServiceWorkerRegistration | null = null;
function checkForUpdates() {
if (registration && navigator.onLine) {
registration.update().catch(() => {
});
registration.update().catch(() => {});
}
}
@@ -20,7 +21,11 @@
}
onMount(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:')
) {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
@@ -76,13 +81,13 @@
<p class="text-xs text-neutral-400 mt-1">A new version is available. Reload to update.</p>
</div>
<button
on:click={reloadApp}
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
on:click={() => (showUpdateAvailable = false)}
onclick={() => (showUpdateAvailable = false)}
class="text-neutral-400 hover:text-white transition-colors"
aria-label="Dismiss"
>
@@ -105,4 +110,4 @@
</div>
{/if}
<slot />
{@render children()}

View File

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

View File

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

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

@@ -4,6 +4,16 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
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: adapter({
@@ -11,9 +21,9 @@ const config = {
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
strict: true,
}),
},
};
export default config;