6 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
8 changed files with 1636 additions and 566 deletions

View File

@@ -97,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)
@@ -105,4 +105,3 @@ jobs:
files: release-assets/*
draft: false
prerelease: false

View File

@@ -60,4 +60,3 @@ jobs:
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

@@ -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

@@ -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.1';
const CACHE_VERSION = '1.5.2';
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];