Add per-notebook icons and sync

This commit is contained in:
Yuri Karamian
2026-08-03 01:05:07 +02:00
parent e62404d4c0
commit 550d3ab3e9
7 changed files with 479 additions and 25 deletions
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import type { NotebookIconId } from '$lib/utils/notebook-icons';
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#if icon === 'book'}
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
{:else if icon === 'folder'}
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
{:else if icon === 'briefcase'}
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
{:else if icon === 'home'}
<path d="m3 11 9-8 9 8" />
<path d="M5 10v11h14V10M9 21v-7h6v7" />
{:else if icon === 'star'}
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
{:else if icon === 'heart'}
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
{:else if icon === 'lightbulb'}
<path d="M9 18h6M10 22h4" />
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
{:else if icon === 'code'}
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
{:else if icon === 'flask'}
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
<path d="M7 16h10" />
{:else if icon === 'graduation'}
<path d="m2 9 10-5 10 5-10 5Z" />
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
{:else if icon === 'calendar'}
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
{:else if icon === 'tasks'}
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
{:else if icon === 'archive'}
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
{:else if icon === 'globe'}
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
{:else if icon === 'plane'}
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
{:else}
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
{/if}
</svg>
+280 -18
View File
@@ -28,6 +28,13 @@
import type { NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
import NotebookGlyph from './NotebookGlyph.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
let { onViewChanged = () => {} }: {
onViewChanged?: () => void;
@@ -79,6 +86,8 @@
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let iconPickerNotebook = $state<NotebookEntry | null>(null);
let iconPickerElement = $state<HTMLDivElement | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
@@ -569,21 +578,38 @@
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
async function openIconPicker(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = nb;
await tick();
iconPickerElement?.focus();
}
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try {
const value = encodeBuiltinNotebookIcon(icon);
await setNotebookIcon(nb.relative_path, value);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
iconPickerNotebook = null;
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
}
async function handleCustomIcon(nb: NotebookEntry) {
iconPickerNotebook = null;
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = typeof selected === 'string' ? selected : selected;
// Read the file and save as attachment
const data = await readFile(filePath as string);
const fileName = (filePath as string).split('/').pop() || 'icon.png';
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative);
$notebookIcons = await getNotebookIcons();
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
@@ -591,9 +617,12 @@
async function handleRemoveIcon(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = null;
try {
await setNotebookIcon(nb.relative_path, null);
$notebookIcons = await getNotebookIcons();
const icons = { ...$notebookIcons };
delete icons[nb.relative_path];
$notebookIcons = icons;
} catch (e) {
console.error('Failed to remove notebook icon:', e);
}
@@ -601,7 +630,7 @@
function getNotebookIconSrc(nb: NotebookEntry): string | null {
const iconPath = $notebookIcons[nb.relative_path];
if (!iconPath) return null;
if (!iconPath || iconPath.startsWith('builtin:')) return null;
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null;
return convertFileSrc(`${vaultRoot}/${iconPath}`);
@@ -661,7 +690,7 @@
}
</script>
<svelte:window onclick={handleWindowClick} />
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
@@ -924,16 +953,10 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
Rename
</button>
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
Set Icon
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
</button>
{#if $notebookIcons[contextMenu.notebook.relative_path]}
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
Remove Icon
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
Delete
@@ -941,6 +964,49 @@
</div>
{/if}
{#if iconPickerNotebook}
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
<header class="icon-picker-header">
<div>
<h3>Notebook icon</h3>
<p>{iconPickerNotebook.name}</p>
</div>
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option}
<button
class="icon-picker-option"
class:active={$notebookIcons[iconPickerNotebook.relative_path] === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label}
title={option.label}
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
>
<NotebookGlyph icon={option.id} size={20} />
<span>{option.label}</span>
</button>
{/each}
</div>
<div class="icon-picker-actions">
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image...
</button>
{#if $notebookIcons[iconPickerNotebook.relative_path]}
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default
</button>
{/if}
</div>
</div>
</div>
{/if}
{#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
@@ -975,6 +1041,7 @@
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
{#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input
@@ -1063,7 +1130,9 @@
{:else}
<span class="chevron-spacer"></span>
{/if}
{#if iconSrc}
{#if builtinIcon}
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
{:else if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" />
{:else if isCollapsed || nb.children.length === 0}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
@@ -1419,6 +1488,15 @@
flex-shrink: 0;
}
.notebook-builtin-icon {
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex-shrink: 0;
}
.notebook-name {
flex: 1;
text-align: left;
@@ -1580,6 +1658,185 @@
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.icon-picker-actions {
display: flex;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
background: transparent;
}
.icon-picker-overlay.mobile {
place-items: end center;
padding:
20px
calc(12px + env(safe-area-inset-right, 0px))
calc(12px + env(safe-area-inset-bottom, 0px))
calc(12px + env(safe-area-inset-left, 0px));
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
font-size: 14px;
}
.delete-confirm-overlay {
position: fixed;
inset: 0;
@@ -1718,6 +1975,11 @@
height: 24px;
}
.sidebar.mobile .notebook-builtin-icon {
width: 24px;
height: 24px;
}
.sidebar.mobile .new-notebook-input {
padding: 4px 12px;
}
+50
View File
@@ -0,0 +1,50 @@
export const NOTEBOOK_ICON_OPTIONS = [
{ id: 'book', label: 'Book' },
{ id: 'folder', label: 'Folder' },
{ id: 'briefcase', label: 'Work' },
{ id: 'home', label: 'Home' },
{ id: 'star', label: 'Star' },
{ id: 'heart', label: 'Heart' },
{ id: 'lightbulb', label: 'Ideas' },
{ id: 'code', label: 'Code' },
{ id: 'flask', label: 'Research' },
{ id: 'graduation', label: 'Study' },
{ id: 'calendar', label: 'Calendar' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'archive', label: 'Archive' },
{ id: 'globe', label: 'Globe' },
{ id: 'plane', label: 'Travel' },
{ id: 'palette', label: 'Creative' }
] as const;
export type NotebookIconId = (typeof NOTEBOOK_ICON_OPTIONS)[number]['id'];
const BUILTIN_PREFIX = 'builtin:';
const BUILTIN_IDS: Record<NotebookIconId, true> = {
book: true,
folder: true,
briefcase: true,
home: true,
star: true,
heart: true,
lightbulb: true,
code: true,
flask: true,
graduation: true,
calendar: true,
tasks: true,
archive: true,
globe: true,
plane: true,
palette: true
};
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
return `${BUILTIN_PREFIX}${icon}`;
}
export function decodeBuiltinNotebookIcon(value: string | null | undefined): NotebookIconId | null {
if (!value?.startsWith(BUILTIN_PREFIX)) return null;
const icon = value.slice(BUILTIN_PREFIX.length);
return Object.hasOwn(BUILTIN_IDS, icon) ? icon as NotebookIconId : null;
}