Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9dd65dbff8
|
|||
|
5eb10386de
|
|||
|
c3b0173da3
|
|||
|
0a4a5f7634
|
|||
|
ecc1253937
|
|||
|
de392d52ea
|
|||
|
204dceeff7
|
|||
|
410448b35d
|
|||
|
119177d64c
|
|||
|
df9ed9465b
|
|||
|
c2de35082f
|
|||
|
bc63b4a42c
|
|||
|
8d4e8cde81
|
|||
|
c9053cb0c6
|
|||
|
8d2d520122
|
|||
|
|
d6d8e8240f | ||
|
|
a16e96355f | ||
|
ea21931650
|
|||
|
90de7a4850
|
|||
|
0c9db82791
|
|||
|
fa4ff7444d
|
|||
|
8d82c160d1
|
|||
|
99d94e092d
|
|||
|
003a88dcee
|
64
.gitea/workflows/docker.yml
Normal file
64
.gitea/workflows/docker.yml
Normal 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 }}
|
||||
36
.gitea/workflows/npm-publish.yml
Normal file
36
.gitea/workflows/npm-publish.yml
Normal 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
3
.npmrc
@@ -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}
|
||||
22
Makefile
22
Makefile
@@ -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
|
||||
|
||||
|
||||
43
README.md
43
README.md
@@ -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
7
bin/linking-tool.js
Executable 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';
|
||||
|
||||
@@ -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
165
package-lock.json
generated
@@ -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",
|
||||
|
||||
26
package.json
26
package.json
@@ -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
BIN
showcase/linkingtool.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-env serviceworker */
|
||||
const CACHE_NAME = 'quad4-linking-tool-v1';
|
||||
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user