1 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
25 changed files with 1539 additions and 3813 deletions

View File

@@ -57,7 +57,7 @@ jobs:
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'

View File

@@ -51,7 +51,7 @@ jobs:
- name: Checkout
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
@@ -69,7 +69,7 @@ jobs:
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

@@ -17,7 +17,7 @@ jobs:
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

@@ -17,7 +17,7 @@ jobs:
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,46 +1,5 @@
# Changelog
## 1.6.0 - 2026-01-01
Happy New Year!
### Features
- **Custom Entity Types**:
- Added ability to create, edit, and delete custom entity types via a new "Custom Types" modal.
- Custom types support user-defined names, colors, and optional custom icon images.
- Custom type icons are stored persistently in IndexedDB.
- **Node Customization**:
- Added **Node Color Override**: Individual nodes can now have their own custom color, overriding the default type color.
- Added a color picker to the Node Inspector for easy color customization.
- **Image Storage**:
- **IndexedDB Image Storage**: All node images and custom type icons are now stored as binary Blobs in IndexedDB increasing performance and reducing lag of selection tool.
- **Theming System**:
- Added support for 8 themes: Dark, Light, OLED Black, Midnight Blue, Sepia, Slate Gray, Cyberpunk, and Paper White.
- Theme selection available in Settings modal with visual preview.
- Theme preference is persisted across sessions.
- **Linking Features**:
- Added **Linking Mode** toggle in toolbar for easier link creation.
- Added **Auto-linking**: When enabled, nodes automatically link when dragged near each other (with 800ms hover delay).
- Auto-linking can be toggled in Settings.
- **Toast Notifications**:
- Added toast notification system for user feedback on actions (success, error, info, warning).
- Replaces alert() calls with non-intrusive toast messages.
- **Codebase Refactor**:
- **Component Breakdown**: Significantly refactored `IdentityGraph.svelte` by extracting logic into modular components: `Toolbar`, `NodeInspector`, `SettingsModal`, `AddEntityModal`, `LinkEditModal`, `CustomTypesModal`, `FloatingWindow`, and `ToastContainer`.
- **UI/UX**:
- Updated graph rendering to support custom type icons and node-specific color overrides.
- Updated search to include custom type names.
- Improved notes background rendering on the graph to dynamically match text width.
- Added desktop toolbar collapse/expand functionality.
- Moved footer into IdentityGraph component for better layout control.
### Fixes
- **Wails Desktop App Compatibility**:
- Fixed Service Worker registration error in Wails desktop app by checking for HTTP/HTTPS protocol before registration.
- Fixed IndexedDB object store errors in Wails by detecting and automatically recreating database when stores are missing.
## 1.5.3 - 2025-12-31
### CI/CD Updates

View File

@@ -162,7 +162,7 @@ tasks:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -tags webkit2_41
- cd desktop && wails build -s
desktop-linux:
desc: Build desktop application for Linux
@@ -171,7 +171,7 @@ tasks:
- mkdir -p desktop/frontend_dist
- rm -rf desktop/frontend_dist/*
- cp -r build/* desktop/frontend_dist/
- cd desktop && wails build -s -platform linux/amd64 -tags webkit2_41
- cd desktop && wails build -s -platform linux/amd64
desktop-windows:
desc: Build desktop application for Windows

View File

@@ -64,15 +64,6 @@ export default [
XMLSerializer: 'readonly',
Image: 'readonly',
FileReader: 'readonly',
IDBRequest: 'readonly',
IDBCursorWithValue: 'readonly',
$state: 'readonly',
$derived: 'readonly',
$effect: 'readonly',
$props: 'readonly',
$bindable: 'readonly',
$inspect: 'readonly',
$host: 'readonly',
},
},
plugins: {

View File

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

View File

@@ -1,300 +0,0 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: iconMap and typeColors access uses keys from controlled constant array (nodeTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import {
nodeTypes,
iconMap,
typeColors,
ALLOWED_IMAGE_TYPES,
MAX_IMAGE_BYTES,
} from '$lib/constants';
import type { NodeType } from '$lib/constants';
import type { CustomType } from '$lib/types';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
onClose: () => void;
onAdd: (data: { label: string; type: NodeType | string; image: string; notes: string }) => void;
isLight: boolean;
theme: string;
surfaceClass: string;
inputClass: string;
modalBackdropClass: string;
customTypes: CustomType[];
imageObjects: Map<string, string>;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
onClose,
onAdd,
isLight,
theme,
surfaceClass,
inputClass,
modalBackdropClass,
customTypes = [],
imageObjects,
zIndex = 50,
onFocus,
}: Props = $props();
let label = $state('');
let type = $state<NodeType | string>('person');
let image = $state('');
let notes = $state('');
let imageError = $state('');
let inputRef = $state<HTMLInputElement | null>(null);
let fileInputRef = $state<HTMLInputElement | null>(null);
const mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
function handleAdd() {
if (!label.trim()) return;
onAdd({ label: label.trim(), type, image: image.trim(), notes: notes.trim() });
label = '';
type = 'person';
image = '';
notes = '';
imageError = '';
onClose();
}
function triggerImageUpload() {
fileInputRef?.click();
}
function handleImageFileSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
imageError = 'Only PNG, JPG, or WebP images are allowed.';
input.value = '';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
imageError = 'Image must be under 2MB.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
image = reader.result as string;
imageError = '';
input.value = '';
};
reader.onerror = () => {
imageError = 'Failed to read image.';
input.value = '';
};
reader.readAsDataURL(file);
}
function clearImage() {
image = '';
imageError = '';
}
$effect(() => {
if (open && inputRef) {
setTimeout(() => {
if (inputRef) {
inputRef.focus();
}
}, 10);
}
});
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- 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) {
onClose();
}
}}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Add Entity"
>
<div class={`w-full max-w-md rounded-xl border p-6 shadow-2xl ${surfaceClass}`}>
{@render addEntityContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="add-entity"
title="Add Entity"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
{zIndex}
{onFocus}
>
{@render addEntityContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet addEntityContent()}
<h4 class={`text-lg font-semibold mb-4 ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Add Entity
</h4>
<div class="space-y-4">
<div>
<label for="nodeLabel" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Label / Name</label
>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={inputRef}
id="nodeLabel"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="e.g. John Doe"
bind:value={label}
onkeydown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div>
<label for="nodeType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Type</label>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as nodeType}
{@const IconComponent = iconMap[nodeType]}
<button
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
(type === nodeType
? 'border-rose-500 bg-rose-500/10'
: isLight
? 'border-amber-300 bg-amber-50 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800/50 hover:border-neutral-700')}
onclick={() => (type = nodeType)}
title={nodeType}
>
<IconComponent size={20} color={typeColors[nodeType]} />
<span
class={`text-[10px] capitalize truncate w-full text-center ${isLight ? 'text-neutral-700' : 'text-neutral-400'}`}
>
{nodeType}
</span>
</button>
{/each}
{#each customTypes as customType}
<button
class={'flex flex-col items-center justify-center gap-1 rounded-lg border p-2 transition ' +
(type === customType.id
? 'border-rose-500 bg-rose-500/10'
: isLight
? 'border-amber-300 bg-amber-50 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800/50 hover:border-neutral-700')}
onclick={() => (type = customType.id)}
title={customType.name}
>
<div
class="w-5 h-5 rounded-full flex items-center justify-center overflow-hidden"
style={`background-color: ${customType.color}22; border: 1.5px solid ${customType.color}`}
>
{#if customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-1.5 h-1.5 rounded-full"
style={`background-color: ${customType.color}`}
></div>
{/if}
</div>
<span
class={`text-[10px] capitalize truncate w-full text-center ${isLight ? 'text-neutral-700' : 'text-neutral-400'}`}
>
{customType.name}
</span>
</button>
{/each}
</div>
</div>
<div>
<label for="nodeImage" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Custom Image URL</label
>
<input
id="nodeImage"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="https://..."
bind:value={image}
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors hover:brightness-110 ${inputClass}`}
type="button"
onclick={triggerImageUpload}
>
Upload Image
</button>
{#if image}
<button
class={`rounded-lg border px-3 py-1.5 text-xs transition-colors hover:brightness-110 ${inputClass}`}
type="button"
onclick={clearImage}
>
Remove
</button>
{/if}
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
bind:this={fileInputRef}
onchange={handleImageFileSelected}
/>
<p class={`mt-1 text-[11px] ${mutedTextClass}`}>
Paste a URL or upload a PNG/JPEG/WebP (2MB max).
</p>
{#if imageError}
<p class={`mt-1 text-[11px] ${isLight ? 'text-rose-600' : 'text-rose-300'}`}>
{imageError}
</p>
{/if}
</div>
<div>
<label for="nodeNotes" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Notes</label
>
<textarea
id="nodeNotes"
rows="3"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 resize-none ${inputClass}`}
placeholder="Short intel notes, context, identifiers..."
bind:value={notes}
></textarea>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class={`rounded-lg px-3 py-2 text-sm transition-colors hover:brightness-110 ${inputClass}`}
onclick={onClose}>Cancel</button
>
<button
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={handleAdd}>Add Entity</button
>
</div>
</div>
{/snippet}

View File

@@ -1,468 +0,0 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
import { untrack } from 'svelte';
import { X, Plus, Trash2, Upload, ImageIcon, Pencil } from 'lucide-svelte';
import type { CustomType } from '$lib/types';
import {
saveCustomType,
deleteCustomType,
storeImageBlob,
loadImageBlob,
deleteImageBlob,
} from '$lib/db';
import { ALLOWED_IMAGE_TYPES, MAX_IMAGE_BYTES } from '$lib/constants';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
isLight: boolean;
theme: string;
dividerClass: string;
inputClass: string;
modalBackdropClass: string;
surfaceClass: string;
customTypes: CustomType[];
onClose: () => void;
onUpdate: () => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
isLight,
theme,
dividerClass,
inputClass,
modalBackdropClass,
surfaceClass,
customTypes,
onClose,
onUpdate,
zIndex = 60,
onFocus,
}: Props = $props();
let newTypeName = $state('');
let newTypeColor = $state('#ef4444');
let newTypeIconBlob = $state<Blob | null>(null);
let newTypeIconUrl = $state<string | null>(null);
let editingType = $state<CustomType | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let error = $state('');
const mutedTextClass = $derived(isLight ? 'text-neutral-600' : 'text-neutral-500');
async function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
error = 'Invalid file type. Please use PNG, JPEG or WEBP.';
return;
}
if (file.size > MAX_IMAGE_BYTES) {
error = 'File too large. Max size is 2MB.';
return;
}
if (editingType) {
const iconId = 'icon-' + editingType.id + '-' + Math.random().toString(36).slice(2, 7);
if (editingType.iconId) {
await deleteImageBlob(editingType.iconId);
}
await storeImageBlob(iconId, file);
editingType.iconId = iconId;
if (iconUrls[iconId]) URL.revokeObjectURL(iconUrls[iconId]);
iconUrls[iconId] = URL.createObjectURL(file);
} else {
newTypeIconBlob = file;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = URL.createObjectURL(file);
}
error = '';
}
async function saveType() {
if (editingType) {
if (!editingType.name.trim()) {
error = 'Type name is required.';
return;
}
await saveCustomType($state.snapshot(editingType));
editingType = null;
} else {
if (!newTypeName.trim()) {
error = 'Type name is required.';
return;
}
const id = 'ct-' + Math.random().toString(36).slice(2, 11);
let iconId: string | undefined;
if (newTypeIconBlob) {
iconId = 'icon-' + id;
await storeImageBlob(iconId, newTypeIconBlob);
}
const newType: CustomType = {
id,
name: newTypeName.trim(),
color: newTypeColor,
iconId,
};
await saveCustomType(newType);
newTypeName = '';
newTypeColor = '#ef4444';
newTypeIconBlob = null;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = null;
}
error = '';
onUpdate();
}
async function removeCustomType(type: CustomType) {
if (type.iconId) {
await deleteImageBlob(type.iconId);
}
await deleteCustomType(type.id);
onUpdate();
}
let iconUrls = $state<Record<string, string>>({});
$effect(() => {
if (open) {
customTypes.forEach(async (type) => {
const hasIcon = untrack(() => type.iconId && iconUrls[type.iconId]);
if (type.iconId && !hasIcon) {
const blob = await loadImageBlob(type.iconId);
if (blob) {
const url = URL.createObjectURL(blob);
iconUrls[type.iconId] = url;
}
}
});
}
return () => {
Object.values(untrack(() => iconUrls)).forEach(URL.revokeObjectURL);
untrack(() => {
iconUrls = {};
});
};
});
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class={`absolute inset-0 z-[60] flex items-center justify-center backdrop-blur-sm p-4 ${modalBackdropClass}`}
onclick={(e) => e.target === e.currentTarget && onClose()}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
aria-modal="true"
>
<div
class={`w-full max-w-2xl rounded-xl border p-6 shadow-2xl overflow-hidden flex flex-col max-h-[90vh] ${surfaceClass}`}
>
{@render customTypesContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="custom-types"
title="Manage Custom Types"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
minWidth={500}
{zIndex}
{onFocus}
>
{@render customTypesContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet customTypesContent()}
{#if isMobile}
<div class="flex items-center justify-between mb-6">
<h4 class={`text-lg font-semibold ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Manage Custom Types
</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={onClose}
>
<X size={20} />
</button>
</div>
{/if}
<div class="flex-1 overflow-y-auto space-y-6 pr-2">
<!-- New/Edit Type Form -->
<div
class={`p-4 rounded-lg border transition-colors ${isLight ? 'bg-amber-50 border-amber-200' : 'bg-neutral-800/30 border-neutral-700'}`}
>
<h5 class={`text-sm font-semibold mb-4 uppercase tracking-wider ${mutedTextClass}`}>
{editingType ? 'Edit Type' : 'Create New Type'}
</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label for="custom-type-name" class={`text-xs font-medium ${mutedTextClass}`}
>Type Name</label
>
{#if editingType}
<input
id="custom-type-name"
type="text"
bind:value={editingType.name}
placeholder="e.g. Asset, Evidence..."
class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 transition-colors ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{:else}
<input
id="custom-type-name"
type="text"
bind:value={newTypeName}
placeholder="e.g. Asset, Evidence..."
class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 transition-colors ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{/if}
</div>
<div class="space-y-2">
<label
for="custom-type-color"
class={`text-xs font-medium ${isLight ? 'text-neutral-600' : 'text-neutral-400'}`}
>Color</label
>
<div class="flex gap-2">
{#if editingType}
<input
id="custom-type-color"
type="color"
bind:value={editingType.color}
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
/>
<input
type="text"
bind:value={editingType.color}
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{:else}
<input
id="custom-type-color"
type="color"
bind:value={newTypeColor}
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
/>
<input
type="text"
bind:value={newTypeColor}
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isLight
? 'bg-white border-amber-300'
: 'bg-neutral-900 border-neutral-700 text-white'
}`}
/>
{/if}
</div>
</div>
<div class="md:col-span-2 space-y-2">
<label
for="custom-type-icon"
class={`text-xs font-medium ${isLight ? 'text-neutral-600' : 'text-neutral-400'}`}
>Custom Icon (Optional)</label
>
<div class="flex items-center gap-4">
<button
onclick={() => fileInput?.click()}
class={`flex items-center gap-2 px-4 py-2 rounded-lg border text-sm transition-colors hover:brightness-110 ${inputClass}`}
>
<Upload size={16} />
{editingType && editingType.iconId ? 'Change Icon' : 'Upload Icon'}
</button>
<input
id="custom-type-icon"
type="file"
bind:this={fileInput}
onchange={handleFileChange}
accept="image/*"
class="hidden"
/>
{#if editingType && editingType.iconId && iconUrls[editingType.iconId]}
<div class="relative w-10 h-10 rounded-lg overflow-hidden border border-neutral-700">
<img
src={iconUrls[editingType.iconId]}
alt="Preview"
class="w-full h-full object-cover"
/>
<button
onclick={async () => {
if (editingType?.iconId) {
await deleteImageBlob(editingType.iconId);
if (iconUrls[editingType.iconId])
URL.revokeObjectURL(iconUrls[editingType.iconId]);
delete iconUrls[editingType.iconId];
editingType.iconId = undefined;
}
}}
class="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 flex items-center justify-center transition-opacity"
>
<X size={14} class="text-white" />
</button>
</div>
{:else if newTypeIconUrl}
<div class="relative w-10 h-10 rounded-lg overflow-hidden border border-neutral-700">
<img src={newTypeIconUrl} alt="Preview" class="w-full h-full object-cover" />
<button
onclick={() => {
newTypeIconBlob = null;
if (newTypeIconUrl) URL.revokeObjectURL(newTypeIconUrl);
newTypeIconUrl = null;
}}
class="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 flex items-center justify-center transition-opacity"
>
<X size={14} class="text-white" />
</button>
</div>
{:else}
<div
class={`w-10 h-10 rounded-lg border-2 border-dashed flex items-center justify-center ${isLight ? 'border-amber-200' : 'border-neutral-700 text-neutral-600'}`}
>
<ImageIcon size={20} />
</div>
{/if}
</div>
</div>
</div>
{#if error}
<p class="text-xs text-rose-500 mt-2">{error}</p>
{/if}
<div class="flex gap-2 mt-4">
{#if editingType}
<button
onclick={() => {
editingType = null;
error = '';
}}
class={`flex-1 flex items-center justify-center gap-2 font-medium py-2 rounded-lg transition-colors border hover:brightness-110 ${inputClass}`}
>
Cancel
</button>
{/if}
<button
onclick={saveType}
class="flex-[2] flex items-center justify-center gap-2 bg-rose-600 hover:bg-rose-500 text-white font-medium py-2 rounded-lg transition-colors shadow-lg shadow-rose-900/20"
>
<Plus size={18} />
{editingType ? 'Save Changes' : 'Add Custom Type'}
</button>
</div>
</div>
<!-- Existing Types List -->
<div class="space-y-3">
<h5 class={`text-sm font-semibold uppercase tracking-wider ${mutedTextClass}`}>
Existing Custom Types ({customTypes.length})
</h5>
{#if customTypes.length === 0}
<p class={`text-sm italic ${isLight ? 'text-neutral-500' : 'text-neutral-500'}`}>
No custom types created yet.
</p>
{:else}
<div class="grid grid-cols-1 gap-2">
{#each customTypes as type}
<div
class={`flex items-center justify-between p-3 rounded-lg border ${
isLight ? 'bg-white border-amber-200' : 'bg-neutral-900 border-neutral-800'
} ${editingType?.id === type.id ? 'border-rose-500 ring-1 ring-rose-500' : ''}`}
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center border-2"
style={`background-color: ${type.color}22; border-color: ${type.color}`}
>
{#if type.iconId && iconUrls[type.iconId]}
<img
src={iconUrls[type.iconId]}
alt={type.name}
class="w-5 h-5 object-cover rounded"
/>
{:else}
<div
class="w-2 h-2 rounded-full"
style={`background-color: ${type.color}`}
></div>
{/if}
</div>
<span class={`font-medium ${isLight ? 'text-neutral-800' : 'text-neutral-200'}`}>
{type.name}
</span>
</div>
<div class="flex items-center gap-1">
<button
onclick={() => {
editingType = { ...type };
error = '';
}}
class="p-2 text-neutral-500 hover:text-rose-500 transition-colors"
title="Edit Type"
>
<Pencil size={18} />
</button>
<button
onclick={() => removeCustomType(type)}
class="p-2 text-neutral-500 hover:text-rose-500 transition-colors"
title="Delete Type"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div
class={`flex justify-end mt-6 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}
>
<button
class={`px-6 py-2 rounded-lg font-medium transition-colors hover:brightness-110 ${inputClass}`}
onclick={onClose}
>
Done
</button>
</div>
{/snippet}

View File

@@ -1,153 +0,0 @@
<script lang="ts">
import { X, GripHorizontal } from 'lucide-svelte';
import { onMount, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import { saveSetting, loadSetting } from '$lib/db';
interface Props {
id: string;
title: string;
open: boolean;
isLight: boolean;
theme: string;
surfaceClass: string;
onClose: () => void;
onFocus?: () => void;
children: Snippet;
defaultPosition?: { x: number; y: number };
minWidth?: number;
zIndex?: number;
}
let {
id,
title,
open,
isLight,
theme,
surfaceClass,
onClose,
onFocus,
children,
defaultPosition = { x: 100, y: 100 },
minWidth = 320,
zIndex = 50,
}: Props = $props();
let pos = $state(untrack(() => ({ x: defaultPosition.x, y: defaultPosition.y })));
let isReady = $state(false);
let isDragging = $state(false);
let dragOffset = { x: 0, y: 0 };
let windowElement = $state<HTMLDivElement | null>(null);
onMount(async () => {
const savedPos = await loadSetting(`window_pos_${id}`);
if (savedPos && typeof savedPos === 'string') {
try {
const parsed = JSON.parse(savedPos);
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
// Ensure it's within bounds
pos.x = Math.max(0, Math.min(parsed.x, window.innerWidth - 100));
pos.y = Math.max(0, Math.min(parsed.y, window.innerHeight - 100));
}
} catch (e) {
console.error(`Failed to parse saved position for ${id}`, e);
}
}
isReady = true;
});
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
isDragging = true;
dragOffset = {
x: e.clientX - pos.x,
y: e.clientY - pos.y,
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
let newX = e.clientX - dragOffset.x;
let newY = e.clientY - dragOffset.y;
// Keep on screen
if (windowElement) {
const rect = windowElement.getBoundingClientRect();
newX = Math.max(0, Math.min(newX, window.innerWidth - rect.width));
newY = Math.max(0, Math.min(newY, window.innerHeight - rect.height));
}
pos.x = newX;
pos.y = newY;
}
async function handleMouseUp() {
isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
await saveSetting(`window_pos_${id}`, JSON.stringify(pos));
}
// Function to reset position (called via event or prop if needed)
export async function resetPosition() {
pos.x = defaultPosition.x;
pos.y = defaultPosition.y;
await saveSetting(`window_pos_${id}`, JSON.stringify(pos));
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={windowElement}
class={`fixed rounded-xl border shadow-2xl overflow-hidden flex flex-col transition-opacity duration-150 ${surfaceClass}`}
style="left: {pos.x}px; top: {pos.y}px; min-width: {minWidth}px; z-index: {zIndex}; max-height: 90vh; opacity: {isReady
? 1
: 0}; pointer-events: {isReady ? 'auto' : 'none'};"
onmousedown={() => onFocus?.()}
>
<!-- Header / Drag Handle -->
<div
class={`flex items-center justify-between p-3 cursor-move border-b select-none ${
theme === 'cyberpunk'
? 'bg-[#1a1a2e] border-cyan-500/30'
: theme === 'oled'
? 'bg-black border-neutral-800'
: isLight
? 'bg-amber-50/50 border-amber-200'
: 'bg-neutral-900/50 border-neutral-800'
}`}
onmousedown={handleMouseDown}
role="presentation"
>
<div class="flex items-center gap-2">
<GripHorizontal size={14} class="text-neutral-500" />
<h4 class={`text-sm font-semibold ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
{title}
</h4>
</div>
<button
class={`p-1 rounded transition-colors ${
theme === 'cyberpunk'
? 'text-cyan-400 hover:text-fuchsia-400 hover:bg-fuchsia-500/10'
: isLight
? 'text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
aria-label="Close window"
>
<X size={16} />
</button>
</div>
<!-- Content -->
<div class="overflow-y-auto p-4 flex-1">
{@render children()}
</div>
</div>
{/if}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +0,0 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: RELATIONSHIP_COLORS access uses keys from controlled constant array (relationshipTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import {
RELATIONSHIP_COLORS,
relationshipTypes,
relationshipStrengths,
type RelationshipType,
type RelationshipStrength,
} from '$lib/constants';
import FloatingWindow from './FloatingWindow.svelte';
interface Props {
open: boolean;
isMobile: boolean;
editingLinkLabel: string;
editingLinkType: string;
editingLinkStrength: RelationshipStrength;
editingLinkTypeManuallyEdited: boolean;
linkEditInput: HTMLInputElement | null;
linkTypeEditInput: HTMLInputElement | null;
isLight: boolean;
theme: string;
mutedTextClass: string;
inputClass: string;
modalBackdropClass: string;
surfaceClass: string;
onLabelChange: (value: string) => void;
onTypeChange: (value: string) => void;
onTypeManuallyEdited: () => void;
onTypeButtonClick: (relType: RelationshipType) => void;
onStrengthChange: (strength: RelationshipStrength) => void;
onSave: () => void;
onCancel: () => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
editingLinkLabel,
editingLinkType,
editingLinkStrength,
linkEditInput,
linkTypeEditInput,
isLight,
theme,
mutedTextClass,
inputClass,
modalBackdropClass,
surfaceClass,
onLabelChange,
onTypeChange,
onTypeManuallyEdited,
onTypeButtonClick,
onStrengthChange,
onSave,
onCancel,
zIndex = 50,
onFocus,
}: Props = $props();
function handleLabelInput(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onLabelChange(target?.value ?? '');
}
function handleTypeInput(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onTypeChange(target?.value ?? '');
onTypeManuallyEdited();
}
function handleKeydown(event: KeyboardEvent, action: 'save' | 'cancel') {
if (event.key === 'Enter' && action === 'save') {
onSave();
}
if (event.key === 'Escape') {
onCancel();
}
}
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- 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) {
onCancel();
}
}}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
aria-label="Edit Link"
>
<div
class={`rounded-lg shadow-xl p-6 w-full max-w-md border ${surfaceClass}`}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
>
{@render linkEditContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="edit-link"
title="Edit Relationship"
{open}
{isLight}
{theme}
{surfaceClass}
onClose={onCancel}
{zIndex}
{onFocus}
>
{@render linkEditContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet linkEditContent()}
<h4 class={`text-lg font-semibold mb-4 ${isLight ? 'text-neutral-900' : 'text-gray-100'}`}>
Edit Relationship
</h4>
<div class="space-y-4">
<div>
<label for="linkLabel" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Label</label
>
<input
id="linkLabel"
bind:this={linkEditInput}
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass}`}
placeholder="Relationship label"
value={editingLinkLabel}
oninput={handleLabelInput}
onkeydown={(e) => handleKeydown(e, 'save')}
/>
</div>
<div>
<label for="linkType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Relationship Type</label
>
<input
id="linkType"
bind:this={linkTypeEditInput}
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass} mb-2`}
placeholder="Relationship type"
value={editingLinkType}
oninput={handleTypeInput}
onkeydown={(e) => handleKeydown(e, 'save')}
/>
<div class="grid grid-cols-3 gap-2" role="group" aria-label="Relationship Type">
{#each relationshipTypes as relType}
{@const relColor = RELATIONSHIP_COLORS[relType]}
<button
class={'rounded-lg border px-2 py-1.5 text-xs transition hover:brightness-110 ' +
(editingLinkType === relType
? 'border-rose-500 bg-rose-500/10 text-rose-500'
: inputClass)}
onclick={() => onTypeButtonClick(relType)}
style={editingLinkType === relType ? `border-color: ${relColor}` : ''}
type="button"
>
{relType}
</button>
{/each}
</div>
</div>
<div>
<div class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Strength</div>
<div class="flex gap-2" role="group" aria-label="Relationship Strength">
{#each relationshipStrengths as strength}
<button
class={'flex-1 rounded-lg border px-3 py-2 text-xs transition hover:brightness-110 ' +
(editingLinkStrength === strength
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: inputClass)}
onclick={() => onStrengthChange(strength)}
>
{strength.charAt(0).toUpperCase() + strength.slice(1)}
</button>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button
class={`rounded-lg px-3 py-2 text-sm transition-colors hover:brightness-110 ${inputClass}`}
onclick={onCancel}>Cancel</button
>
<button
class="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-500 shadow-lg shadow-rose-900/20"
onclick={onSave}>Save</button
>
</div>
</div>
{/snippet}

View File

@@ -1,499 +0,0 @@
<script lang="ts">
/* eslint-disable security/detect-object-injection */
// Safe: iconMap and typeColors access uses keys from controlled constant array (nodeTypes),
// not user input. Even with base64 sharing, types are validated/normalized before use.
import { X } from 'lucide-svelte';
import {
iconMap,
nodeTypes,
typeColors,
ALLOWED_IMAGE_TYPES,
MAX_IMAGE_BYTES,
} from '$lib/constants';
import type { NodeType } from '$lib/constants';
import type { Node, ConnectedEdge, CustomType } from '$lib/types';
interface Props {
node: Node | null;
connectedEdges: ConnectedEdge[];
imageObjects: Map<string, string>;
customTypes: CustomType[];
isLight: boolean;
editLabel: string;
editType: NodeType | string;
editImageUrl: string;
editColor: string;
editNotes: string;
editShowLabel: boolean;
editShowType: boolean;
editShowNotes: boolean;
imageUploadError: string;
imageUploadInput: HTMLInputElement | null;
mutedTextClass: string;
inputClass: string;
dividerClass: string;
surfaceClass: string;
onClose: () => void;
onLabelChange: (value: string) => void;
onTypeChange: (type: NodeType | string) => void;
onImageUrlChange: (value: string) => void;
onColorChange: (value: string) => void;
onNotesChange: (value: string) => void;
onToggleShowLabel: () => void;
onToggleShowType: () => void;
onToggleShowNotes: () => void;
onTriggerImageUpload: () => void;
onImageFileSelected: (event: Event) => void;
onClearImage: () => void;
onDelete: () => void;
}
let {
node,
connectedEdges,
imageObjects,
customTypes = [],
isLight,
editLabel,
editType,
editImageUrl,
editColor,
editNotes,
editShowLabel,
editShowType,
editShowNotes,
imageUploadError,
imageUploadInput = $bindable(),
mutedTextClass,
inputClass,
dividerClass,
surfaceClass,
onClose,
onLabelChange,
onTypeChange,
onImageUrlChange,
onColorChange,
onNotesChange,
onToggleShowLabel,
onToggleShowType,
onToggleShowNotes,
onTriggerImageUpload,
onImageFileSelected,
onClearImage,
onDelete,
}: Props = $props();
function handleLabelInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onLabelChange(target?.value ?? '');
}
function handleImageUrlInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onImageUrlChange(target?.value ?? '');
}
function handleColorInputEvent(event: Event) {
const target = event.currentTarget as HTMLInputElement | null;
onColorChange(target?.value ?? '');
}
function handleNotesInputEvent(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement | null;
onNotesChange(target?.value ?? '');
}
const maxImageSizeMB = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
const allowedImageTypesStr = ALLOWED_IMAGE_TYPES.map((t) => t.split('/')[1].toUpperCase()).join(
'/'
);
</script>
{#if node}
<div
class={`absolute top-0 right-0 h-full w-full max-w-sm border-l backdrop-blur pointer-events-auto z-20 transition-colors ${surfaceClass}`}
>
<div
class={`flex items-center justify-between border-b px-4 py-3 transition-colors ${dividerClass.replace(
'hidden md:block md:',
''
)}`}
>
<div>
<div class={`text-xs uppercase tracking-[0.3em] ${mutedTextClass}`}>Entity</div>
<div class={`text-lg font-semibold ${isLight ? 'text-neutral-900' : 'text-white'}`}>
{node.label || 'Untitled'}
</div>
</div>
<button
class={`rounded-lg border p-1.5 transition-colors ${
isLight
? 'border-amber-300 text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'border-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-800'
}`}
onclick={onClose}
>
<X size={16} />
</button>
</div>
<div class="flex h-[calc(100%-64px)] flex-col overflow-y-auto px-4 py-4 gap-4">
<div>
<div class="flex items-center gap-3">
{#if node.imageUrl || (node.imageId && imageObjects.get(node.imageId))}
<img
src={node.imageUrl || imageObjects.get(node.imageId!)}
alt={node.label}
class={`h-16 w-16 rounded-2xl border object-cover shadow-lg ${dividerClass.replace('hidden md:block md:', '')}`}
/>
{:else}
{@const customType = customTypes.find((t) => t.id === node.type)}
<div
class={`h-16 w-16 rounded-2xl border flex items-center justify-center shadow-lg ${inputClass} ${dividerClass.replace('hidden md:block md:', '')}`}
>
{#if customType && customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt={customType.name}
class="h-10 w-10 object-cover rounded"
/>
{:else if customType}
<div
class="w-8 h-8 rounded-full border-2"
style={`background-color: ${customType.color}22; border-color: ${customType.color}`}
></div>
{:else}
{#snippet icon()}
{@const IconComponent = iconMap[node.type as NodeType]}
<IconComponent size={28} color={typeColors[node.type as NodeType]} />
{/snippet}
{@render icon()}
{/if}
</div>
{/if}
<div>
<div class="text-xs uppercase tracking-[0.3em] text-neutral-500">Type</div>
<div class="text-sm font-semibold text-neutral-200 capitalize">
{customTypes.find((t) => t.id === node.type)?.name || node.type}
</div>
{#if node.notes}
<div class="mt-1 text-xs text-amber-200 break-words">{node.notes}</div>
{/if}
</div>
</div>
</div>
<div class="space-y-3">
<div>
<label
for="inspector-label-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Label</label
>
<input
id="inspector-label-input"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editLabel}
oninput={handleLabelInputEvent}
placeholder="Entity Label"
/>
</div>
<div>
<label
for="inspector-color-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Node Color Override</label
>
<div class="flex gap-2">
<input
id="inspector-color-input"
type="color"
class="h-10 w-12 rounded border-0 bg-transparent cursor-pointer"
value={editColor ||
(editType && customTypes.find((t) => t.id === editType)?.color) ||
typeColors[editType as NodeType] ||
'#ef4444'}
oninput={handleColorInputEvent}
/>
<input
type="text"
class={`flex-1 rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editColor}
oninput={handleColorInputEvent}
placeholder="Custom Hex Color"
/>
{#if editColor}
<button
class={`px-2 py-1 rounded text-xs transition-colors ${
isLight
? 'bg-amber-100 text-neutral-600 hover:bg-amber-200'
: 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
}`}
onclick={() => onColorChange('')}
>
Clear
</button>
{/if}
</div>
</div>
<div>
<div class={`text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}>
Type
</div>
<div class="grid grid-cols-4 gap-2">
{#each nodeTypes as type}
<button
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
(editType === type
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700')}
type="button"
onclick={() => onTypeChange(type)}
title={type}
>
{#snippet typeButton()}
{@const IconComponent = iconMap[type]}
<IconComponent size={18} color={typeColors[type]} />
<span class="truncate w-full text-center">{type}</span>
{/snippet}
{@render typeButton()}
</button>
{/each}
{#each customTypes as customType}
<button
class={'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[11px] capitalize transition ' +
(editType === customType.id
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700')}
type="button"
onclick={() => onTypeChange(customType.id)}
title={customType.name}
>
<div
class="w-5 h-5 rounded-full flex items-center justify-center overflow-hidden"
style={`background-color: ${customType.color}22; border: 1.5px solid ${customType.color}`}
>
{#if customType.iconId && imageObjects.get(customType.iconId)}
<img
src={imageObjects.get(customType.iconId)}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-1.5 h-1.5 rounded-full"
style={`background-color: ${customType.color}`}
></div>
{/if}
</div>
<span class="truncate w-full text-center">{customType.name}</span>
</button>
{/each}
</div>
</div>
<div>
<label
for="inspector-image-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Custom Image URL</label
>
<input
id="inspector-image-input"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none ${inputClass}`}
value={editImageUrl}
oninput={handleImageUrlInputEvent}
placeholder="https://..."
/>
<div class="mt-2 flex gap-2 flex-wrap">
<button
class={`rounded-lg border px-3 py-1.5 text-xs 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'
}`}
type="button"
onclick={onTriggerImageUpload}
>
Upload Image
</button>
{#if node?.imageUrl || node?.imageId}
<button
class={`rounded-lg border px-3 py-1.5 text-xs 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'
}`}
type="button"
onclick={onClearImage}
>
Remove Image
</button>
{/if}
</div>
<input
type="file"
accept={ALLOWED_IMAGE_TYPES.join(',')}
class="hidden"
bind:this={imageUploadInput}
onchange={onImageFileSelected}
/>
<p class={`mt-1 text-[11px] ${mutedTextClass}`}>
Paste a URL or upload a {allowedImageTypesStr} ({maxImageSizeMB}MB max).
</p>
{#if imageUploadError}
<p class={`mt-1 text-[11px] ${isLight ? 'text-rose-600' : 'text-rose-300'}`}>
{imageUploadError}
</p>
{/if}
</div>
<div>
<label
for="inspector-notes-input"
class={`block text-xs font-semibold uppercase tracking-[0.2em] mb-1 ${mutedTextClass}`}
>Notes</label
>
<textarea
id="inspector-notes-input"
rows="4"
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none resize-none ${inputClass}`}
value={editNotes}
oninput={handleNotesInputEvent}
placeholder="Add analyst notes, identifiers, context..."
></textarea>
</div>
<div>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500 mb-2">
Display Options
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Label</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowLabel
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowLabel}
role="switch"
aria-checked={editShowLabel}
aria-label="Toggle show label"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowLabel ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Type Icon</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowType
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowType}
role="switch"
aria-checked={editShowType}
aria-label="Toggle show type icon"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowType ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Notes</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
editShowNotes
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: isLight
? 'bg-amber-200'
: 'bg-neutral-700'
}`}
onclick={onToggleShowNotes}
role="switch"
aria-checked={editShowNotes}
aria-label="Toggle show notes"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
editShowNotes ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
</div>
</div>
</div>
<div
class={`border rounded-xl p-3 transition-colors ${
isLight ? 'border-amber-200 bg-amber-50/60' : 'border-neutral-900 bg-neutral-900/40'
}`}
>
<div class={`text-xs uppercase tracking-[0.3em] mb-2 ${mutedTextClass}`}>Connections</div>
{#if connectedEdges.length > 0}
<div class="space-y-2">
{#each connectedEdges as edge}
<div
class={`rounded-lg border px-3 py-2 transition-colors ${
isLight
? 'border-amber-200 bg-amber-50/50'
: 'border-neutral-800 bg-neutral-900/40'
}`}
>
<div
class={`text-sm font-semibold ${isLight ? 'text-neutral-900' : 'text-neutral-100'}`}
>
{edge.otherNode?.label || 'Unknown entity'}
</div>
<div class={`text-xs capitalize ${mutedTextClass}`}>
{edge.otherNode?.type || 'entity'} • {edge.label}
</div>
</div>
{/each}
</div>
{:else}
<div class={`text-xs ${mutedTextClass}`}>
No linked entities yet. Shift + drag from this node to create relationships.
</div>
{/if}
</div>
<div class={`mt-auto pt-2 text-[11px] text-center ${mutedTextClass}`}>
Changes are applied and saved automatically.
</div>
<button
class={`rounded-lg border px-3 py-2 text-sm transition ${
isLight
? 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100'
: 'border-rose-900/40 bg-rose-900/10 text-rose-200 hover:bg-rose-900/20'
}`}
onclick={onDelete}
>
Delete Entity
</button>
</div>
</div>
{/if}

View File

@@ -1,283 +0,0 @@
<script lang="ts">
import { X, Sun, Moon, RotateCcw } from 'lucide-svelte';
import { GRID_SIZE } from '$lib/constants';
import type { AppTheme } from '$lib/types';
import { themeConfigs, themeNames, getThemeIcon } from '$lib/themes.svelte';
import FloatingWindow from './FloatingWindow.svelte';
import { clearWindowPositions } from '$lib/db';
import { toast } from '$lib/toast.svelte';
import type { Component } from 'svelte';
interface Props {
open: boolean;
isMobile: boolean;
showGrid: boolean;
gridOpacityMultiplier: number;
snapToGrid: boolean;
autoLinkingEnabled: boolean;
theme: AppTheme;
isLight: boolean;
modalBackdropClass: string;
surfaceClass: string;
dividerClass: string;
inputClass: string;
onClose: () => void;
onShowGridChange: (value: boolean) => void;
onGridOpacityChange: (value: number) => void;
onSnapToGridChange: (value: boolean) => void;
onAutoLinkingChange: (value: boolean) => void;
onThemeToggle: () => void;
onThemeChange: (theme: AppTheme) => void;
zIndex?: number;
onFocus?: () => void;
}
let {
open,
isMobile,
showGrid,
gridOpacityMultiplier,
snapToGrid,
autoLinkingEnabled,
theme,
isLight,
modalBackdropClass,
surfaceClass,
dividerClass,
inputClass,
onClose,
onShowGridChange,
onGridOpacityChange,
onSnapToGridChange,
onAutoLinkingChange,
onThemeToggle,
onThemeChange,
zIndex = 50,
onFocus,
}: Props = $props();
async function handleResetPositions() {
await clearWindowPositions();
toast.success('Window positions reset. Please refresh or reopen windows.');
}
/* eslint-disable security/detect-object-injection */
const themes: { id: AppTheme; name: string; icon: Component }[] = (
Object.keys(themeConfigs) as AppTheme[]
).map((id) => ({
id,
name: themeNames[id],
icon: getThemeIcon(id) as unknown as Component,
}));
</script>
{#if open}
{#if isMobile}
<!-- Mobile Modal (Blocking) -->
<!-- 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) {
onClose();
}
}}
onkeydown={(e) => e.key === 'Escape' && onClose()}
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}`}>
{@render settingsContent()}
</div>
</div>
{:else}
<!-- Desktop Floating Window (Non-blocking) -->
<FloatingWindow
id="settings"
title="Settings"
{open}
{isLight}
{theme}
{surfaceClass}
{onClose}
{zIndex}
{onFocus}
>
{@render settingsContent()}
</FloatingWindow>
{/if}
{/if}
{#snippet settingsContent()}
<div class="flex items-center justify-between mb-6">
<h4
class={`text-lg font-semibold transition-colors ${isLight ? 'text-neutral-900' : 'text-white'}`}
>
Settings
</h4>
{#if isMobile}
<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={onClose}
>
<X size={20} />
</button>
{/if}
</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>
<div class="flex items-center justify-between">
<span
class={`text-sm transition-colors ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Show Grid Squares</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
showGrid
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onShowGridChange(!showGrid)}
role="switch"
aria-checked={showGrid}
aria-label="Toggle grid squares"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
showGrid ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<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"
value={gridOpacityMultiplier}
oninput={(e) => onGridOpacityChange(Number((e.currentTarget as HTMLInputElement).value))}
class={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-rose-500 transition-colors ${inputClass}`}
/>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Snap to Grid ({GRID_SIZE}px)</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
snapToGrid
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onSnapToGridChange(!snapToGrid)}
role="switch"
aria-checked={snapToGrid}
aria-label="Toggle snap to grid"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
snapToGrid ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
<div class="flex items-center justify-between">
<span class={`text-sm ${isLight ? 'text-neutral-700' : 'text-neutral-300'}`}
>Disable Auto Linking</span
>
<button
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-200 focus:outline-none ${
!autoLinkingEnabled
? 'bg-rose-600 shadow-[0_0_8px_rgba(225,29,72,0.4)]'
: `${inputClass} border-transparent`
}`}
onclick={() => onAutoLinkingChange(!autoLinkingEnabled)}
role="switch"
aria-checked={!autoLinkingEnabled}
aria-label="Toggle auto linking"
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
!autoLinkingEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
></span>
</button>
</div>
</div>
<div class={`space-y-3 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">Theme</div>
<div class="grid grid-cols-2 gap-2">
{#each themes as t}
<button
class={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm ${
theme === t.id
? 'border-rose-500 bg-rose-500/10 text-rose-500 font-medium'
: `${inputClass} hover:brightness-110`
}`}
onclick={() => onThemeChange(t.id)}
>
<t.icon size={16} />
{t.name}
</button>
{/each}
</div>
<button
class={`w-full flex items-center justify-between px-4 py-2 mt-2 rounded-lg border transition-colors ${inputClass} hover:brightness-110`}
onclick={onThemeToggle}
>
<span class="text-xs opacity-70">Cycle Themes</span>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{themes.find((t) => t.id === theme)?.name}</span>
{#if isLight}
<Sun size={14} />
{:else}
<Moon size={14} />
{/if}
</div>
</button>
</div>
<div class={`space-y-3 pt-4 border-t ${dividerClass.replace('hidden md:block md:', '')}`}>
<div class="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">System</div>
<button
class={`w-full flex items-center justify-between px-4 py-2 rounded-lg border transition-colors ${inputClass} hover:brightness-110`}
onclick={handleResetPositions}
>
<span class="text-xs font-medium">Reset Window Positions</span>
<RotateCcw size={14} class="text-rose-500" />
</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={onClose}
>
Close
</button>
</div>
{/snippet}

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
import { themeConfigs } from '$lib/themes.svelte';
import type { AppTheme } from '$lib/types';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
import { fly } from 'svelte/transition';
interface Props {
theme: AppTheme;
}
/* eslint-disable security/detect-object-injection */
let { theme }: Props = $props();
let config = $derived(themeConfigs[theme]);
const typeStyles = {
success: {
icon: CheckCircle,
color: 'text-green-500',
bg: 'border-green-500/30',
},
error: {
icon: AlertCircle,
color: 'text-rose-500',
bg: 'border-rose-500/30',
},
warning: {
icon: AlertTriangle,
color: 'text-amber-500',
bg: 'border-amber-500/30',
},
info: {
icon: Info,
color: 'text-cyan-500',
bg: 'border-cyan-500/30',
},
};
</script>
<div
class="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 pointer-events-none w-full max-w-sm px-4 md:bottom-6"
>
{#each toast.toasts as t (t.id)}
{@const style = typeStyles[t.type]}
<div
in:fly={{ y: 20, duration: 300 }}
out:fly={{ y: -20, duration: 200 }}
class={`pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border shadow-2xl backdrop-blur-md transition-all ${config.surface} ${style.bg}`}
>
<style.icon size={18} class={style.color} />
<p class="text-sm flex-1 font-medium">{t.message}</p>
<button
onclick={() => toast.remove(t.id)}
class={`p-1 rounded-lg hover:bg-white/10 transition-colors ${config.muted}`}
>
<X size={14} />
</button>
</div>
{/each}
</div>

View File

@@ -1,349 +0,0 @@
<script lang="ts">
import {
Plus,
Link2,
Upload,
Download,
Undo2,
Redo2,
Maximize,
Trash2,
Share2,
HelpCircle,
Settings,
Layers,
MoreVertical,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
Sun,
Moon,
} from 'lucide-svelte';
interface Props {
isMobile: boolean;
mobileToolbarCollapsed: boolean;
desktopToolbarCollapsed: boolean;
isLinkMode: boolean;
panelClass: string;
iconButtonClass: string;
dividerClass: string;
undoCount: number;
redoCount: number;
isLight: boolean;
showMoreMenu: boolean;
moreMenuRef: HTMLDivElement | null;
onUndo: () => void;
onRedo: () => void;
onCenterView: () => void;
onImportGraph: () => void;
onExportGraph: () => void;
onShareGraph: () => void;
onClearGraph: () => void;
onShowSettings: () => void;
onShowCustomTypes: () => void;
onShowAddModal: () => void;
onShowShortcuts: () => void;
onToggleLinkMode: () => void;
onToggleMobileToolbar: () => void;
onToggleDesktopToolbar: () => void;
onToggleMoreMenu: (e: MouseEvent) => void;
onToggleTheme: () => void;
}
let {
isMobile,
mobileToolbarCollapsed,
desktopToolbarCollapsed,
isLinkMode,
panelClass,
iconButtonClass,
dividerClass,
undoCount,
redoCount,
isLight,
showMoreMenu = $bindable(),
moreMenuRef = $bindable(),
onUndo,
onRedo,
onCenterView,
onImportGraph,
onExportGraph,
onShareGraph,
onClearGraph,
onShowSettings,
onShowCustomTypes,
onShowAddModal,
onShowShortcuts,
onToggleLinkMode,
onToggleMobileToolbar,
onToggleDesktopToolbar,
onToggleMoreMenu,
onToggleTheme,
}: Props = $props();
</script>
<div
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 md:left-4 md:translate-x-0 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
? 'top-3 right-2 md:right-auto'
: 'top-3 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 w-[calc(100vw-0.25rem)] md:w-auto'} {desktopToolbarCollapsed
? 'md:top-0'
: 'md:top-4'}"
>
{#if (isMobile && !mobileToolbarCollapsed) || (!isMobile && !desktopToolbarCollapsed)}
<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 ${
mobileToolbarCollapsed ? 'hidden md:block' : ''
}`}
>
<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={onToggleTheme}>
{#if !isLight}
<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} ${isLinkMode ? 'bg-rose-500/20 text-rose-500 border-rose-500/50' : ''}`}
title="Linking Mode"
onclick={onToggleLinkMode}
>
<Link2 size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Settings" onclick={onShowSettings}>
<Settings size={18} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Custom Types" onclick={onShowCustomTypes}>
<Layers 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={onShowAddModal}
>
<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={onImportGraph}
>
<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={onExportGraph}
>
<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={onShareGraph}
>
<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={onShowShortcuts}
>
<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={onUndo}
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={onRedo}
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={onCenterView}>
<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={onClearGraph}
>
<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={onToggleMoreMenu}
>
<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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowAddModal();
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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onImportGraph();
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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onExportGraph();
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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShareGraph();
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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowSettings();
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 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onShowCustomTypes();
showMoreMenu = false;
}}
>
<Layers size={16} />
Custom Types
</button>
<button
class={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${
isLight
? 'text-neutral-700 hover:bg-amber-100'
: 'text-neutral-200 hover:bg-neutral-800/50'
}`}
onclick={() => {
onClearGraph();
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={onToggleMobileToolbar}
>
<ChevronLeft size={18} />
</button>
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:hidden`}
title="Collapse toolbar"
onclick={onToggleDesktopToolbar}
>
<ChevronUp size={18} />
</button>
</div>
</div>
{/if}
{#if mobileToolbarCollapsed}
<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={onToggleMobileToolbar}
>
<ChevronRight size={18} />
</button>
{/if}
{#if desktopToolbarCollapsed}
<button
class={`${iconButtonClass} hidden md:block mobile-landscape:hidden pointer-events-auto rounded-lg p-2 shadow-lg border ${panelClass} w-auto`}
title="Expand toolbar"
onclick={onToggleDesktopToolbar}
>
<ChevronDown size={18} />
</button>
{/if}
</div>

View File

@@ -4,8 +4,6 @@ export const DB_NAME = 'quad4-linking-db';
export const DB_VERSION = 2;
export const STORE_NAME = 'graphs';
export const SETTINGS_STORE = 'settings';
export const IMAGE_STORE = 'images';
export const CUSTOM_TYPES_STORE = 'custom_types';
export const UNDO_STORE = 'undo_stack';
export const REDO_STORE = 'redo_stack';
@@ -14,8 +12,6 @@ export const MAX_HISTORY = 100;
export const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
export const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
export const GRID_SIZE = 40;
export type RelationshipType =
| 'Linked'
| 'Works For'

View File

@@ -1,390 +0,0 @@
/* global IDBDatabase, IDBOpenDBRequest, IDBKeyRange */
import {
DB_NAME,
DB_VERSION,
STORE_NAME,
SETTINGS_STORE,
IMAGE_STORE,
CUSTOM_TYPES_STORE,
UNDO_STORE,
REDO_STORE,
MAX_HISTORY,
} from './constants';
import type { StoredGraphData, GraphState, CustomType } from './types';
let db: IDBDatabase | null = null;
export async function initDB(): Promise<IDBDatabase> {
if (db) return db;
if (typeof window === 'undefined' || !window.indexedDB) {
throw new Error('IndexedDB is not available');
}
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const requiredStores = [
STORE_NAME,
SETTINGS_STORE,
UNDO_STORE,
REDO_STORE,
IMAGE_STORE,
CUSTOM_TYPES_STORE,
];
const missingStores = requiredStores.filter(
(store) => !database.objectStoreNames.contains(store)
);
if (missingStores.length > 0) {
database.close();
const deleteRequest = window.indexedDB.deleteDatabase(DB_NAME);
deleteRequest.onsuccess = () => {
const recreateRequest = window.indexedDB.open(DB_NAME, DB_VERSION);
recreateRequest.onerror = () => reject(recreateRequest.error);
recreateRequest.onsuccess = () => {
db = recreateRequest.result;
resolve(db);
};
recreateRequest.onupgradeneeded = (event) => {
const target = event.target;
if (!target) return;
const newDatabase = (target as IDBOpenDBRequest).result;
if (!newDatabase.objectStoreNames.contains(STORE_NAME)) {
newDatabase.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
if (!newDatabase.objectStoreNames.contains(SETTINGS_STORE)) {
newDatabase.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
}
if (!newDatabase.objectStoreNames.contains(UNDO_STORE)) {
newDatabase.createObjectStore(UNDO_STORE, { keyPath: 'index' });
}
if (!newDatabase.objectStoreNames.contains(REDO_STORE)) {
newDatabase.createObjectStore(REDO_STORE, { keyPath: 'index' });
}
if (!newDatabase.objectStoreNames.contains(IMAGE_STORE)) {
newDatabase.createObjectStore(IMAGE_STORE, { keyPath: 'id' });
}
if (!newDatabase.objectStoreNames.contains(CUSTOM_TYPES_STORE)) {
newDatabase.createObjectStore(CUSTOM_TYPES_STORE, { keyPath: 'id' });
}
};
};
deleteRequest.onerror = () => reject(deleteRequest.error);
} else {
db = database;
resolve(db);
}
};
request.onupgradeneeded = (event) => {
const target = event.target;
if (!target) return;
const database = (target as IDBOpenDBRequest).result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(SETTINGS_STORE)) {
database.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
}
if (!database.objectStoreNames.contains(UNDO_STORE)) {
database.createObjectStore(UNDO_STORE, { keyPath: 'index' });
}
if (!database.objectStoreNames.contains(REDO_STORE)) {
database.createObjectStore(REDO_STORE, { keyPath: 'index' });
}
if (!database.objectStoreNames.contains(IMAGE_STORE)) {
database.createObjectStore(IMAGE_STORE, { keyPath: 'id' });
}
if (!database.objectStoreNames.contains(CUSTOM_TYPES_STORE)) {
database.createObjectStore(CUSTOM_TYPES_STORE, { keyPath: 'id' });
}
};
});
}
export async function saveToIndexedDB(key: string, data: StoredGraphData): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const record = {
id: key,
data,
timestamp: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save to IndexedDB:', err);
}
}
export async function loadFromIndexedDB(key: string): Promise<StoredGraphData | null> {
try {
const database = await initDB();
const transaction = database.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.data : null);
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load from IndexedDB:', err);
return null;
}
}
export async function saveSetting(key: string, value: string | number | boolean): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readwrite');
const store = transaction.objectStore(SETTINGS_STORE);
const record = { key, value };
return new Promise((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save setting:', err);
}
}
export async function loadSetting(key: string): Promise<string | number | boolean | null> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readonly');
const store = transaction.objectStore(SETTINGS_STORE);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
resolve(result ? result.value : null);
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load setting:', err);
return null;
}
}
export async function clearWindowPositions(): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([SETTINGS_STORE], 'readwrite');
const store = transaction.objectStore(SETTINGS_STORE);
return new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
if (typeof cursor.key === 'string' && cursor.key.startsWith('window_pos_')) {
cursor.delete();
}
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to clear window positions:', err);
}
}
export async function storeImageBlob(id: string, blob: Blob): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.put({ id, blob, timestamp: new Date().toISOString() });
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to store image blob:', err);
}
}
export async function loadImageBlob(id: string): Promise<Blob | null> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readonly');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result ? request.result.blob : null);
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load image blob:', err);
return null;
}
}
export async function deleteImageBlob(id: string): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([IMAGE_STORE], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to delete image blob:', err);
}
}
export async function saveCustomType(customType: CustomType): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readwrite');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.put(customType);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to save custom type:', err);
}
}
export async function loadCustomTypes(): Promise<CustomType[]> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readonly');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to load custom types:', err);
return [];
}
}
export async function deleteCustomType(id: string): Promise<void> {
try {
const database = await initDB();
const transaction = database.transaction([CUSTOM_TYPES_STORE], 'readwrite');
const store = transaction.objectStore(CUSTOM_TYPES_STORE);
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (err) {
console.error('Failed to delete custom type:', err);
}
}
export async function pushToStack(storeName: string, state: GraphState) {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise<void>((resolve, reject) => {
const countRequest = store.count();
countRequest.onsuccess = () => {
const count = countRequest.result;
const putRequest = store.put({ index: count, state });
putRequest.onsuccess = () => {
if (count + 1 > MAX_HISTORY) {
const deleteRequest = store.delete(IDBKeyRange.upperBound(count - MAX_HISTORY));
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
} else {
resolve();
}
};
putRequest.onerror = () => reject(putRequest.error);
};
countRequest.onerror = () => reject(countRequest.error);
});
}
export async function popFromStack(storeName: string): Promise<GraphState | null> {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const countRequest = store.count();
countRequest.onsuccess = () => {
const count = countRequest.result;
if (count === 0) {
resolve(null);
return;
}
const getRequest = store.get(count - 1);
getRequest.onsuccess = () => {
const result = getRequest.result;
const deleteRequest = store.delete(count - 1);
deleteRequest.onsuccess = () => resolve(result ? result.state : null);
deleteRequest.onerror = () => reject(deleteRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
};
countRequest.onerror = () => reject(countRequest.error);
});
}
export async function clearStack(storeName: string) {
const database = await initDB();
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
return new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getStackCount(storeName: string): Promise<number> {
const database = await initDB();
const transaction = database.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
return new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

View File

@@ -1,154 +0,0 @@
import { Sun, Moon, Palette } from 'lucide-svelte';
import type { AppTheme } from './types';
export interface ThemeConfig {
bg: string;
surface: string;
panel: string;
input: string;
muted: string;
iconButton: string;
divider: string;
gridColor: string;
backdrop: string;
nodeBg: string;
nodeLabelBg: string;
}
export const themeConfigs: Record<AppTheme, ThemeConfig> = {
dark: {
bg: 'bg-neutral-950',
surface: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg',
panel: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg',
input: 'bg-neutral-800 border-neutral-700 text-neutral-100 placeholder-neutral-500',
muted: 'text-neutral-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-neutral-800 md:my-1',
gridColor: '255, 255, 255',
backdrop: 'bg-black/60',
nodeBg: '#171717',
nodeLabelBg: 'rgba(0, 0, 0, 0.5)',
},
light: {
bg: 'bg-amber-50',
surface: 'bg-white border-amber-200 text-neutral-900 shadow-amber-900/20',
panel: 'bg-white/95 border-amber-200 text-neutral-800 shadow-amber-900/10',
input: 'bg-amber-50 border-amber-300 text-neutral-900 placeholder-neutral-500',
muted: 'text-neutral-600',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100',
divider: 'hidden md:block md:h-px bg-neutral-300 md:my-1',
gridColor: '0, 0, 0',
backdrop: 'bg-black/30',
nodeBg: '#ffffff',
nodeLabelBg: 'rgba(255, 255, 255, 0.9)',
},
oled: {
bg: 'bg-black',
surface: 'bg-neutral-950 border-neutral-800 text-neutral-200 shadow-lg',
panel: 'bg-neutral-950 border-neutral-800 text-neutral-200 shadow-lg',
input: 'bg-neutral-900 border-neutral-800 text-neutral-100 placeholder-neutral-500',
muted: 'text-neutral-600',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-neutral-900 rounded text-neutral-500 hover:text-white',
divider: 'hidden md:block md:h-px bg-neutral-900 md:my-1',
gridColor: '255, 255, 255',
backdrop: 'bg-black/80',
nodeBg: '#000000',
nodeLabelBg: 'rgba(0, 0, 0, 0.8)',
},
midnight: {
bg: 'bg-slate-950',
surface: 'bg-slate-900 border-slate-800 text-slate-200 shadow-lg',
panel: 'bg-slate-900/95 border-slate-800 text-slate-200 shadow-lg',
input: 'bg-slate-800 border-slate-700 text-slate-100 placeholder-slate-500',
muted: 'text-slate-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-slate-800 rounded text-slate-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-slate-800 md:my-1',
gridColor: '148, 163, 184',
backdrop: 'bg-slate-950/70',
nodeBg: '#0f172a',
nodeLabelBg: 'rgba(15, 23, 42, 0.7)',
},
sepia: {
bg: 'bg-[#f4ecd8]',
surface: 'bg-[#fdf6e3] border-[#d3c6aa] text-[#5c6a72] shadow-sm',
panel: 'bg-[#fdf6e3]/95 border-[#d3c6aa] text-[#5c6a72] shadow-sm',
input: 'bg-[#f4ecd8] border-[#d3c6aa] text-[#5c6a72] placeholder-[#a6b0a0]',
muted: 'text-[#939f91]',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-[#5c6a72] hover:text-[#333] hover:bg-[#e9e0ca]',
divider: 'hidden md:block md:h-px bg-[#d3c6aa] md:my-1',
gridColor: '92, 106, 114',
backdrop: 'bg-[#5c6a72]/20',
nodeBg: '#fdf6e3',
nodeLabelBg: 'rgba(253, 246, 227, 0.9)',
},
slate: {
bg: 'bg-zinc-950',
surface: 'bg-zinc-900 border-zinc-800 text-zinc-200 shadow-lg',
panel: 'bg-zinc-900/95 border-zinc-800 text-zinc-200 shadow-lg',
input: 'bg-zinc-800 border-zinc-700 text-zinc-100 placeholder-zinc-500',
muted: 'text-zinc-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-white',
divider: 'hidden md:block md:h-px bg-zinc-800 md:my-1',
gridColor: '161, 161, 170',
backdrop: 'bg-zinc-950/70',
nodeBg: '#18181b',
nodeLabelBg: 'rgba(24, 24, 27, 0.7)',
},
cyberpunk: {
bg: 'bg-[#0a0a12]',
surface:
'bg-[#1a1a2e]/95 border-[#ff00ff]/30 text-cyan-400 shadow-[0_0_15px_rgba(255,0,255,0.1)]',
panel:
'bg-[#1a1a2e]/95 border-[#ff00ff]/30 text-cyan-400 shadow-[0_0_15px_rgba(255,0,255,0.1)]',
input: 'bg-[#0f0f1a] border-cyan-500/50 text-yellow-400 placeholder-cyan-900',
muted: 'text-fuchsia-500/70',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 hover:bg-fuchsia-500/10 rounded text-cyan-500 hover:text-fuchsia-400',
divider: 'hidden md:block md:h-px bg-cyan-900 md:my-1',
gridColor: '0, 255, 255',
backdrop: 'bg-[#0a0a12]/80',
nodeBg: '#0a0a12',
nodeLabelBg: 'rgba(10, 10, 18, 0.8)',
},
paper: {
bg: 'bg-white',
surface: 'bg-white border-neutral-300 text-neutral-900 shadow-md',
panel: 'bg-white/95 border-neutral-300 text-neutral-900 shadow-sm',
input: 'bg-neutral-50 border-neutral-300 text-neutral-900 placeholder-neutral-400',
muted: 'text-neutral-500',
iconButton:
'p-3 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-black hover:bg-neutral-100',
divider: 'hidden md:block md:h-px bg-neutral-200 md:my-1',
gridColor: '0, 0, 0',
backdrop: 'bg-black/10',
nodeBg: '#ffffff',
nodeLabelBg: 'rgba(255, 255, 255, 0.9)',
},
};
export const themeNames: Record<AppTheme, string> = {
dark: 'Dark',
light: 'Light',
oled: 'OLED Black',
midnight: 'Midnight Blue',
sepia: 'Sepia',
slate: 'Slate Gray',
cyberpunk: 'Cyberpunk',
paper: 'Paper White',
};
export function getIsLight(theme: AppTheme): boolean {
return theme === 'light' || theme === 'sepia' || theme === 'paper';
}
export function getThemeIcon(theme: AppTheme) {
if (theme === 'sepia' || theme === 'cyberpunk') return Palette;
if (theme === 'light' || theme === 'paper') return Sun;
return Moon;
}

View File

@@ -1,50 +0,0 @@
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
class ToastManager {
#toasts = $state<Toast[]>([]);
get toasts() {
return this.#toasts;
}
add(message: string, type: ToastType = 'info', duration = 3000) {
const id = Math.random().toString(36).slice(2, 9);
const toast: Toast = { id, message, type, duration };
this.#toasts.push(toast);
if (duration > 0) {
setTimeout(() => {
this.remove(id);
}, duration);
}
}
remove(id: string) {
this.#toasts = this.#toasts.filter((t) => t.id !== id);
}
success(message: string, duration?: number) {
this.add(message, 'success', duration);
}
error(message: string, duration?: number) {
this.add(message, 'error', duration);
}
info(message: string, duration?: number) {
this.add(message, 'info', duration);
}
warning(message: string, duration?: number) {
this.add(message, 'warning', duration);
}
}
export const toast = new ToastManager();

View File

@@ -1,59 +0,0 @@
import type { NodeType, RelationshipType, RelationshipStrength } from './constants';
export type Node = {
id: string;
label: string;
type: NodeType | string;
x: number;
y: number;
notes?: string;
imageUrl?: string;
imageId?: string;
color?: string;
showLabel?: boolean;
showType?: boolean;
showNotes?: boolean;
};
export type CustomType = {
id: string;
name: string;
color: string;
iconId?: string; // Stored in IMAGE_STORE
};
export type Link = {
id: string;
source: string;
target: string;
label: string;
type?: RelationshipType;
strength?: RelationshipStrength;
};
export type GraphState = {
nodes: Node[];
links: Link[];
};
export type StoredGraphData = {
nodes: Node[];
links: Link[];
transform: { x: number; y: number; k: number };
};
export type ConnectedEdge = {
id: string;
label: string;
otherNode: Node | null;
};
export type AppTheme =
| 'dark'
| 'light'
| 'oled'
| 'midnight'
| 'sepia'
| 'slate'
| 'cyberpunk'
| 'paper';

View File

@@ -21,11 +21,7 @@
}
onMount(() => {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:')
) {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {

View File

@@ -1,5 +1,22 @@
<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 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>
@@ -18,8 +35,41 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</svelte:head>
<div class="flex flex-col h-screen h-[100dvh] bg-bg-primary text-text-primary overflow-hidden">
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0">
<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">
<span
>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
>
-
<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>
</footer>
</div>

View File

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