24 Commits

Author SHA1 Message Date
9dd65dbff8 Update README
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 21s
CI / build (push) Successful in 34s
Publish NPM Package / publish (push) Successful in 44s
Build and Publish Docker Image / build (push) Successful in 8m18s
2025-12-25 16:09:43 -06:00
5eb10386de Refactor header layout in +page.svelte for improved responsiveness and accessibility, including link updates and style adjustments. 2025-12-25 16:07:11 -06:00
c3b0173da3 Remove unnecessary eslint directive from service worker file to streamline code. 2025-12-25 16:07:11 -06:00
0a4a5f7634 Refactor APP_VERSION definition to support multiple sources, including a global variable and npm package version, enhancing flexibility in version management. 2025-12-25 16:07:11 -06:00
ecc1253937 Update IdentityGraph component with link selection and mobile support. 2025-12-25 16:07:10 -06:00
de392d52ea Add linking tool script to set environment variables and import main application module 2025-12-25 16:07:10 -06:00
204dceeff7 Add workflow for publishing NPM packages, including setup for Node.js, dependency installation, packaging, and publishing to a custom registry. 2025-12-25 16:07:10 -06:00
410448b35d Add GitHub Actions workflow for building and publishing Docker images, including setup for QEMU, Docker Buildx, and metadata extraction. 2025-12-25 16:07:10 -06:00
119177d64c Add app version definition in Vite configuration using environment variable or package version 2025-12-25 16:07:10 -06:00
df9ed9465b Add mobile landscape screen breakpoint to Tailwind configuration for responsive design 2025-12-25 16:07:10 -06:00
c2de35082f Update Svelte configuration to use node adapter instead of auto adapter for improved environment compatibility. 2025-12-25 16:07:10 -06:00
bc63b4a42c Update package version to 1.3.0, rename package to @quad4/linking-tool, and add new fields for main entry, binary, and engines in package.json. Enhance package-lock.json with additional dependencies and metadata. 2025-12-25 16:07:09 -06:00
8d4e8cde81 Update Makefile with Docker support and additional npm commands for packaging and publishing 2025-12-25 16:07:09 -06:00
c9053cb0c6 Update ESLint configuration 2025-12-25 16:07:09 -06:00
8d2d520122 Add npm registry configuration and authentication token to .npmrc for package management 2025-12-25 16:07:09 -06:00
ivan
d6d8e8240f Update README.md
All checks were successful
CI / check (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build (push) Successful in 30s
2025-12-25 04:59:48 +00:00
ivan
a16e96355f Upload files to "showcase"
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 20s
CI / build (push) Successful in 35s
2025-12-25 04:58:56 +00:00
ea21931650 Update meta tags in +page.svelte for improved clarity and consistency in descriptions and titles.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / check (push) Successful in 19s
CI / build (push) Successful in 32s
2025-12-24 22:09:54 -06:00
90de7a4850 Update version to 1.2.1
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / check (push) Successful in 20s
CI / build (push) Successful in 34s
2025-12-24 21:39:52 -06:00
0c9db82791 Update README 2025-12-24 21:38:41 -06:00
fa4ff7444d Fix link editing functionality in IdentityGraph component by introducing manual editing for relationship types, adding validation for shareable URLs, and improving input handling for relationship type selection. 2025-12-24 21:38:30 -06:00
8d82c160d1 Remove PNG export functionality from IdentityGraph component and associated button in the UI.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / check (push) Successful in 19s
CI / build (push) Successful in 31s
2025-12-24 21:27:36 -06:00
99d94e092d Update version to 1.2.0 in package.json and package-lock.json for release. 2025-12-24 21:27:30 -06:00
003a88dcee Improve data validation in IdentityGraph component by adding checks for decoded data structure and image URL validity. Ensure nodes and links are properly validated before processing.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / check (push) Successful in 34s
CI / build (push) Successful in 32s
2025-12-24 21:24:36 -06:00
17 changed files with 610 additions and 143 deletions

View File

@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
workflow_dispatch:
push:
tags:
- 'v*'
env:
REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/linking-tool
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_digest: ${{ steps.build.outputs.digest }}
image_tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Log in to the Container registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
id: build
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -0,0 +1,36 @@
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 }}

3
.npmrc
View File

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

View File

@@ -1,4 +1,4 @@
.PHONY: help install dev build preview check lint format clean
.PHONY: help install dev build preview check lint format clean docker-build docker-run docker package publish
help:
@echo 'Usage: make [target]'
@@ -6,15 +6,19 @@ help:
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install:
npm install
dev:
npm install
npm run dev
build:
npm run build
package:
npm run package
publish:
npm publish
preview:
npm run preview
@@ -28,5 +32,13 @@ format:
npm run format
clean:
rm -rf .svelte-kit build node_modules/.vite
rm -rf .svelte-kit build node_modules/.vite dist package
docker-build:
docker build -t linking-tool .
docker-run:
docker run --rm -p 3000:3000 linking-tool
docker: docker-build docker-run

View File

@@ -1,6 +1,8 @@
# Quad4 Linking Tool
A client-side identity graph visualization tool for mapping relationships between entities.
A client-side web linking tool for mapping relationships between entities.
<img src="showcase/linkingtool.png" alt="showcase image" width="900">
## Features
@@ -8,14 +10,45 @@ A client-side identity graph visualization tool for mapping relationships betwee
- Multiple entity types (person, email, phone, address, domain, org, IP, social)
- Auto-save to localStorage
- Import/Export JSON
- Export PNG snapshots
- Share link via base64 for smaller graphs
- Undo/Redo support
- Pan and zoom controls
- PWA support (installable, offline-capable)
- Self-hostable
- Mobile support
## Self-Hosting
### NPM
```sh
npm config set @quad4:registry https://git.quad4.io/api/packages/quad4-software/npm/
npm install -g @quad4/linking-tool
linking-tool
```
Or
```sh
PORT=3000 HOST=0.0.0.0 linking-tool
```
### Docker
```sh
docker run -p 3000:3000 git.quad4.io/quad4-software/linking-tool
```
### Podman
```sh
podman run -p 3000:3000 git.quad4.io/quad4-software/linking-tool
```
## Development
```sh
git clone https://git.quad4.io/quad4-software/linking-tool.git
cd linking-tool
```
### NPM
```sh
@@ -38,6 +71,10 @@ docker build -t quad4-linking-tool .
docker run -p 3000:3000 quad4-linking-tool
```
## Contributing
Send us a email at[team@quad4.io](mailto:team@quad4.io) for any issues or feedback.
## LICENSE
[MIT](LICENSE)

7
bin/linking-tool.js Executable file
View File

@@ -0,0 +1,7 @@
#!/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

@@ -32,6 +32,8 @@ export default [
Blob: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',
TouchEvent: 'readonly',
Touch: 'readonly',
WheelEvent: 'readonly',
KeyboardEvent: 'readonly',
URLSearchParams: 'readonly',
@@ -70,6 +72,26 @@ export default [
...sveltePlugin.configs.recommended.rules,
},
},
{
files: ['bin/**/*.js'],
languageOptions: {
globals: {
process: 'readonly',
},
},
},
{
files: ['static/sw.js'],
languageOptions: {
globals: {
self: 'readonly',
caches: 'readonly',
fetch: 'readonly',
URL: 'readonly',
console: 'readonly',
},
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**'],
},

165
package-lock.json generated
View File

@@ -1,21 +1,25 @@
{
"name": "quad4-linking-tool",
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quad4-linking-tool",
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.3.0",
"dependencies": {
"autoprefixer": "^10.4.23",
"lucide-svelte": "^0.562.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
},
"bin": {
"linking-tool": "bin/linking-tool.js"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -29,6 +33,9 @@
"svelte-eslint-parser": "^1.4.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -813,6 +820,112 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.9",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz",
"integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
@@ -1147,6 +1260,22 @@
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/adapter-node": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz",
"integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"rollup": "^4.9.5"
},
"peerDependencies": {
"@sveltejs/kit": "^2.4.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.49.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz",
@@ -1245,6 +1374,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz",
@@ -1824,6 +1960,13 @@
"node": ">= 6"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2257,6 +2400,13 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2573,6 +2723,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",

View File

@@ -1,21 +1,39 @@
{
"name": "quad4-linking-tool",
"private": true,
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.3.0",
"type": "module",
"main": "./build/index.js",
"bin": {
"linking-tool": "./bin/linking-tool.js"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
},
"files": [
"build/**/*",
"bin/**/*",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"start": "HOST=127.0.0.1 PORT=3000 node build",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"package": "svelte-kit sync && vite build"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",

BIN
showcase/linkingtool.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -4,7 +4,6 @@
Plus,
Upload,
Download,
Image as ImageIcon,
Undo2,
Redo2,
Maximize,
@@ -103,14 +102,17 @@
let selectedNodeId: string | null = null;
let selectedNodeIds: Set<string> = new Set();
let selectedLinkId: string | null = null;
let draggedNodeId: string | null = null;
let draggedNodeIds: Set<string> = new Set();
let linkStartNodeId: string | null = null;
let hoverNodeId: string | null = null;
let editingLinkId: string | null = null;
let editingLinkLabel = '';
let editingLinkType: RelationshipType = 'Linked';
let editingLinkType: string = 'Linked';
let editingLinkTypeManuallyEdited = false;
let editingLinkStrength: RelationshipStrength = 'medium';
let linkTypeEditInput: HTMLInputElement | null = null;
let selectedNode: Node | null = null;
let isSelecting = false;
let selectionBox: { x1: number; y1: number; x2: number; y2: number } | null = null;
@@ -165,6 +167,7 @@
let imageUploadError = '';
let newNodeImageError = '';
let controlsCollapsed = false;
let isMobile = false;
let copiedNodes: Node[] = [];
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
@@ -176,16 +179,17 @@
let touchHoldTimeout: ReturnType<typeof setTimeout> | null = null;
let touchHoldNodeId: string | null = null;
let touchHoldStart = { x: 0, y: 0 };
let isLongPressing = false;
$: isLight = theme === 'light';
$: panelClass =
(isLight
? 'bg-white/60 border-amber-200 text-neutral-800 shadow-amber-900/10'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-200 shadow-lg') + ' backdrop-blur';
isLight
? 'bg-white/95 border-amber-200 text-neutral-800 shadow-amber-900/10'
: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg';
$: iconButtonClass = isLight
? 'p-1.5 md:p-2 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'p-1.5 md:p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white';
? 'p-2 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'p-2 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white';
$: dividerClass = isLight
? 'hidden md:block md:h-px bg-neutral-300 md:my-1'
: 'hidden md:block md:h-px bg-neutral-800 md:my-1';
@@ -478,11 +482,15 @@
try {
const decoded = atob(encoded);
const data = JSON.parse(decoded);
if (!data || typeof data !== 'object') return false;
if (!Array.isArray(data.nodes) || !Array.isArray(data.links)) return false;
if (data.nodes && data.links) {
pushState();
nodes = normalizeNodes(data.nodes);
links = data.links;
if (data.transform) {
if (data.transform && typeof data.transform === 'object') {
transform = data.transform;
} else {
centerView();
@@ -529,6 +537,14 @@
const encoded = btoa(jsonData);
const shareUrl = `${window.location.origin}${window.location.pathname}?graph=${encoded}`;
const MAX_URL_LENGTH = 2000;
if (shareUrl.length > MAX_URL_LENGTH) {
alert(
'Graph data is too large to share via URL. Please use the JSON export feature instead.'
);
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
alert('Share link copied to clipboard!');
@@ -616,55 +632,6 @@
}
}
async function exportPNG() {
if (!svgElement) return;
try {
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svgElement);
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bbox = svgElement.getBoundingClientRect();
canvas.width = bbox.width * 2;
canvas.height = bbox.height * 2;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `quad4-linking-snapshot-${new Date().toISOString().slice(0, 10)}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
URL.revokeObjectURL(url);
}
}, 'image/png');
};
img.onerror = () => {
console.error('Failed to load SVG image');
URL.revokeObjectURL(url);
};
img.src = url;
} catch (err) {
console.error('Failed to export PNG', err);
}
}
function getScreenCoords(e: MouseEvent) {
const rect = containerElement.getBoundingClientRect();
return {
@@ -712,6 +679,7 @@
isPanning = true;
selectedNodeId = null;
selectedNodeIds.clear();
selectedLinkId = null;
containerElement.style.cursor = 'grabbing';
}
}
@@ -721,6 +689,15 @@
e.stopPropagation();
const mouse = getScreenCoords(e);
lastMouse = mouse;
selectedLinkId = null;
if (isMobile && selectedNodeId && selectedNodeId !== nodeId && !isLinking) {
addLink(selectedNodeId, nodeId);
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
return;
}
if (e.shiftKey) {
isLinking = true;
@@ -758,22 +735,30 @@
clearTouchHold();
touchHoldNodeId = nodeId;
touchHoldStart = { x: touch.clientX, y: touch.clientY };
handleNodeMouseDown(
new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY,
button: 0,
bubbles: true,
}),
nodeId
);
isLongPressing = false;
if (selectedNodeId && selectedNodeId !== nodeId && !isLinking) {
addLink(selectedNodeId, nodeId);
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
return;
}
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
selectedLinkId = null;
touchHoldTimeout = setTimeout(() => {
if (touchHoldNodeId === nodeId) {
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
isLongPressing = true;
isDragging = true;
draggedNodeId = nodeId;
draggedNodeIds.clear();
draggedNodeIds.add(nodeId);
}
}, 450);
}, 300);
}
function handleMouseMove(e: MouseEvent) {
@@ -825,7 +810,7 @@
}
}
function handleMouseUp(_e?: MouseEvent) {
function handleMouseUp() {
if (isDragging && (draggedNodeId || draggedNodeIds.size > 0)) {
saveToLocalStorage();
}
@@ -854,6 +839,7 @@
touchHoldTimeout = null;
}
touchHoldNodeId = null;
isLongPressing = false;
}
function touchToMouseEvent(
@@ -878,22 +864,22 @@
function handleTouchMove(e: TouchEvent) {
if (e.touches.length === 0) return;
const touch = e.touches[0];
if (touchHoldTimeout) {
if (touchHoldTimeout && !isLongPressing) {
const dx = touch.clientX - touchHoldStart.x;
const dy = touch.clientY - touchHoldStart.y;
if (Math.hypot(dx, dy) > 10) {
clearTouchHold();
}
}
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
if (isLongPressing && touchHoldNodeId) {
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
}
}
function handleTouchEnd(e: TouchEvent) {
function handleTouchEnd() {
const wasLongPressing = isLongPressing;
clearTouchHold();
const touch = e.changedTouches[0] || e.touches[0];
if (touch) {
handleMouseUp();
} else {
if (wasLongPressing) {
handleMouseUp();
}
}
@@ -960,6 +946,10 @@
}
function deleteSelected() {
if (selectedLinkId) {
deleteLink(selectedLinkId);
return;
}
if (selectedNodeIds.size === 0 && !selectedNodeId) return;
pushState();
const idsToDelete =
@@ -975,12 +965,29 @@
saveToLocalStorage();
}
function deleteLink(linkId: string) {
pushState();
links = links.filter((l) => l.id !== linkId);
selectedLinkId = null;
saveToLocalStorage();
}
function selectLink(linkId: string, e: MouseEvent) {
e.stopPropagation();
selectedLinkId = linkId;
selectedNodeId = null;
selectedNodeIds.clear();
}
function startEditingLink(linkId: string, e: MouseEvent) {
e.stopPropagation();
const link = links.find((l) => l.id === linkId);
if (link) {
selectedLinkId = linkId;
editingLinkId = linkId;
editingLinkLabel = link.label;
editingLinkType = link.type || 'Linked';
editingLinkTypeManuallyEdited = false;
setTimeout(() => linkEditInput?.focus(), 10);
}
}
@@ -988,12 +995,13 @@
function saveLinkEdit() {
if (!editingLinkId) return;
pushState();
const linkType = editingLinkType.trim() || 'Linked';
links = links.map((l) =>
l.id === editingLinkId
? {
...l,
label: editingLinkLabel.trim() || editingLinkType,
type: editingLinkType,
label: editingLinkLabel.trim() || linkType,
type: linkType as RelationshipType,
strength: editingLinkStrength,
}
: l
@@ -1001,6 +1009,7 @@
editingLinkId = null;
editingLinkLabel = '';
editingLinkType = 'Linked';
editingLinkTypeManuallyEdited = false;
editingLinkStrength = 'medium';
saveToLocalStorage();
}
@@ -1008,6 +1017,18 @@
function cancelLinkEdit() {
editingLinkId = null;
editingLinkLabel = '';
editingLinkType = 'Linked';
editingLinkTypeManuallyEdited = false;
}
function handleRelationshipTypeButtonClick(relType: RelationshipType) {
if (!editingLinkTypeManuallyEdited) {
editingLinkType = relType;
}
}
function handleRelationshipTypeInput() {
editingLinkTypeManuallyEdited = true;
}
function centerView() {
@@ -1044,9 +1065,22 @@
};
}
function isValidImageUrl(url: string): boolean {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (!trimmed) return false;
if (trimmed.startsWith('javascript:')) return false;
if (trimmed.startsWith('data:')) {
return trimmed.startsWith('data:image/');
}
return trimmed.startsWith('http://') || trimmed.startsWith('https://');
}
function normalizeNodes(nodesToNormalize: Node[]): Node[] {
return nodesToNormalize.map((node) => ({
...node,
imageUrl: node.imageUrl && isValidImageUrl(node.imageUrl) ? node.imageUrl : undefined,
showLabel: node.showLabel !== undefined ? node.showLabel : true,
showType: node.showType !== undefined ? node.showType : true,
showNotes: node.showNotes !== undefined ? node.showNotes : true,
@@ -1152,7 +1186,7 @@
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
(selectedNodeId || selectedNodeIds.size > 0)
(selectedLinkId || selectedNodeId || selectedNodeIds.size > 0)
) {
deleteSelected();
}
@@ -1192,10 +1226,21 @@
}
onMount(() => {
const checkMobile = () => {
isMobile = window.innerWidth < 640;
};
checkMobile();
if (isMobile) {
controlsCollapsed = true;
}
window.addEventListener('resize', checkMobile);
loadTheme();
if (!loadFromUrl()) {
loadFromLocalStorage();
}
return () => {
window.removeEventListener('resize', checkMobile);
};
});
$: {
@@ -1210,43 +1255,40 @@
</script>
<div
class={`flex h-full flex-col rounded-xl overflow-hidden relative border transition-colors ${
class={`flex h-full flex-col overflow-hidden relative transition-colors ${
isLight
? 'bg-gradient-to-b from-amber-50 via-white to-amber-100 border-amber-200 shadow-lg'
: 'bg-neutral-900/50 border-neutral-800'
? 'bg-gradient-to-b from-amber-50 via-white to-amber-100 border-amber-200 shadow-lg rounded-none md:rounded-xl border-0 md:border'
: 'bg-neutral-900/50 border-neutral-800 rounded-none md:rounded-xl border-0 md:border'
}`}
bind:this={containerElement}
>
<div
class="absolute z-10 pointer-events-none flex flex-col gap-2 top-2 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 md:top-4 max-w-[calc(100vw-0.5rem)] md:max-w-none"
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 top-1 md:top-2 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 md:top-4 max-w-[calc(100vw-1rem)] md:max-w-none 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"
>
<div class={`rounded-lg p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass}`}>
<div class={`rounded-lg p-1 mobile-landscape:p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass} max-h-full overflow-y-auto mobile-landscape:max-h-none mobile-landscape:overflow-visible`}>
<div
class="flex flex-row flex-wrap md:flex-col md:flex-nowrap gap-0.5 md:gap-1 justify-center w-full md:w-auto"
class="flex flex-row flex-wrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-1.5 md:gap-1.5 mobile-landscape:gap-1 justify-center w-full md:w-auto mobile-landscape:w-auto"
>
<button class={iconButtonClass} title="Toggle Theme" on:click={toggleTheme}>
{#if theme === 'dark'}
<Sun size={16} />
<Sun size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{:else}
<Moon size={16} />
<Moon size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{/if}
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Add Node" on:click={() => (showAddModal = true)}>
<Plus size={16} />
<Plus size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Import Graph" on:click={importGraph}>
<Upload size={16} />
<Upload size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Export JSON" on:click={exportGraph}>
<Download size={16} />
</button>
<button class={iconButtonClass} title="Export PNG" on:click={exportPNG}>
<ImageIcon size={16} />
<Download size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Share Link" on:click={shareGraph}>
<Share2 size={16} />
<Share2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
@@ -1254,7 +1296,7 @@
title="Keyboard Shortcuts (?)"
on:click={() => (showShortcutsModal = true)}
>
<HelpCircle size={16} />
<HelpCircle size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
@@ -1264,7 +1306,7 @@
disabled={undoStack.length === 0}
class:opacity-50={undoStack.length === 0}
>
<Undo2 size={16} />
<Undo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={iconButtonClass}
@@ -1273,14 +1315,14 @@
disabled={redoStack.length === 0}
class:opacity-50={redoStack.length === 0}
>
<Redo2 size={16} />
<Redo2 size={16} 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" on:click={centerView}>
<Maximize size={16} />
<Maximize size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Clear Graph" on:click={clearGraph}>
<Trash2 size={16} />
<Trash2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
</div>
</div>
@@ -1437,15 +1479,27 @@
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase())))}
{#if source && target}
<g class="group">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={isMatch ? '#ef4444' : linkColor}
stroke-width={isMatch ? '3' : strokeWidth}
stroke-opacity={isMatch ? '0.8' : linkStrength === 'weak' ? '0.5' : '1'}
class="transition-colors group-hover:stroke-neutral-300"
stroke={selectedLinkId === link.id ? '#ef4444' : isMatch ? '#ef4444' : linkColor}
stroke-width={selectedLinkId === link.id ? '3' : isMatch ? '3' : strokeWidth}
stroke-opacity={selectedLinkId === link.id ? '1' : isMatch ? '0.8' : linkStrength === 'weak' ? '0.5' : '1'}
class="transition-colors group-hover:stroke-neutral-300 cursor-pointer"
style="pointer-events: stroke;"
role="button"
tabindex="0"
on:click={(e) => selectLink(link.id, e)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectLink(link.id, e as unknown as MouseEvent);
}
}}
/>
{#if !editingLinkId || editingLinkId !== link.id}
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -1455,10 +1509,12 @@
width={link.label.length * 6 + 8}
height="16"
rx="4"
fill={isLight ? '#ffffff' : '#171717'}
fill={selectedLinkId === link.id ? (isLight ? '#fef2f2' : '#7f1d1d') : isLight ? '#ffffff' : '#171717'}
stroke="none"
class="cursor-pointer"
role="button"
tabindex="0"
on:click={(e) => selectLink(link.id, e)}
on:dblclick={(e) => startEditingLink(link.id, e)}
on:keydown={(e) => {
if (e.key === 'Enter') {
@@ -1548,6 +1604,17 @@
class="animate-pulse"
/>
{/if}
{#if isMobile && selectedNodeId === node.id && !isLinking}
<circle
r="32"
fill="none"
stroke="#ef4444"
stroke-width="3"
stroke-opacity="0.6"
stroke-dasharray="8 4"
class="animate-pulse"
/>
{/if}
{#if isMatch && searchQuery.trim()}
<circle
r="30"
@@ -2112,7 +2179,21 @@
/>
</div>
<div>
<div class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Relationship Type</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"
bind:value={editingLinkType}
on:input={handleRelationshipTypeInput}
on:keydown={(e) => {
if (e.key === 'Enter') saveLinkEdit();
if (e.key === 'Escape') cancelLinkEdit();
}}
/>
<div class="grid grid-cols-3 gap-2" role="group" aria-label="Relationship Type">
{#each relationshipTypes as relType}
{@const relColor = RELATIONSHIP_COLORS[relType]}
@@ -2123,8 +2204,9 @@
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800 text-neutral-400 hover:border-neutral-700')}
on:click={() => (editingLinkType = relType)}
on:click={() => handleRelationshipTypeButtonClick(relType)}
style={editingLinkType === relType ? `border-color: ${relColor}` : ''}
type="button"
>
{relType}
</button>

View File

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

View File

@@ -8,13 +8,13 @@
<title>Linking Tool - Identity Graph</title>
<meta
name="description"
content="Linking Tool - A client-side identity graph visualization tool for mapping relationships between entities."
content="Linking Tool - A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Linking Tool - Identity Graph" />
<meta property="og:title" content="Linking Tool" />
<meta
property="og:description"
content="A client-side identity graph visualization tool for mapping relationships between entities."
content="A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:image" content="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -22,17 +22,22 @@
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<header
class="bg-neutral-950 border-b border-neutral-800 px-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0 shadow-lg"
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"
>
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
<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-5 w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
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={14} class="text-accent-red-light" />
<LinkIcon size={12} class="sm:w-[14px] sm:h-[14px] text-accent-red-light" />
</div>
Linking Tool
</h1>
<div class="text-text-secondary text-xs sm:text-sm flex items-center gap-2">
</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"
@@ -51,7 +56,7 @@
>
</div>
</header>
<main class="flex-1 relative overflow-hidden bg-bg-primary p-4">
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<IdentityGraph />
</main>
</div>

View File

@@ -1,4 +1,3 @@
/* eslint-env serviceworker */
const CACHE_NAME = 'quad4-linking-tool-v1';
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];

View File

@@ -1,16 +1,11 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
},
};

View File

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

View File

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