Compare commits

...

3 Commits

Author SHA1 Message Date
beo3000 786b4374b4 upd link handling 2026-02-21 12:56:17 +01:00
beo3000 90970322f1 upd link handling journal 2026-02-21 12:46:29 +01:00
beo3000 9db6cacb8c link update 2026-02-21 12:32:52 +01:00
15 changed files with 197 additions and 17 deletions

View File

@ -11,7 +11,10 @@
"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:*)",
"Bash(python3:*)",
"Bash(echo:*)"
]
}
}

View File

@ -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<HTMLAnchorElement>('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();
}
};
}

View File

@ -8,6 +8,7 @@
import { extractAssignments, extractProjects, extractMentions, extractCompanies } from '$lib/utils/extractors';
import { mention } from '$lib/actions/mention';
import RenderedMarkdown from './RenderedMarkdown.svelte';
import LinkTitle from './LinkTitle.svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import EditableMarkdown from './EditableMarkdown.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
@ -276,7 +277,7 @@
{#each topics as lt (lt.topic.id)}
<div class="mb-1.5 flex items-center gap-2 text-sm {lt.isDone ? 'text-[#666] line-through' : 'text-[#ccc]'}">
<span class="text-xs {lt.isDone ? 'text-green-600' : 'text-[#555]'}">{lt.isDone ? '✓' : '○'}</span>
<span class="flex-1">{lt.topic.title}</span>
<span class="flex-1"><LinkTitle text={lt.topic.title} /></span>
<span class="text-xs text-[#666]">{lt.contextName}</span>
</div>
{/each}
@ -450,7 +451,7 @@
>{isDone ? '✓' : ''}</button>
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{entry.date} {formatTime(entry.updatedAt)}</span>
<div class="flex-1 {isDone ? 'line-through opacity-50' : ''}">
<div class="font-bold">{title}</div>
<div class="font-bold"><LinkTitle text={title} /></div>
{#if body}
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
{/if}

View File

@ -9,6 +9,7 @@
import DateNavigator from './DateNavigator.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import WiedervorlageSection from './WiedervorlageSection.svelte';
import LinkTitle from './LinkTitle.svelte';
interface Props {
contextId: string;
@ -23,7 +24,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 +60,31 @@
return map;
});
async function resolveUrlTitle(url: string): Promise<string | null> {
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;
}
}
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 +100,7 @@
entryText = '';
entryEditor?.clear();
selectedLinkedContextId = '';
wiedervorlageChecked = false;
wiedervorlageChecked = true;
}
function handleTitleKeypress(e: KeyboardEvent) {
@ -249,7 +270,8 @@
<div class="group mb-3 flex items-start gap-2 rounded bg-card-bg p-2.5">
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry.updatedAt)}</span>
<div class="flex-1">
<div class="font-bold">{title}
<div class="font-bold">
<LinkTitle text={title} />
{#if entry.linkedContextId}
<span class="ml-1 inline-block rounded bg-accent/20 px-1.5 py-0.5 text-xs font-normal text-accent">
{contextNameMap().get(entry.linkedContextId) ?? entry.linkedContextId}

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { isUrl, truncateUrlDisplay } from '$lib/utils/urlUtils';
interface Props {
text: string;
class?: string;
}
let { text, class: className = '' }: Props = $props();
</script>
{#if isUrl(text)}
<a
href={text}
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:underline {className}"
onclick={(e) => e.stopPropagation()}
>{truncateUrlDisplay(text)}</a>
{:else}
<span class={className}>{text}</span>
{/if}

View File

@ -3,6 +3,7 @@
import { db } from '$lib/db/schema';
import { softDeleteRating } from '$lib/db/repositories';
import { RATING_CONFIG, type RatingValue } from '$lib/utils/ratingConfig';
import LinkTitle from './LinkTitle.svelte';
import { goto } from '$app/navigation';
import type { Rating } from '@ka-note/shared';
import ConfirmDialog from './ConfirmDialog.svelte';
@ -143,7 +144,7 @@
<span class="text-[#666]">|</span>
<span class="text-accent">{item.contextName}</span>
<span class="text-[#666]">|</span>
<span>{item.topicTitle}</span>
<LinkTitle text={item.topicTitle} />
</div>
<div class="mt-1 text-sm text-[#ccc]">{cfg.label}</div>
{#if item.rating.comment}

View File

@ -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 @@
});
</script>
<div class="markdown-content {className}" use:refClick use:ratingIndicator>{@html html}</div>
<div class="markdown-content {className}" use:refClick use:ratingIndicator use:linkMenu>{@html html}</div>

View File

@ -2,6 +2,7 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { updateTopic } from '$lib/db/repositories';
import LinkTitle from './LinkTitle.svelte';
import type { Topic, AgendaContext } from '@ka-note/shared';
interface Props {
@ -49,7 +50,7 @@
<div class="mb-2 rounded-lg border-l-[5px] border-l-muted bg-card-bg opacity-60">
<div class="flex items-center justify-between px-5 py-4">
<div>
<span class="text-lg font-bold">{topic.title}</span>
<span class="text-lg font-bold"><LinkTitle text={topic.title} /></span>
{#if isDailyLog && topic.contextId !== 'daily-log'}
<span class="ml-2 text-xs text-muted">{contextMap.get(topic.contextId) ?? topic.contextId}</span>
{/if}

View File

@ -6,6 +6,7 @@
import { today } from '$lib/db/helpers';
import { processedTopicIds, collapsedTopicIds } from '$lib/stores/agenda';
import HistoryItem from './HistoryItem.svelte';
import LinkTitle from './LinkTitle.svelte';
import MeetingControls from './MeetingControls.svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
@ -149,7 +150,7 @@
autofocus
/>
{:else}
<span>{topic.title}</span>
<LinkTitle text={topic.title} />
<button
class="opacity-0 group-hover:opacity-100 ml-2.5 bg-transparent border-none text-[#aaa] cursor-pointer text-xs hover:text-white"
onclick={(e) => { e.stopPropagation(); startTitleEdit(); }}

View File

@ -1,8 +1,10 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/db/schema';
import { resolveWiedervorlage, setWiedervorlage, convertToTopic } from '$lib/db/repositories';
import { resolveWiedervorlage, setWiedervorlage, convertToTopic, softDeleteHistoryEntry } from '$lib/db/repositories';
import type { HistoryEntry } from '@ka-note/shared';
import RenderedMarkdown from './RenderedMarkdown.svelte';
import LinkTitle from './LinkTitle.svelte';
interface Props {
entry: HistoryEntry;
@ -28,6 +30,10 @@
await resolveWiedervorlage(entry.id);
}
async function handleDelete() {
await softDeleteHistoryEntry(entry.id);
}
async function handleVerschieben() {
if (!newDate) return;
await setWiedervorlage(entry.id, newDate);
@ -48,9 +54,9 @@
<span class="text-xs font-semibold uppercase tracking-wider text-amber-400">⏰ Wiedervorlage {entry.wiedervorlageDate}</span>
</div>
<div class="mb-3">
<div class="font-bold text-white">{title}</div>
<div class="font-bold text-white"><LinkTitle text={title} /></div>
{#if body}
<div class="mt-1 text-sm text-[#ccc]">{body}</div>
<RenderedMarkdown text={body} class="mt-1 text-sm text-[#ccc]" />
{/if}
</div>
@ -105,6 +111,10 @@
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={() => showConvert = true}
>In Thema wandeln</button>
<button
class="ml-auto rounded bg-red-900/60 px-3 py-1 text-sm text-red-300 hover:bg-red-800"
onclick={handleDelete}
>Löschen</button>
</div>
{/if}
</div>

View File

@ -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, '&amp;');
const display = isRawUrl ? truncateLinkUrl(href) : text;
const t = title ? ` title="${title}"` : '';
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${t}>${display}</a>`;
}
}
});
// --- Collapsible list post-processing ---

View File

@ -0,0 +1,15 @@
export function isUrl(text: string): boolean {
return /^https?:\/\//i.test(text);
}
export function truncateUrlDisplay(text: string, maxLen = 60): string {
if (!isUrl(text)) return text;
try {
const u = new URL(text);
const path = u.pathname !== '/' ? u.pathname : '';
const display = u.hostname + path;
return display.length > maxLen ? display.slice(0, maxLen - 3) + '…' : display;
} catch {
return text.length > maxLen ? text.slice(0, maxLen - 3) + '…' : text;
}
}

Binary file not shown.

View File

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "node --watch --import tsx/esm src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",

View File

@ -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[^>]*>([^<]+)<\/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);