24 Commits

Author SHA1 Message Date
Renovate Bot
3a924fad19 chore(deps): update https://git.quad4.io/actions/setup-go action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 18s
2026-01-01 00:02:17 +00:00
c1ed5ea92f feat: add tag handling for repository links
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 29s
CI / build-frontend (push) Successful in 55s
CI / scan-backend (push) Successful in 9m22s
CI / build-backend (push) Successful in 20s
2025-12-31 15:51:30 -06:00
3256ec63a2 chore: add Linux desktop build dependencies installation step to CI workflow
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 20s
CI / build-frontend (push) Successful in 51s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 20s
2025-12-31 15:49:38 -06:00
dd0ef88856 chore: add Wails installation step
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 27s
CI / build-frontend (push) Successful in 1m18s
CI / scan-backend (push) Successful in 9m20s
CI / build-backend (push) Successful in 21s
2025-12-31 15:45:08 -06:00
81869eb6d7 docs: update CHANGELOG for version 1.5.3, detailing CI/CD updates and UI/UX improvements
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 46s
Build and Release / build (push) Has been cancelled
CI / scan-backend (push) Successful in 9m19s
CI / build-backend (push) Successful in 24s
Build and Publish Docker Image / build (push) Successful in 10m37s
2025-12-31 15:37:53 -06:00
ab39ddca15 fix: pass VITE_APP_VERSION as an argument during frontend build in Dockerfile 2025-12-31 15:37:44 -06:00
579dc721bc chore: update version to 1.5.3 in package.json and service worker 2025-12-31 15:37:38 -06:00
4c83b97d60 feat: implement version determination logic for frontend and Docker workflows, enhancing VITE_APP_VERSION usage 2025-12-31 15:36:57 -06:00
a5e7784048 feat: enhance VITE_APP_VERSION handling in Taskfile for frontend, backend, and Docker builds 2025-12-31 15:36:50 -06:00
298b62fdc4 chore: simplify VITE_APP_VERSION handling in package.json scripts 2025-12-31 15:36:40 -06:00
936a7e51c3 feat: improve footer to display version information with conditional link to repository 2025-12-31 15:36:35 -06:00
e6cf656556 chore: update dependencies in pnpm-lock.yaml to latest versions
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 22s
2025-12-31 15:18:23 -06:00
74e0bd403e feat: add Trivy download and SBOM generation to build workflow 2025-12-31 15:16:46 -06:00
c14dc18a65 chore: remove SBOM generation workflow and associated output files 2025-12-31 15:16:38 -06:00
e9eb07ef52 Merge pull request 'Update https://git.quad4.io/actions/checkout action to v6' (#18) from renovate/https-git.quad4.io-actions-checkout-6.x into master
All checks were successful
CI / scan-backend (push) Successful in 12s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 9m23s
CI / build-frontend (push) Successful in 9m38s
CI / build-backend (push) Successful in 9m25s
Reviewed-on: #18
2025-12-31 21:06:51 +00:00
5ccc6846a7 feat: add settings modal and grid customization options to IdentityGraph component
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 22s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m18s
CI / build-backend (push) Successful in 21s
2025-12-31 08:58:22 -06:00
7e3c8e2b79 Auto-update SBOM [skip ci] 2025-12-31 14:46:32 +00:00
6563d75b48 chore: update CHANGELOG for version 1.5.2 with mobile enhancements and UI/UX improvements
Some checks failed
Generate SBOM / generate-sbom (push) Successful in 50s
Build and Release / build (push) Failing after 1m29s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 25s
CI / build-frontend (push) Successful in 1m6s
CI / scan-backend (push) Successful in 9m18s
CI / build-backend (push) Successful in 21s
Build and Publish Docker Image / build (push) Successful in 12m50s
2025-12-31 08:45:43 -06:00
0973b6f378 chore: bump version to 1.5.2 and update CACHE_VERSION in service worker
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 22s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m21s
CI / build-backend (push) Successful in 20s
2025-12-31 08:44:44 -06:00
51fd93c9a0 fix: update footer text to include 'Linking Tool - Created by' 2025-12-31 08:44:35 -06:00
97b023f1f4 feat: enhance IdentityGraph component with touch gesture support and mobile toolbar improvements 2025-12-31 08:44:30 -06:00
10bfb5d9e4 format: workflows
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 21s
CI / build-frontend (push) Successful in 43s
CI / scan-backend (push) Successful in 9m20s
CI / build-backend (push) Successful in 21s
2025-12-31 08:20:18 -06:00
b09e7f05fd refactor: header and footer structure in +page.svelte, removing the LinkIcon and simplifying layout 2025-12-31 08:20:17 -06:00
Renovate Bot
232d62e5f9 Update https://git.quad4.io/actions/checkout action to v6
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 17s
2025-12-31 00:01:37 +00:00
16 changed files with 1996 additions and 22132 deletions

View File

@@ -18,7 +18,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@a5b3063b1edaa6ba4911c8a1b1d5e1656fba3ea5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
@@ -27,8 +27,11 @@ jobs:
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
@@ -50,12 +53,22 @@ jobs:
- name: Build frontend
run: task build:frontend
env:
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@f50900cd786a0c549eed5a472b4f2c371ae8589f # v5
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version: '1.25.5'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@ac867f658730618b79b4fbea194ccbbbddac28ee # v2.11.0
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Build server binaries
run: |
task build:backend
@@ -68,6 +81,16 @@ jobs:
- name: Build desktop Windows
run: task desktop-windows
- name: Download Trivy
run: |
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
- name: Generate SBOM (CycloneDX)
run: |
mkdir -p release-assets
trivy fs --format cyclonedx --include-dev-deps --output release-assets/sbom.cyclonedx.json .
- name: Prepare release assets
run: |
mkdir -p release-assets
@@ -80,14 +103,6 @@ jobs:
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:
@@ -97,7 +112,7 @@ jobs:
tag: ${{ steps.version.outputs.version }}
body: |
Release ${{ steps.version.outputs.version }}
## Assets
- Server binaries (Linux AMD64, Windows AMD64)
- Desktop applications (Linux AMD64, Windows AMD64)
@@ -105,4 +120,3 @@ jobs:
files: release-assets/*
draft: false
prerelease: false

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -29,8 +29,16 @@ jobs:
run: task lint
- name: Frontend checks
run: task check
- name: Determine version
id: version
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Build frontend
run: task build:frontend
env:
VITE_APP_VERSION: ${{ steps.version.outputs.version }}
- name: Upload frontend assets
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
@@ -41,9 +49,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version: '1.25.5'
- name: Run gosec security scan
@@ -54,14 +62,14 @@ jobs:
needs: [build-frontend, scan-backend]
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download frontend assets
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: frontend-build
path: build/
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version: '1.25.5'
- name: Setup Task

View File

@@ -22,7 +22,19 @@ jobs:
steps:
- name: Checkout repository
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
@@ -62,3 +74,5 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VITE_APP_VERSION=${{ steps.version.outputs.version }}

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: 'go.mod'

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
uses: https://git.quad4.io/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6
with:
go-version-file: 'go.mod'

View File

@@ -1,63 +0,0 @@
name: Generate SBOM
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
generate-sbom:
runs-on: ubuntu-latest
steps:
- name: Checkout
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
with:
node-version: 22
cache: pnpm
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.5'
- name: Setup Task
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
with:
version: '3.46.3'
- name: Setup environment
run: task setup
- name: Install dependencies
run: task install:ci
- name: Download Trivy
run: |
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
- name: Generate SBOM
run: |
mkdir -p sbom
trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
- name: Commit and Push Changes
run: |
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 origin master)
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -1,5 +1,36 @@
# Changelog
## 1.5.3 - 2025-12-31
### CI/CD Updates
- Moved SBOM generation from `sbom.yml` workflow to `build.yml` workflow as release assets instead of auto-committing to source code
- Removed SPDX format, now only generating CycloneDX SBOM format (more popular and security-focused)
### UI/UX
- Updated version display logic: tag builds show tag version (e.g., `v1.5.2`), branch builds show commit SHA (e.g., `abc1234`), local dev shows `dev`
## 1.5.2 - 2025-12-31
### Features
- **Mobile Enhancements**:
- Added pinch-to-zoom support for graph navigation on touch devices.
- Redesigned mobile toolbar into a single row with a collapsible "More" menu.
- Added a responsive expand/collapse toggle for the mobile toolbar using chevron icons.
- Moved the "Add Node" action to a floating sticky button in the bottom-right on mobile for better accessibility.
- Optimized toolbar width and spacing for mobile screens.
- **UI/UX**:
- Removed top navbar/header to maximize workspace area.
- Simplified layout with a minimal footer.
- Updated footer branding to include "Linking Tool".
### Fixes
- Improved click-outside handling for mobile menus.
- Fixed various mobile layout and justification constraints.
## 1.5.1 - 2025-12-29
### Features
@@ -27,7 +58,7 @@
- Mass selection improvements (moving and linking multiple nodes at once).
- Codebase refactor to use Svelte 5 Runes.
- Mobile improvements
- Added SBOM generation, see `/sbom/` for the generated SBOMs.
- Added SBOM generation as release assets
### Dependency Updates
@@ -39,7 +70,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

@@ -19,7 +19,19 @@ tasks:
build:frontend:
desc: Build frontend only
cmds:
- pnpm run build
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
build:backend:
desc: Build backend binary only
@@ -31,7 +43,19 @@ tasks:
desc: Build the single binary web server
cmds:
- pnpm install
- pnpm run build
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
- mkdir -p {{.BUILD_DIR}}
- CGO_ENABLED=0 go build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} main.go
@@ -93,9 +117,14 @@ tasks:
- GOOS=freebsd GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-amd64 main.go
docker-build:
desc: Build Docker image
desc: Build Docker image (VITE_APP_VERSION env var will be passed as build arg if set)
cmds:
- docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
- |
if [ -n "$VITE_APP_VERSION" ]; then
docker build --build-arg VITE_APP_VERSION="$VITE_APP_VERSION" -f docker/Dockerfile -t {{.BINARY_NAME}} .
else
docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
fi
docker-run:
desc: Run Docker container

View File

@@ -1,5 +1,6 @@
# Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
ARG VITE_APP_VERSION
WORKDIR /app
USER root
RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
@@ -7,7 +8,7 @@ USER node
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node . .
RUN pnpm run build
RUN VITE_APP_VERSION=${VITE_APP_VERSION} pnpm run build
# Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder

View File

@@ -1,6 +1,6 @@
{
"name": "@quad4/linking-tool",
"version": "1.5.1",
"version": "1.5.3",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
@@ -27,9 +27,9 @@
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"dev": "VITE_APP_VERSION=dev vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"build": "VITE_APP_VERSION=${VITE_APP_VERSION:-$(node -p \"require('./package.json').version\")} vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",

1790
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,15 @@
X,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Share2,
Search,
HelpCircle,
Moon,
Sun,
MoreVertical,
Settings,
} from 'lucide-svelte';
import {
DB_NAME,
@@ -371,6 +375,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 +384,20 @@
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 showSettingsModal = $state(false);
let showGrid = $state(true);
let gridOpacityMultiplier = $state(1);
let snapToGrid = $state(false);
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 +406,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
@@ -411,7 +426,35 @@
);
let modalBackdropClass = $derived(isLight ? 'bg-black/30' : 'bg-black/60');
let canvasBgClass = $derived(isLight ? 'bg-amber-50' : 'bg-neutral-950');
let gridStroke = $derived(isLight ? '#e5e7eb' : '#262626');
let gridStyle = $derived(() => {
if (!showGrid) return 'background-image: none;';
const k = transform.k;
const s1 = 40 * k;
const s2 = 200 * k;
const s3 = 1000 * k;
// Extremely subtle opacities
const o1 = Math.min(0.03, Math.max(0, (s1 - 15) / 100)) * gridOpacityMultiplier;
const o2 = Math.min(0.06, Math.max(0, (s2 - 15) / 100)) * gridOpacityMultiplier;
const o3 = Math.min(0.1, k < 0.1 ? 0.1 : 0.05) * gridOpacityMultiplier;
const color = isLight ? '0, 0, 0' : '255, 255, 255';
return `
background-image:
linear-gradient(to right, rgba(${color}, ${o1}) 1px, transparent 1px),
linear-gradient(to bottom, rgba(${color}, ${o1}) 1px, transparent 1px),
linear-gradient(to right, rgba(${color}, ${o2}) 1px, transparent 1px),
linear-gradient(to bottom, rgba(${color}, ${o2}) 1px, transparent 1px),
linear-gradient(to right, rgba(${color}, ${o3}) 1px, transparent 1px),
linear-gradient(to bottom, rgba(${color}, ${o3}) 1px, transparent 1px);
background-size:
${s1}px ${s1}px, ${s1}px ${s1}px,
${s2}px ${s2}px, ${s2}px ${s2}px,
${s3}px ${s3}px, ${s3}px ${s3}px;
background-position: ${transform.x}px ${transform.y}px;
`;
});
let mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
let filteredNodes = $derived(
@@ -1271,6 +1314,10 @@
if (node) {
node.x += dx / transform.k;
node.y += dy / transform.k;
if (snapToGrid) {
node.x = Math.round(node.x / 40) * 40;
node.y = Math.round(node.y / 40) * 40;
}
}
});
// eslint-disable-next-line no-self-assign
@@ -1280,6 +1327,10 @@
if (node) {
node.x += dx / transform.k;
node.y += dy / transform.k;
if (snapToGrid) {
node.x = Math.round(node.x / 40) * 40;
node.y = Math.round(node.y / 40) * 40;
}
// eslint-disable-next-line no-self-assign
nodes = nodes;
}
@@ -1340,11 +1391,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 +1440,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 +1475,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();
}
@@ -1754,11 +1867,32 @@
await saveSetting('theme', theme);
}
async function updateGridSettings() {
await saveSetting('showGrid', showGrid);
await saveSetting('gridOpacityMultiplier', gridOpacityMultiplier);
await saveSetting('snapToGrid', snapToGrid);
}
async function loadTheme() {
const saved = await loadSetting('theme');
if (saved === 'light' || saved === 'dark') {
theme = saved;
}
const savedShowGrid = await loadSetting('showGrid');
if (typeof savedShowGrid === 'boolean') {
showGrid = savedShowGrid;
}
const savedGridOpacity = await loadSetting('gridOpacityMultiplier');
if (typeof savedGridOpacity === 'number') {
gridOpacityMultiplier = savedGridOpacity;
}
const savedSnapToGrid = await loadSetting('snapToGrid');
if (typeof savedSnapToGrid === 'boolean') {
snapToGrid = savedSnapToGrid;
}
}
$effect(() => {
@@ -1827,6 +1961,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 +1992,215 @@
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>
<button
class={iconButtonClass}
title="Settings"
onclick={() => (showSettingsModal = true)}
>
<Settings size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:block`}
title="Add Node"
onclick={() => (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={() => {
showSettingsModal = true;
showMoreMenu = false;
}}
>
<Settings size={16} />
Settings
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 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 +2244,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 ${
@@ -2003,7 +2318,7 @@
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class={`flex-1 w-full h-full cursor-default outline-none ${canvasBgClass}`}
style="touch-action: none;"
style={`touch-action: none; ${gridStyle()}`}
bind:this={canvasElement}
onmousedown={handleMouseDown}
onwheel={handleWheel}
@@ -2017,25 +2332,6 @@
aria-roledescription="Interactive graph canvas"
>
<svg bind:this={svgElement} class="w-full h-full block">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke={gridStroke} stroke-width="1" />
</pattern>
</defs>
<g
transform="translate({transform.x % (40 * transform.k)}, {transform.y %
(40 * transform.k)}) scale({transform.k})"
>
<rect
x={-containerElement?.clientWidth / transform.k || -1000}
y={-containerElement?.clientHeight / transform.k || -1000}
width="400%"
height="400%"
fill="url(#grid)"
/>
</g>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
{#each filteredLinks as link (link.id)}
{@const source = nodes.find((n) => n.id === link.source)}
@@ -2679,6 +2975,128 @@
</div>
{/if}
{#if showSettingsModal}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => {
if (e.target === e.currentTarget) {
showSettingsModal = false;
}
}}
onkeydown={(e) => e.key === 'Escape' && (showSettingsModal = false)}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Settings"
>
<div class={`w-full max-w-md rounded-xl border p-6 shadow-2xl ${surfaceClass}`}>
<div class="flex items-center justify-between mb-6">
<h4 class={`text-lg font-semibold ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Settings
</h4>
<button
class={`p-1 rounded transition-colors ${
isLight
? 'text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={() => (showSettingsModal = false)}
>
<X size={20} />
</button>
</div>
<div class="space-y-6">
<div class="space-y-3">
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">
Grid Settings
</div>
<label class="flex items-center justify-between cursor-pointer">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Grid Squares</span
>
<input
type="checkbox"
bind:checked={showGrid}
onchange={updateGridSettings}
class={`rounded text-rose-500 focus:ring-rose-500 h-5 w-5 ${
isLight ? 'border-amber-300 bg-amber-50' : 'border-neutral-700 bg-neutral-800'
}`}
/>
</label>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Grid Opacity</span
>
<span class="text-xs font-mono text-neutral-500"
>{Math.round(gridOpacityMultiplier * 100)}%</span
>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
bind:value={gridOpacityMultiplier}
oninput={updateGridSettings}
class="w-full h-2 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-rose-500"
/>
</div>
<label class="flex items-center justify-between cursor-pointer">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Snap to Grid (40px)</span
>
<input
type="checkbox"
bind:checked={snapToGrid}
onchange={updateGridSettings}
class={`rounded text-rose-500 focus:ring-rose-500 h-5 w-5 ${
isLight ? 'border-amber-300 bg-amber-50' : 'border-neutral-700 bg-neutral-800'
}`}
/>
</label>
</div>
<div class="space-y-3 pt-4 border-t border-neutral-800">
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">
Theme
</div>
<button
class={`w-full flex items-center justify-between px-4 py-2 rounded-lg border transition-colors ${
isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:bg-amber-100'
: 'border-neutral-800 bg-neutral-900 text-neutral-300 hover:bg-neutral-800'
}`}
onclick={toggleTheme}
>
<span class="text-sm"
>Active Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}</span
>
{#if theme === 'dark'}
<Sun size={16} />
{:else}
<Moon size={16} />
{/if}
</button>
</div>
</div>
<div class="flex justify-end mt-8">
<button
class="rounded-lg bg-rose-600 px-6 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={() => (showSettingsModal = false)}
>
Close
</button>
</div>
</div>
</div>
{/if}
{#if showAddModal}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div

View File

@@ -1,7 +1,22 @@
<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';
const REPO_URL = 'https://git.quad4.io/Quad4-Software/Linking-Tool';
const isCommitSha = /^[a-f0-9]{7,}$/i.test(APP_VERSION);
const isTag = APP_VERSION.startsWith('v') && APP_VERSION !== 'dev';
const displayVersion =
APP_VERSION.startsWith('v') || APP_VERSION === 'dev' || isCommitSha
? APP_VERSION
: `v${APP_VERSION}`;
const versionUrl = isCommitSha
? `${REPO_URL}/commit/${APP_VERSION}`
: isTag
? `${REPO_URL}/releases/tag/${APP_VERSION}`
: REPO_URL;
</script>
<svelte:head>
@@ -21,42 +36,40 @@
</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"
class="text-accent-red-light hover:text-accent-red-dark transition-colors">Quad4</a
>
-
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors inline-flex items-center gap-1"
>v{APP_VERSION} <GitBranch size={12} /></a
></span
>
<span class="inline-flex items-center gap-1">
{#if isCommitSha || isTag}
<a
href={versionUrl}
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors"
>{displayVersion}</a
>
{:else}
<span>{displayVersion}</span>
{/if}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors"
><GitBranch size={12} /></a
>
</span>
</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.1';
const CACHE_VERSION = '1.5.3';
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];