1 Commits

Author SHA1 Message Date
Renovate Bot
2d8dcbca22 Update https://git.quad4.io/actions/setup-go digest to 40f1582
All checks were successful
OSV-Scanner PR Scan / scan-pr (pull_request) Successful in 9m24s
2025-12-31 00:01:32 +00:00
16 changed files with 22128 additions and 1979 deletions

View File

@@ -18,7 +18,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: https://git.quad4.io/actions/checkout@a5b3063b1edaa6ba4911c8a1b1d5e1656fba3ea5 # v4
with:
fetch-depth: 0
@@ -27,11 +27,8 @@ jobs:
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
@@ -53,11 +50,9 @@ 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@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.5'
@@ -73,16 +68,6 @@ 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
@@ -95,6 +80,14 @@ 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:
@@ -104,7 +97,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)
@@ -112,3 +105,4 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: https://git.quad4.io/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
@@ -29,16 +29,8 @@ 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:
@@ -49,7 +41,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
@@ -62,7 +54,7 @@ jobs:
needs: [build-frontend, scan-backend]
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download frontend assets
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:

View File

@@ -22,19 +22,7 @@ jobs:
steps:
- name: Checkout repository
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
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up QEMU
uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
@@ -74,5 +62,3 @@ 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,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Go
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5

View File

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

63
.gitea/workflows/sbom.yml Normal file
View File

@@ -0,0 +1,63 @@
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,36 +1,5 @@
# 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
@@ -58,7 +27,7 @@
- Mass selection improvements (moving and linking multiple nodes at once).
- Codebase refactor to use Svelte 5 Runes.
- Mobile improvements
- Added SBOM generation as release assets
- Added SBOM generation, see `/sbom/` for the generated SBOMs.
### Dependency Updates
@@ -70,6 +39,7 @@
- `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,19 +19,7 @@ tasks:
build:frontend:
desc: Build frontend only
cmds:
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
- pnpm run build
build:backend:
desc: Build backend binary only
@@ -43,19 +31,7 @@ tasks:
desc: Build the single binary web server
cmds:
- pnpm install
- |
if [ -z "$VITE_APP_VERSION" ]; then
if git rev-parse --git-dir > /dev/null 2>&1; then
if git describe --tags --exact-match HEAD > /dev/null 2>&1; then
VITE_APP_VERSION=$(git describe --tags --exact-match HEAD)
else
VITE_APP_VERSION=$(git rev-parse --short HEAD)
fi
else
VITE_APP_VERSION=$(node -p "require('./package.json').version")
fi
fi
VITE_APP_VERSION="$VITE_APP_VERSION" pnpm run build
- pnpm run build
- mkdir -p {{.BUILD_DIR}}
- CGO_ENABLED=0 go build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} main.go
@@ -117,14 +93,9 @@ tasks:
- GOOS=freebsd GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-amd64 main.go
docker-build:
desc: Build Docker image (VITE_APP_VERSION env var will be passed as build arg if set)
desc: Build Docker image
cmds:
- |
if [ -n "$VITE_APP_VERSION" ]; then
docker build --build-arg VITE_APP_VERSION="$VITE_APP_VERSION" -f docker/Dockerfile -t {{.BINARY_NAME}} .
else
docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
fi
- docker build -f docker/Dockerfile -t {{.BINARY_NAME}} .
docker-run:
desc: Run Docker container

View File

@@ -1,6 +1,5 @@
# 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
@@ -8,7 +7,7 @@ USER node
COPY --chown=node:node package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY --chown=node:node . .
RUN VITE_APP_VERSION=${VITE_APP_VERSION} pnpm run build
RUN 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.3",
"version": "1.5.1",
"license": "BSD-3-Clause",
"author": "Quad4",
"type": "module",
@@ -27,9 +27,9 @@
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=dev vite dev",
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "VITE_APP_VERSION=${VITE_APP_VERSION:-$(node -p \"require('./package.json').version\")} vite build",
"build": "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

8856
sbom/sbom.cyclonedx.json Normal file
View File

File diff suppressed because it is too large Load Diff

12578
sbom/sbom.spdx.json Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -13,15 +13,11 @@
X,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Share2,
Search,
HelpCircle,
Moon,
Sun,
MoreVertical,
Settings,
} from 'lucide-svelte';
import {
DB_NAME,
@@ -375,7 +371,6 @@
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);
@@ -384,20 +379,10 @@
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
@@ -406,8 +391,8 @@
);
let iconButtonClass = $derived(
isLight
? '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'
? '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'
);
let dividerClass = $derived(
isLight
@@ -426,35 +411,7 @@
);
let modalBackdropClass = $derived(isLight ? 'bg-black/30' : 'bg-black/60');
let canvasBgClass = $derived(isLight ? 'bg-amber-50' : 'bg-neutral-950');
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 gridStroke = $derived(isLight ? '#e5e7eb' : '#262626');
let mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
let filteredNodes = $derived(
@@ -1314,10 +1271,6 @@
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
@@ -1327,10 +1280,6 @@
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;
}
@@ -1391,46 +1340,11 @@
});
}
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'));
}
@@ -1440,34 +1354,16 @@
e.preventDefault();
}
if (e.touches.length === 0) return;
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;
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();
}
return;
}
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'));
}
if ((isLongPressing && touchHoldNodeId) || isPanning) {
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
}
}
@@ -1475,15 +1371,6 @@
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();
}
@@ -1867,32 +1754,11 @@
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(() => {
@@ -1961,26 +1827,6 @@
}, 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
@@ -1992,215 +1838,71 @@
bind:this={containerElement}
>
<div
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'}"
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"
>
{#if !mobileToolbarCollapsed}
<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={`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`}
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"
>
<div
class="flex flex-row flex-nowrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-2 md:gap-1.5 mobile-landscape:gap-1 justify-start md:justify-center mobile-landscape:justify-center w-full md:w-auto mobile-landscape:w-auto overflow-visible md:overflow-visible items-center"
<button class={iconButtonClass} title="Toggle Theme" onclick={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)}
>
<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>
<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>
</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>
</div>
{#if showSearch}
@@ -2244,24 +1946,7 @@
</div>
{/if}
<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="absolute bottom-4 right-4 z-10 pointer-events-none">
<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 ${
@@ -2318,7 +2003,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; ${gridStyle()}`}
style="touch-action: none;"
bind:this={canvasElement}
onmousedown={handleMouseDown}
onwheel={handleWheel}
@@ -2332,6 +2017,25 @@
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)}
@@ -2975,128 +2679,6 @@
</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,17 +1,7 @@
<script lang="ts">
import IdentityGraph from '../components/IdentityGraph.svelte';
import { APP_VERSION } from '$lib/version';
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 displayVersion =
APP_VERSION.startsWith('v') || APP_VERSION === 'dev' || isCommitSha
? APP_VERSION
: `v${APP_VERSION}`;
const versionUrl = isCommitSha ? `${REPO_URL}/commit/${APP_VERSION}` : REPO_URL;
import { Link as LinkIcon, GitBranch } from 'lucide-svelte';
</script>
<svelte:head>
@@ -31,40 +21,42 @@
</svelte:head>
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<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">
<header
class="bg-neutral-950 border-b border-neutral-800 px-2 sm:px-6 py-1.5 sm:py-3 flex flex-col sm:flex-row justify-between items-center gap-1 sm:gap-2 flex-shrink-0 shadow-lg"
>
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-sm sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2 hover:text-accent-red-dark transition-colors"
>
<div
class="h-4 w-4 sm:h-5 sm:w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
>
<LinkIcon size={12} class="sm:w-[14px] sm:h-[14px] text-accent-red-light" />
</div>
Linking Tool
</a>
<div class="text-text-secondary text-[10px] sm:text-sm flex items-center gap-1 sm:gap-2">
<span
>Linking Tool - Created by <a
>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
>
-
<span class="inline-flex items-center gap-1">
{#if isCommitSha}
<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>
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-accent-red-light hover:text-accent-red-dark transition-colors inline-flex items-center gap-1"
>v{APP_VERSION} <GitBranch size={12} /></a
></span
>
</div>
</footer>
</header>
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<IdentityGraph />
</main>
</div>

View File

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