Compare commits
22 Commits
renovate/h
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
a518c2e2eb
|
|||
|
2553f3ab8c
|
|||
|
5902896b9c
|
|||
|
e9471e9110
|
|||
|
aea77cb4b6
|
|||
|
59536df2ff
|
|||
|
44b1a1472f
|
|||
|
5ac482f6dc
|
|||
|
1da1e61cc5
|
|||
|
8feb48b044
|
|||
|
2e8b01483e
|
|||
|
232b63ecff
|
|||
|
8c16350e08
|
|||
|
ad568ecc22
|
|||
|
b99afb374f
|
|||
|
5911e3156f
|
|||
|
612d86127f
|
|||
|
3dff39f062
|
|||
|
8b3df40c9a
|
|||
|
0b11894b79
|
|||
|
5991451116
|
|||
|
2da685dd20
|
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,5 +1,46 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
- cd desktop && wails build -s -tags webkit2_41
|
||||
|
||||
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
|
||||
- cd desktop && wails build -s -platform linux/amd64 -tags webkit2_41
|
||||
|
||||
desktop-windows:
|
||||
desc: Build desktop application for Windows
|
||||
|
||||
@@ -64,6 +64,15 @@ 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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@quad4/linking-tool",
|
||||
"version": "1.5.3",
|
||||
"version": "1.6.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Quad4",
|
||||
"type": "module",
|
||||
|
||||
300
src/components/AddEntityModal.svelte
Normal file
300
src/components/AddEntityModal.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<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}
|
||||
468
src/components/CustomTypesModal.svelte
Normal file
468
src/components/CustomTypesModal.svelte
Normal file
@@ -0,0 +1,468 @@
|
||||
<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}
|
||||
153
src/components/FloatingWindow.svelte
Normal file
153
src/components/FloatingWindow.svelte
Normal file
@@ -0,0 +1,153 @@
|
||||
<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}
|
||||
File diff suppressed because it is too large
Load Diff
205
src/components/LinkEditModal.svelte
Normal file
205
src/components/LinkEditModal.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<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}
|
||||
499
src/components/NodeInspector.svelte
Normal file
499
src/components/NodeInspector.svelte
Normal file
@@ -0,0 +1,499 @@
|
||||
<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}
|
||||
283
src/components/SettingsModal.svelte
Normal file
283
src/components/SettingsModal.svelte
Normal file
@@ -0,0 +1,283 @@
|
||||
<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}
|
||||
60
src/components/ToastContainer.svelte
Normal file
60
src/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
349
src/components/Toolbar.svelte
Normal file
349
src/components/Toolbar.svelte
Normal file
@@ -0,0 +1,349 @@
|
||||
<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>
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
|
||||
@@ -12,6 +14,8 @@ 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'
|
||||
|
||||
390
src/lib/db.ts
Normal file
390
src/lib/db.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/* 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);
|
||||
});
|
||||
}
|
||||
154
src/lib/themes.svelte.ts
Normal file
154
src/lib/themes.svelte.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
}
|
||||
50
src/lib/toast.svelte.ts
Normal file
50
src/lib/toast.svelte.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
59
src/lib/types.ts
Normal file
59
src/lib/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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';
|
||||
@@ -21,7 +21,11 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
(window.location.protocol === 'http:' || window.location.protocol === 'https:')
|
||||
) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((reg) => {
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<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>
|
||||
@@ -35,41 +18,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
|
||||
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = '1.5.3';
|
||||
const CACHE_VERSION = '1.6.0';
|
||||
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
|
||||
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user