Compare commits
3 Commits
71d5a6593a
...
786b4374b4
| Author | SHA1 | Date |
|---|---|---|
|
|
786b4374b4 | |
|
|
90970322f1 | |
|
|
9db6cacb8c |
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(); }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 `<a href="${href}" target="_blank" rel="noopener noreferrer"${t}>${display}</a>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Collapsible list post-processing ---
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue