19 Commits

Author SHA1 Message Date
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
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
14 changed files with 3848 additions and 2450 deletions

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

@@ -0,0 +1,107 @@
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@a5b3063b1edaa6ba4911c8a1b1d5e1656fba3ea5 # v4
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
else
echo "version=${GITHUB_REF#refs/tags/}" >> $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
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@f50900cd786a0c549eed5a472b4f2c371ae8589f # v5
with:
go-version: '1.25.5'
- 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: 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: Download SBOM
run: |
git fetch origin master:master || true
git checkout master -- sbom/ || git checkout ${{ github.sha }} -- sbom/ || true
if [ -d sbom ]; then
cp sbom/*.json release-assets/ || true
fi
- name: Create Release
uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74 # v1
with:
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

@@ -14,6 +14,7 @@ jobs:
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
@@ -53,8 +54,9 @@ jobs:
git config --global user.name "Gitea Action"
git config --global user.email "actions@noreply.quad4.io"
git remote set-url origin https://${{ secrets.GITEA_TOKEN }}@git.quad4.io/${{ github.repository }}.git
git fetch origin master
git checkout master
git add sbom/
git diff --quiet && git diff --staged --quiet || (git commit -m "Auto-update SBOM [skip ci]" && git push)
git diff --quiet && git diff --staged --quiet || (git commit -m "Auto-update SBOM [skip ci]" && git push origin master)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -1,5 +1,25 @@
# Changelog
## 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
@@ -39,7 +59,6 @@
- `vite`: ^7.2.6 -> ^7.3.0
- Added `eslint-plugin-security`: ^3.0.1
### Major Codebase Changes
- Moved from `npm` to `pnpm`

View File

@@ -85,17 +85,52 @@ cd linking-tool
pnpm install
```
### Taskfile
### Task
The project uses a [Taskfile](https://taskfile.dev/) for all development tasks.
The project uses [Task](https://taskfile.dev/) for all development tasks.
Common tasks:
```
| Task | Description |
|---------------------|-------------------------------------------|
| default | Show available tasks |
| dev | Run development servers (Go & SvelteKit) |
| build | Build the single binary web server |
| build:frontend | Build frontend only |
| build:backend | Build backend binary only |
| package | Package the application |
| release | Build binaries for all platforms |
| build-linux-amd64 | Build Linux AMD64 binary |
| build-linux-arm64 | Build Linux ARM64 binary |
| build-linux-armv6 | Build Linux ARMv6 binary |
| build-linux-armv7 | Build Linux ARMv7 binary |
| build-windows-amd64 | Build Windows AMD64 binary |
| build-darwin-amd64 | Build Darwin AMD64 binary |
| build-darwin-arm64 | Build Darwin ARM64 binary |
| build-freebsd-amd64 | Build FreeBSD AMD64 binary |
| docker-build | Build Docker image |
| docker-run | Run Docker container |
| docker-builder | Build and extract binaries using Docker |
| podman-build | Build Podman image |
| podman-run | Run Podman container |
| podman | Build and run Podman container |
| desktop-build | Build desktop application |
| desktop-linux | Build desktop application for Linux |
| desktop-windows | Build desktop application for Windows |
| desktop-darwin | Build desktop application for Darwin |
| desktop-dev | Run desktop app in development mode |
| clean | Clean build artifacts |
| setup | Setup development environment |
| install | Install dependencies |
| install:ci | Install dependencies for CI (frozen lock) |
| preview | Preview production build |
| check | Run type checking |
| lint | Run linter |
| format | Format code |
| version:minor | Bump minor version in package.json |
| version:major | Bump major version in package.json |
```sh
task dev # Run development servers (Go & SvelteKit)
task build # Build the single binary web server
task desktop-build # Build the desktop application
task # List all available tasks
example: task dev
you might to set alias alias task=`go-task`
```
## Contributing

View File

@@ -112,18 +112,43 @@ tasks:
- 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
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
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
@@ -132,6 +157,7 @@ tasks:
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
@@ -140,6 +166,7 @@ tasks:
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

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

View File

@@ -1,6 +1,6 @@
{
"name": "@quad4/linking-tool",
"version": "1.5.1",
"version": "1.5.2",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",

1766
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:b33a9989-9fad-4087-80b6-4ce46353cce8",
"serialNumber": "urn:uuid:f1002335-7033-40d4-8772-e58b021876b5",
"version": 1,
"metadata": {
"timestamp": "2025-12-29T20:25:57+00:00",
"timestamp": "2025-12-30T03:41:16+00:00",
"tools": {
"components": [
{
@@ -20,7 +20,7 @@
]
},
"component": {
"bom-ref": "7a17460a-af7f-4124-8495-8d456c672b94",
"bom-ref": "49c9b545-bb12-4f4f-acb4-f4daefddfb16",
"type": "application",
"name": ".",
"properties": [
@@ -33,7 +33,7 @@
},
"components": [
{
"bom-ref": "024e29d4-4b84-4452-ae89-1752f041af5b",
"bom-ref": "7c9818eb-d1be-4a49-ae6f-8c9409b498f9",
"type": "application",
"name": "pnpm-lock.yaml",
"properties": [
@@ -48,7 +48,7 @@
]
},
{
"bom-ref": "3dad09ca-ce81-4283-9dea-b2a592f898df",
"bom-ref": "b40d466a-2d62-4def-9b70-c23b87bd3e9a",
"type": "application",
"name": "go.mod",
"properties": [
@@ -7021,7 +7021,14 @@
],
"dependencies": [
{
"ref": "024e29d4-4b84-4452-ae89-1752f041af5b",
"ref": "49c9b545-bb12-4f4f-acb4-f4daefddfb16",
"dependsOn": [
"7c9818eb-d1be-4a49-ae6f-8c9409b498f9",
"b40d466a-2d62-4def-9b70-c23b87bd3e9a"
]
},
{
"ref": "7c9818eb-d1be-4a49-ae6f-8c9409b498f9",
"dependsOn": [
"pkg:npm/%40eslint/js@9.39.2",
"pkg:npm/%40sveltejs/adapter-static@3.0.10",
@@ -7046,18 +7053,11 @@
]
},
{
"ref": "3dad09ca-ce81-4283-9dea-b2a592f898df",
"ref": "b40d466a-2d62-4def-9b70-c23b87bd3e9a",
"dependsOn": [
"pkg:golang/git.quad4.io/quad4-software/linking-tool"
]
},
{
"ref": "7a17460a-af7f-4124-8495-8d456c672b94",
"dependsOn": [
"024e29d4-4b84-4452-ae89-1752f041af5b",
"3dad09ca-ce81-4283-9dea-b2a592f898df"
]
},
{
"ref": "pkg:golang/git.quad4.io/quad4-software/linking-tool",
"dependsOn": [

View File

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,14 @@
X,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Share2,
Search,
HelpCircle,
Moon,
Sun,
MoreVertical,
} from 'lucide-svelte';
import {
DB_NAME,
@@ -371,6 +374,7 @@
let newNodeImageError = $state('');
let controlsCollapsed = $state(false);
let isMobile = $state(false);
let mobileToolbarCollapsed = $state(false);
let copiedNodes = $state<Node[]>([]);
let searchQuery = $state('');
let searchInput = $state<HTMLInputElement | null>(null);
@@ -379,10 +383,16 @@
let addNodeInput = $state<HTMLInputElement | null>(null);
let theme = $state<'dark' | 'light'>('dark');
let isLight = $derived(theme === 'light');
let showMoreMenu = $state(false);
let moreMenuRef = $state<HTMLDivElement | null>(null);
let touchHoldTimeout: ReturnType<typeof setTimeout> | null = null;
let touchHoldNodeId = $state<string | null>(null);
let touchHoldStart = $state({ x: 0, y: 0 });
let isLongPressing = $state(false);
let isPinching = $state(false);
let pinchStartDistance = $state(0);
let pinchStartScale = $state(1);
let pinchCenter = $state({ x: 0, y: 0 });
let panelClass = $derived(
isLight
@@ -391,8 +401,8 @@
);
let iconButtonClass = $derived(
isLight
? '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'
? 'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white'
);
let dividerClass = $derived(
isLight
@@ -1340,11 +1350,46 @@
});
}
function getTouchDistance(touches: TouchEvent['touches']): number {
if (touches.length < 2) return 0;
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getTouchCenter(touches: TouchEvent['touches']): { x: number; y: number } {
if (touches.length === 0) return { x: 0, y: 0 };
if (touches.length === 1) {
return { x: touches[0].clientX, y: touches[0].clientY };
}
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
function handleTouchStart(e: TouchEvent) {
if (e.cancelable) {
e.preventDefault();
}
if (e.touches.length === 0) return;
if (e.touches.length === 2) {
isPinching = true;
pinchStartDistance = getTouchDistance(e.touches);
pinchStartScale = transform.k;
const center = getTouchCenter(e.touches);
const rect = containerElement!.getBoundingClientRect();
pinchCenter = {
x: center.x - rect.left,
y: center.y - rect.top,
};
isPanning = false;
isDragging = false;
clearTouchHold();
return;
}
const touch = e.touches[0];
handleMouseDown(touchToMouseEvent(touch, 'mousedown'));
}
@@ -1354,16 +1399,34 @@
e.preventDefault();
}
if (e.touches.length === 0) return;
const touch = e.touches[0];
if (touchHoldTimeout && !isLongPressing) {
const dx = touch.clientX - touchHoldStart.x;
const dy = touch.clientY - touchHoldStart.y;
if (Math.hypot(dx, dy) > 10) {
clearTouchHold();
if (e.touches.length === 2 && isPinching) {
const currentDistance = getTouchDistance(e.touches);
if (pinchStartDistance > 0) {
const scaleChange = currentDistance / pinchStartDistance;
const newScale = Math.min(Math.max(0.1, pinchStartScale * scaleChange), 5);
const worldBefore = screenToWorld(pinchCenter.x, pinchCenter.y);
transform.k = newScale;
const worldAfter = screenToWorld(pinchCenter.x, pinchCenter.y);
transform.x += (worldAfter.x - worldBefore.x) * newScale;
transform.y += (worldAfter.y - worldBefore.y) * newScale;
}
return;
}
if ((isLongPressing && touchHoldNodeId) || isPanning) {
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
if (e.touches.length === 1) {
const touch = e.touches[0];
if (touchHoldTimeout && !isLongPressing) {
const dx = touch.clientX - touchHoldStart.x;
const dy = touch.clientY - touchHoldStart.y;
if (Math.hypot(dx, dy) > 10) {
clearTouchHold();
}
}
if ((isLongPressing && touchHoldNodeId) || isPanning) {
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
}
}
}
@@ -1371,6 +1434,15 @@
if (e.cancelable) {
e.preventDefault();
}
if (isPinching && e.touches.length < 2) {
isPinching = false;
pinchStartDistance = 0;
pinchStartScale = 1;
if (e.touches.length === 0) {
handleMouseUp();
}
return;
}
clearTouchHold();
handleMouseUp();
}
@@ -1827,6 +1899,26 @@
}, 10);
}
});
$effect(() => {
if (showMoreMenu) {
const handleClickOutside = (e: MouseEvent) => {
if (moreMenuRef && e.target instanceof Element) {
const target = e.target;
if (!moreMenuRef.contains(target) && !target.closest('[data-more-menu-button]')) {
showMoreMenu = false;
}
}
};
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside, true);
}, 10);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside, true);
};
}
});
</script>
<div
@@ -1838,71 +1930,196 @@
bind:this={containerElement}
>
<div
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"
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 top-1 md:top-2 md:left-4 md:translate-x-0 md:top-4 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
? 'right-2 md:left-4 md:right-auto'
: 'left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 w-[calc(100vw-0.25rem)] md:w-auto'}"
>
<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`}
>
{#if !mobileToolbarCollapsed}
<div
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"
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`}
>
<button class={iconButtonClass} title="Toggle Theme" onclick={toggleTheme}>
{#if theme === 'dark'}
<Sun size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{:else}
<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" onclick={() => (showAddModal = true)}>
<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" onclick={importGraph}>
<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" onclick={exportGraph}>
<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" onclick={shareGraph}>
<Share2 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="Keyboard Shortcuts (?)"
onclick={() => (showShortcutsModal = true)}
<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"
>
<HelpCircle 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="Undo (Ctrl+Z)"
onclick={undo}
disabled={undoCount === 0}
class:opacity-50={undoCount === 0}
>
<Undo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={iconButtonClass}
title="Redo (Ctrl+Y)"
onclick={redo}
disabled={redoCount === 0}
class:opacity-50={redoCount === 0}
>
<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" onclick={centerView}>
<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" onclick={clearGraph}>
<Trash2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Toggle Theme" onclick={toggleTheme}>
{#if theme === 'dark'}
<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>
<div class={dividerClass}></div>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Add Node"
onclick={() => (showAddModal = true)}
>
<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={importGraph}
>
<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={exportGraph}
>
<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={shareGraph}
>
<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={() => (showShortcutsModal = true)}
>
<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={undo}
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={redo}
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={centerView}>
<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={clearGraph}
>
<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={(e) => {
e.stopPropagation();
showMoreMenu = !showMoreMenu;
}}
>
<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 hover:bg-neutral-800/50 transition-colors ${
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
}`}
onclick={() => {
showAddModal = true;
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 hover:bg-neutral-800/50 transition-colors ${
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
}`}
onclick={() => {
importGraph();
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 hover:bg-neutral-800/50 transition-colors ${
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
}`}
onclick={() => {
exportGraph();
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 hover:bg-neutral-800/50 transition-colors ${
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
}`}
onclick={() => {
shareGraph();
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 hover:bg-neutral-800/50 transition-colors ${
isLight ? 'text-neutral-700 hover:bg-amber-100' : 'text-neutral-200'
}`}
onclick={() => {
clearGraph();
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={() => (mobileToolbarCollapsed = !mobileToolbarCollapsed)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
</div>
{:else}
<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={() => (mobileToolbarCollapsed = !mobileToolbarCollapsed)}
>
<ChevronRight size={18} />
</button>
{/if}
</div>
{#if showSearch}
@@ -1946,7 +2163,24 @@
</div>
{/if}
<div class="absolute bottom-4 right-4 z-10 pointer-events-none">
<div class="fixed bottom-14 right-4 z-20 pointer-events-none md:hidden mobile-landscape:hidden">
<button
class={`rounded-full p-4 pointer-events-auto shadow-lg border-2 transition-transform hover:scale-110 active:scale-95 ${
isLight
? 'bg-rose-600 border-rose-700 text-white hover:bg-rose-500'
: 'bg-rose-600 border-rose-700 text-white hover:bg-rose-500'
}`}
title="Add Node"
onclick={() => (showAddModal = true)}
aria-label="Add Node"
>
<Plus size={24} />
</button>
</div>
<div
class="absolute bottom-4 right-4 z-10 pointer-events-none hidden md:block mobile-landscape:block"
>
<div class={`backdrop-blur rounded-lg pointer-events-auto shadow-lg border ${panelClass}`}>
<button
class={`w-full flex items-center justify-between px-3 py-2 text-[10px] uppercase tracking-wider font-semibold transition-colors ${

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import IdentityGraph from '../components/IdentityGraph.svelte';
import { APP_VERSION } from '$lib/version';
import { Link as LinkIcon, GitBranch } from 'lucide-svelte';
import { GitBranch } from 'lucide-svelte';
</script>
<svelte:head>
@@ -21,25 +21,13 @@
</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">
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<IdentityGraph />
</main>
<footer class="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex-shrink-0">
<div class="text-text-secondary text-xs flex items-center justify-center gap-2">
<span
>Created by <a
>Linking Tool - Created by <a
href="https://quad4.io"
target="_blank"
rel="noopener noreferrer"
@@ -55,8 +43,5 @@
></span
>
</div>
</header>
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<IdentityGraph />
</main>
</footer>
</div>

View File

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