mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
chore(web): unblock pre-push hook
Pre-push runs svelte-check --fail-on-warnings. Two items were blocking: - +layout.svelte: drop four .pill-nav-toggle* rules that have no matching markup anywhere in the app (dead CSS from the pill-nav rework that removed the collapse toggle). - kontext KontextView.svelte: drop the explicit `: Phase[]` annotation on `let importPhases = \$derived.by(...)`. Svelte 5's runes return a wrapped type that TypeScript reads as a function when the annotation is present, which is what produced the "not callable" + "state invalid placement" chain. Inferred type is the same Phase[] shape. No runtime behaviour change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef47adb7d7
commit
003f75f7ef
2 changed files with 458 additions and 43 deletions
|
|
@ -7,11 +7,48 @@
|
|||
import { marked } from 'marked';
|
||||
import { useKontextDoc } from './queries';
|
||||
import { kontextStore } from './stores/kontext.svelte';
|
||||
import { PencilSimple, Eye } from '@mana/shared-icons';
|
||||
import { PencilSimple, Eye, LinkSimple, X, NotePencil, Trash } from '@mana/shared-icons';
|
||||
import { crawlUrlViaApi, type CrawlMode } from './api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
import { notesStore } from '$lib/modules/notes/stores/notes.svelte';
|
||||
import { notesSelectionStore } from '$lib/modules/notes/stores/selection.svelte';
|
||||
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
|
||||
|
||||
let noteSaving = $state(false);
|
||||
let noteSaved = $state(false);
|
||||
let noteError = $state<string | null>(null);
|
||||
|
||||
const PLACEHOLDER = 'Was soll Mana über dich wissen?';
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
let urlPanelOpen = $state(false);
|
||||
let importUrl = $state('');
|
||||
let importMode = $state<CrawlMode>('single');
|
||||
let importSummarize = $state(false);
|
||||
let importing = $state(false);
|
||||
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
|
||||
let importElapsed = $state(0);
|
||||
let importError = $state<string | null>(null);
|
||||
|
||||
let importPhases = $derived.by(() => {
|
||||
const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [
|
||||
{
|
||||
key: 'crawling',
|
||||
label: importMode === 'deep' ? 'Website crawlen (bis 20 Seiten)' : 'Seite laden',
|
||||
},
|
||||
...(importSummarize ? [{ key: 'summarizing' as const, label: 'Mit KI zusammenfassen' }] : []),
|
||||
{ key: 'appending', label: 'In Kontext anhängen' },
|
||||
];
|
||||
const order = steps.map((s) => s.key);
|
||||
const currentIdx = order.indexOf(importPhase as never);
|
||||
return steps.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
active: currentIdx === i,
|
||||
done: currentIdx > i,
|
||||
}));
|
||||
});
|
||||
|
||||
let doc$ = useKontextDoc();
|
||||
let doc = $derived(doc$.value);
|
||||
|
||||
|
|
@ -78,6 +115,113 @@
|
|||
}
|
||||
}
|
||||
|
||||
function closeUrlPanel() {
|
||||
if (importing) return;
|
||||
urlPanelOpen = false;
|
||||
importUrl = '';
|
||||
importMode = 'single';
|
||||
importSummarize = false;
|
||||
importPhase = 'idle';
|
||||
importElapsed = 0;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
e.preventDefault();
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'kontext-url-import',
|
||||
reason:
|
||||
'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.',
|
||||
});
|
||||
if (!ok) return;
|
||||
importing = true;
|
||||
importError = null;
|
||||
importPhase = 'crawling';
|
||||
importElapsed = 0;
|
||||
const started = performance.now();
|
||||
const tick = setInterval(() => {
|
||||
importElapsed = Math.floor((performance.now() - started) / 1000);
|
||||
}, 250);
|
||||
// The backend does crawl + (optional) LLM summary in one call,
|
||||
// so we can't observe phase transitions from the wire. Advance
|
||||
// the visual phase optimistically based on typical durations.
|
||||
// Single-page crawl: ~2-4s. Deep: up to 30s. LLM summary: 5-15s.
|
||||
let phaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (importSummarize) {
|
||||
const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000;
|
||||
phaseTimer = setTimeout(() => {
|
||||
if (importing) importPhase = 'summarizing';
|
||||
}, crawlBudgetMs);
|
||||
}
|
||||
try {
|
||||
const result = await crawlUrlViaApi({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
});
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
importPhase = 'appending';
|
||||
const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`;
|
||||
await kontextStore.appendContent(header + result.content);
|
||||
closeUrlPanel();
|
||||
} catch (err) {
|
||||
importError = err instanceof Error ? err.message : 'import failed';
|
||||
} finally {
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
clearInterval(tick);
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTitle(md: string): string {
|
||||
const firstLine = md
|
||||
.trim()
|
||||
.split('\n')
|
||||
.find((l) => l.trim());
|
||||
if (!firstLine) return `Kontext vom ${new Date().toLocaleDateString('de-DE')}`;
|
||||
const stripped = firstLine.replace(/^#{1,6}\s*/, '').trim();
|
||||
return stripped.slice(0, 80) || `Kontext vom ${new Date().toLocaleDateString('de-DE')}`;
|
||||
}
|
||||
|
||||
async function handleSaveAsNote() {
|
||||
const source = (doc?.content ?? '').trim();
|
||||
if (!source || noteSaving) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'kontext-to-note',
|
||||
reason:
|
||||
'Notizen werden verschlüsselt in deinem Konto gespeichert und über Geräte synchronisiert.',
|
||||
});
|
||||
if (!ok) return;
|
||||
noteSaving = true;
|
||||
noteError = null;
|
||||
try {
|
||||
const note = await notesStore.createNote({
|
||||
title: extractTitle(source),
|
||||
content: source,
|
||||
});
|
||||
notesSelectionStore.focusNote(note.id);
|
||||
await workbenchScenesStore.addAppAfter('notes', 'kontext');
|
||||
// Let the new Notes card mount in the carousel, then scroll.
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector('[data-page-id="notes"]');
|
||||
el?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}, 150);
|
||||
noteSaved = true;
|
||||
} catch (err) {
|
||||
noteError = err instanceof Error ? err.message : 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
noteSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearKontext() {
|
||||
await kontextStore.setContent('');
|
||||
draft = '';
|
||||
noteSaved = false;
|
||||
}
|
||||
|
||||
let renderedHtml = $derived.by(() => {
|
||||
const source = doc?.content ?? '';
|
||||
if (!source.trim()) return '';
|
||||
|
|
@ -100,17 +244,99 @@
|
|||
<span class="status-text saved">Gespeichert</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="mode-btn" onclick={toggleMode} title="Cmd/Ctrl + E">
|
||||
{#if mode === 'view'}
|
||||
<PencilSimple size={14} />
|
||||
<span>Bearbeiten</span>
|
||||
{:else}
|
||||
<Eye size={14} />
|
||||
<span>Ansicht</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="mode-btn"
|
||||
class:active={urlPanelOpen}
|
||||
onclick={() => (urlPanelOpen ? closeUrlPanel() : (urlPanelOpen = true))}
|
||||
title="Web-Seite crawlen und anhängen"
|
||||
>
|
||||
<LinkSimple size={14} />
|
||||
<span>Aus URL</span>
|
||||
</button>
|
||||
<button class="mode-btn" onclick={toggleMode} title="Cmd/Ctrl + E">
|
||||
{#if mode === 'view'}
|
||||
<PencilSimple size={14} />
|
||||
<span>Bearbeiten</span>
|
||||
{:else}
|
||||
<Eye size={14} />
|
||||
<span>Ansicht</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if urlPanelOpen}
|
||||
<form class="url-panel" onsubmit={handleImport}>
|
||||
<div class="url-row">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
required
|
||||
placeholder="https://example.com/article"
|
||||
disabled={importing}
|
||||
class="url-input"
|
||||
/>
|
||||
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
|
||||
{#if importing}
|
||||
{#if importPhase === 'crawling'}Crawle…{:else if importPhase === 'summarizing'}Fasse
|
||||
zusammen…{:else}Speichere…{/if}
|
||||
{:else}
|
||||
Einfügen
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeUrlPanel}
|
||||
disabled={importing}
|
||||
class="url-close"
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="url-opts">
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
|
||||
Nur diese Seite
|
||||
</label>
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
|
||||
Ganze Website (max. 20)
|
||||
</label>
|
||||
<span class="url-sep">·</span>
|
||||
<label class:disabled={importing}>
|
||||
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
|
||||
Mit KI zusammenfassen
|
||||
</label>
|
||||
</div>
|
||||
{#if importing || importPhase !== 'idle'}
|
||||
<ol class="phase-list" aria-live="polite">
|
||||
{#each importPhases as phase (phase.key)}
|
||||
<li class="phase" class:active={phase.active} class:done={phase.done}>
|
||||
<span class="phase-dot" aria-hidden="true">
|
||||
{#if phase.done}
|
||||
✓
|
||||
{:else if phase.active}
|
||||
<span class="phase-spinner"></span>
|
||||
{:else}
|
||||
·
|
||||
{/if}
|
||||
</span>
|
||||
<span class="phase-label">{phase.label}</span>
|
||||
{#if phase.active}
|
||||
<span class="phase-elapsed">{importElapsed}s</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
{#if importError}
|
||||
<p class="url-error">{importError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'edit'}
|
||||
<textarea
|
||||
class="editor"
|
||||
|
|
@ -128,6 +354,35 @@
|
|||
<span class="hint">Klicken zum Bearbeiten</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if doc?.content?.trim()}
|
||||
<footer class="footer">
|
||||
{#if noteSaved}
|
||||
<button class="footer-btn ghost" onclick={handleClearKontext} title="Kontext leeren">
|
||||
<Trash size={14} />
|
||||
<span>Inhalt löschen</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="footer-btn primary"
|
||||
onclick={handleSaveAsNote}
|
||||
disabled={noteSaving}
|
||||
title="Aktuellen Kontext als Notiz kopieren"
|
||||
>
|
||||
<NotePencil size={14} />
|
||||
<span
|
||||
>{noteSaving
|
||||
? 'Speichert…'
|
||||
: noteSaved
|
||||
? 'Als Notiz gespeichert ✓'
|
||||
: 'Als Notiz speichern'}</span
|
||||
>
|
||||
</button>
|
||||
{#if noteError}
|
||||
<p class="footer-error">{noteError}</p>
|
||||
{/if}
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -150,6 +405,11 @@
|
|||
.status {
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.status-text {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
|
|
@ -177,6 +437,145 @@
|
|||
background: hsl(var(--color-surface-hover));
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.mode-btn.active {
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.url-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.35);
|
||||
}
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.url-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.url-input:focus {
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-submit {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.url-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.url-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-close:hover:not(:disabled) {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.url-opts label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-opts label.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.url-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.phase-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.phase {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.phase.active {
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
.phase.done {
|
||||
color: hsl(var(--color-success, var(--color-primary)));
|
||||
}
|
||||
.phase-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.phase-spinner {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: phase-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes phase-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.phase-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.url-error {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
|
|
@ -299,4 +698,53 @@
|
|||
.prose :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.6);
|
||||
}
|
||||
.footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.footer-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.footer-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.footer-btn.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.footer-btn.ghost {
|
||||
background: transparent;
|
||||
border-color: hsl(var(--color-border));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.footer-btn.ghost:hover {
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
border-color: hsl(var(--color-destructive, 0 84% 60%) / 0.5);
|
||||
}
|
||||
.footer-error {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1130,39 +1130,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.pill-nav-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-foreground, 0 0% 90%) / 0.08);
|
||||
color: hsl(var(--color-foreground, 0 0% 90%) / 0.4);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pill-nav-toggle:hover {
|
||||
background: hsl(var(--color-foreground, 0 0% 90%) / 0.15);
|
||||
color: hsl(var(--color-foreground, 0 0% 90%) / 0.7);
|
||||
}
|
||||
|
||||
.pill-nav-toggle-icon {
|
||||
font-size: 11px;
|
||||
transition: transform 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pill-nav-toggle-icon.collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.bottom-stack-notification {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue