diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3d4e665..92ea617 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(npm run db:generate:*)", "Bash(docker ps:*)", "Bash(docker compose:*)", - "Bash(az webapp config appsettings list:*)" + "Bash(az webapp config appsettings list:*)", + "Bash(node:*)" ] } } diff --git a/ka-note/client/src/lib/actions/linkMenu.ts b/ka-note/client/src/lib/actions/linkMenu.ts new file mode 100644 index 0000000..8428fde --- /dev/null +++ b/ka-note/client/src/lib/actions/linkMenu.ts @@ -0,0 +1,61 @@ +let activeMenu: HTMLDivElement | null = null; + +function closeMenu() { + if (activeMenu) { + activeMenu.remove(); + activeMenu = null; + } +} + +function showLinkMenu(href: string, x: number, y: number) { + closeMenu(); + + const menu = document.createElement('div'); + menu.style.cssText = ` + position: fixed; left: ${x}px; top: ${y}px; z-index: 200; + background: #2d2d2d; border: 1px solid #555; border-radius: 6px; + padding: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); + font-size: 0.85rem; + `; + + const items: { label: string; action: () => void }[] = [ + { label: 'Link kopieren', action: () => navigator.clipboard.writeText(href) }, + { label: 'Link öffnen', action: () => window.open(href, '_blank', 'noopener,noreferrer') } + ]; + + for (const item of items) { + const btn = document.createElement('button'); + btn.textContent = item.label; + btn.style.cssText = ` + display: block; width: 100%; text-align: left; + padding: 5px 12px; background: transparent; border: none; + color: #e0e0e0; cursor: pointer; border-radius: 4px; white-space: nowrap; + `; + btn.addEventListener('mouseenter', () => { btn.style.background = '#444'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); + btn.addEventListener('click', () => { item.action(); closeMenu(); }); + menu.appendChild(btn); + } + + document.body.appendChild(menu); + activeMenu = menu; + setTimeout(() => document.addEventListener('click', closeMenu, { once: true }), 0); +} + +export function linkMenu(node: HTMLElement) { + function onContextMenu(e: MouseEvent) { + const link = (e.target as HTMLElement).closest('a[href]'); + if (!link) return; + e.preventDefault(); + showLinkMenu(link.href, e.clientX, e.clientY); + } + + node.addEventListener('contextmenu', onContextMenu); + + return { + destroy() { + node.removeEventListener('contextmenu', onContextMenu); + closeMenu(); + } + }; +} diff --git a/ka-note/client/src/lib/components/JournalView.svelte b/ka-note/client/src/lib/components/JournalView.svelte index 7f856dd..c217167 100644 --- a/ka-note/client/src/lib/components/JournalView.svelte +++ b/ka-note/client/src/lib/components/JournalView.svelte @@ -23,7 +23,7 @@ let entryEditor: MarkdownEditor; let selectedDate = $state(today()); let selectedLinkedContextId = $state(''); - let wiedervorlageChecked = $state(false); + let wiedervorlageChecked = $state(true); // All meeting contexts for the link dropdown const meetingContexts = liveQuery(() => @@ -59,11 +59,42 @@ return map; }); + async function resolveUrlTitle(url: string): Promise { + try { + const res = await fetch(`/api/fetch-title?url=${encodeURIComponent(url)}`); + const data = await res.json() as { title?: string }; + return data.title || null; + } catch { + return null; + } + } + + function truncateUrlDisplay(text: string): string { + if (!/^https?:\/\//i.test(text)) return text; + try { + const u = new URL(text); + const path = u.pathname !== '/' ? u.pathname : ''; + const display = u.hostname + path; + return display.length > 60 ? display.slice(0, 57) + '…' : display; + } catch { + return text.length > 60 ? text.slice(0, 57) + '…' : text; + } + } + async function handleAddEntry() { - const title = entryTitle.trim(); - const body = entryText.trim(); + let title = entryTitle.trim(); + let body = entryText.trim(); if (!title) return; + // If title is a URL, try to fetch its page title + if (/^https?:\/\//i.test(title)) { + const fetched = await resolveUrlTitle(title); + if (fetched) { + body = body ? `[${title}](${title})\n${body}` : `[${title}](${title})`; + title = fetched; + } + } + if (selectedLinkedContextId) { const topic = await createTopic(selectedLinkedContextId, title); if (body) { @@ -79,7 +110,7 @@ entryText = ''; entryEditor?.clear(); selectedLinkedContextId = ''; - wiedervorlageChecked = false; + wiedervorlageChecked = true; } function handleTitleKeypress(e: KeyboardEvent) { @@ -249,7 +280,7 @@
{formatTime(entry.updatedAt)}
-
{title} +
{truncateUrlDisplay(title)} {#if entry.linkedContextId} {contextNameMap().get(entry.linkedContextId) ?? entry.linkedContextId} diff --git a/ka-note/client/src/lib/components/RenderedMarkdown.svelte b/ka-note/client/src/lib/components/RenderedMarkdown.svelte index 753e89d..096bc88 100644 --- a/ka-note/client/src/lib/components/RenderedMarkdown.svelte +++ b/ka-note/client/src/lib/components/RenderedMarkdown.svelte @@ -3,6 +3,7 @@ import { resolveImageUrls } from '$lib/db/imageStore'; import { refClick } from '$lib/actions/refClick'; import { ratingIndicator } from '$lib/actions/ratingIndicator'; + import { linkMenu } from '$lib/actions/linkMenu'; interface Props { text: string; @@ -23,4 +24,4 @@ }); -
{@html html}
+
{@html html}
diff --git a/ka-note/client/src/lib/utils/renderMarkdown.ts b/ka-note/client/src/lib/utils/renderMarkdown.ts index eb3203f..463fb36 100644 --- a/ka-note/client/src/lib/utils/renderMarkdown.ts +++ b/ka-note/client/src/lib/utils/renderMarkdown.ts @@ -71,12 +71,35 @@ const personMentionExt: TokenizerExtension & RendererExtension = { } }; +// --- Link helpers --- + +function truncateLinkUrl(url: string): string { + if (!url) return url; + try { + const u = new URL(url); + const path = u.pathname !== '/' ? u.pathname : ''; + const display = u.hostname + path + (u.search || ''); + return display.length > 50 ? display.slice(0, 47) + '…' : display; + } catch { + return url.length > 50 ? url.slice(0, 47) + '…' : url; + } +} + // --- Configure marked --- marked.use({ gfm: true, breaks: true, - extensions: [assignmentExt, projectRefExt, companyRefExt, personMentionExt] + extensions: [assignmentExt, projectRefExt, companyRefExt, personMentionExt], + renderer: { + link({ href, title, text }) { + if (!href) return text; + const isRawUrl = text === href || text === href.replace(/&/g, '&'); + const display = isRawUrl ? truncateLinkUrl(href) : text; + const t = title ? ` title="${title}"` : ''; + return `${display}`; + } + } }); // --- Collapsible list post-processing --- diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index eead079..b1bb41a 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/src/index.ts b/ka-note/server/src/index.ts index 76346e4..b8e4c8d 100644 --- a/ka-note/server/src/index.ts +++ b/ka-note/server/src/index.ts @@ -17,6 +17,26 @@ app.use('/api/*', cors()); // Public routes app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); +app.get('/api/fetch-title', async (c) => { + const url = c.req.query('url'); + if (!url) return c.json({ title: '' }); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + const res = await fetch(url, { + headers: { 'User-Agent': 'Mozilla/5.0' }, + signal: controller.signal + }); + clearTimeout(timer); + const html = await res.text(); + const match = /]*>([^<]+)<\/title>/i.exec(html); + const title = match ? match[1].trim().replace(/\s+/g, ' ') : ''; + return c.json({ title }); + } catch { + return c.json({ title: '' }); + } +}); + // Protected routes app.use('/api/sync/*', authMiddleware); app.route('/api/sync', syncRoutes);