link update

This commit is contained in:
beo3000 2026-02-21 12:32:52 +01:00
parent 71d5a6593a
commit 9db6cacb8c
7 changed files with 145 additions and 8 deletions

View File

@ -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:*)"
]
}
}

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

@ -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<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;
}
}
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 @@
<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">{truncateUrlDisplay(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

@ -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

@ -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 ---

Binary file not shown.

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);