link update
This commit is contained in:
parent
71d5a6593a
commit
9db6cacb8c
|
|
@ -11,7 +11,8 @@
|
||||||
"Bash(npm run db:generate:*)",
|
"Bash(npm run db:generate:*)",
|
||||||
"Bash(docker ps:*)",
|
"Bash(docker ps:*)",
|
||||||
"Bash(docker compose:*)",
|
"Bash(docker compose:*)",
|
||||||
"Bash(az webapp config appsettings list:*)"
|
"Bash(az webapp config appsettings list:*)",
|
||||||
|
"Bash(node:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
let entryEditor: MarkdownEditor;
|
let entryEditor: MarkdownEditor;
|
||||||
let selectedDate = $state(today());
|
let selectedDate = $state(today());
|
||||||
let selectedLinkedContextId = $state('');
|
let selectedLinkedContextId = $state('');
|
||||||
let wiedervorlageChecked = $state(false);
|
let wiedervorlageChecked = $state(true);
|
||||||
|
|
||||||
// All meeting contexts for the link dropdown
|
// All meeting contexts for the link dropdown
|
||||||
const meetingContexts = liveQuery(() =>
|
const meetingContexts = liveQuery(() =>
|
||||||
|
|
@ -59,11 +59,42 @@
|
||||||
return map;
|
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() {
|
async function handleAddEntry() {
|
||||||
const title = entryTitle.trim();
|
let title = entryTitle.trim();
|
||||||
const body = entryText.trim();
|
let body = entryText.trim();
|
||||||
if (!title) return;
|
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) {
|
if (selectedLinkedContextId) {
|
||||||
const topic = await createTopic(selectedLinkedContextId, title);
|
const topic = await createTopic(selectedLinkedContextId, title);
|
||||||
if (body) {
|
if (body) {
|
||||||
|
|
@ -79,7 +110,7 @@
|
||||||
entryText = '';
|
entryText = '';
|
||||||
entryEditor?.clear();
|
entryEditor?.clear();
|
||||||
selectedLinkedContextId = '';
|
selectedLinkedContextId = '';
|
||||||
wiedervorlageChecked = false;
|
wiedervorlageChecked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTitleKeypress(e: KeyboardEvent) {
|
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">
|
<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>
|
<span class="mt-0.5 text-xs text-muted whitespace-nowrap">{formatTime(entry.updatedAt)}</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-bold">{title}
|
<div class="font-bold">{truncateUrlDisplay(title)}
|
||||||
{#if entry.linkedContextId}
|
{#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">
|
<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}
|
{contextNameMap().get(entry.linkedContextId) ?? entry.linkedContextId}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { resolveImageUrls } from '$lib/db/imageStore';
|
import { resolveImageUrls } from '$lib/db/imageStore';
|
||||||
import { refClick } from '$lib/actions/refClick';
|
import { refClick } from '$lib/actions/refClick';
|
||||||
import { ratingIndicator } from '$lib/actions/ratingIndicator';
|
import { ratingIndicator } from '$lib/actions/ratingIndicator';
|
||||||
|
import { linkMenu } from '$lib/actions/linkMenu';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -23,4 +24,4 @@
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
// --- Configure marked ---
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: 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 `<a href="${href}" target="_blank" rel="noopener noreferrer"${t}>${display}</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Collapsible list post-processing ---
|
// --- Collapsible list post-processing ---
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -17,6 +17,26 @@ app.use('/api/*', cors());
|
||||||
// Public routes
|
// Public routes
|
||||||
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
|
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
|
// Protected routes
|
||||||
app.use('/api/sync/*', authMiddleware);
|
app.use('/api/sync/*', authMiddleware);
|
||||||
app.route('/api/sync', syncRoutes);
|
app.route('/api/sync', syncRoutes);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue