mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(cards-web): PDF input for AI generator + study activity heatmap
PDF input:
• lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via
Vite's `?worker` suffix so the heavy parsing runs off-main-thread.
• AiCardGen gains a "📄 PDF laden" button that pipes extracted text
into the same textarea — the user can review/trim before
generation. Reading state shows file name + page count + chars.
Heatmap:
• queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the
grid renders without holes.
• StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5
intensity buckets (neutral → emerald-300), tooltip per cell with
date + count, legend strip.
• Mounted on the dashboard between the deck list and the Anki import
so the user lands on a quick visual progress receipt every visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8a35afd99
commit
1f2206f10b
7 changed files with 375 additions and 10 deletions
|
|
@ -41,6 +41,7 @@
|
|||
"@mana/shared-utils": "workspace:*",
|
||||
"dexie": "^4.4.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"sql.js": "^1.14.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
apps/cards/apps/web/src/lib/ai/pdf.ts
Normal file
56
apps/cards/apps/web/src/lib/ai/pdf.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* PDF text extraction using pdfjs-dist.
|
||||
*
|
||||
* Loads each page, walks the text layer, joins items with spaces and
|
||||
* pages with double newlines so the LLM gets a structured input. We
|
||||
* don't try to preserve columns / tables — the use case is "feed me
|
||||
* the prose so I can make cards", not document fidelity.
|
||||
*
|
||||
* Worker is wired via Vite's `?worker` suffix so the heavy parsing
|
||||
* happens off the main thread (PDF extraction is CPU-heavy).
|
||||
*/
|
||||
|
||||
import * as pdfjs from 'pdfjs-dist';
|
||||
import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker';
|
||||
|
||||
let workerWired = false;
|
||||
function ensureWorker() {
|
||||
if (workerWired) return;
|
||||
pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker();
|
||||
workerWired = true;
|
||||
}
|
||||
|
||||
export interface PdfExtractResult {
|
||||
text: string;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export async function extractTextFromPdf(file: File | Blob): Promise<PdfExtractResult> {
|
||||
ensureWorker();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const doc = await pdfjs.getDocument({ data: new Uint8Array(buffer) }).promise;
|
||||
|
||||
const pages: string[] = [];
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
const pieces: string[] = [];
|
||||
for (const item of content.items) {
|
||||
if (typeof (item as { str?: string }).str === 'string') {
|
||||
pieces.push((item as { str: string }).str);
|
||||
}
|
||||
}
|
||||
pages.push(
|
||||
pieces
|
||||
.join(' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
await doc.destroy();
|
||||
return {
|
||||
text: pages.filter(Boolean).join('\n\n'),
|
||||
pageCount: doc.numPages,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { generateCardsFromText, type GeneratedCard } from '$lib/ai/generate';
|
||||
import { extractTextFromPdf } from '$lib/ai/pdf';
|
||||
import { cardStore } from '$lib/stores/cards.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,8 +11,12 @@
|
|||
|
||||
let { deckId, currentCardCount, onCreated }: Props = $props();
|
||||
|
||||
let stage = $state<'idle' | 'generating' | 'preview' | 'creating' | 'done' | 'error'>('idle');
|
||||
let stage = $state<
|
||||
'idle' | 'reading-pdf' | 'generating' | 'preview' | 'creating' | 'done' | 'error'
|
||||
>('idle');
|
||||
let source = $state('');
|
||||
let pdfPicker = $state<HTMLInputElement | null>(null);
|
||||
let pdfStatus = $state<string | null>(null);
|
||||
let generated = $state<GeneratedCard[]>([]);
|
||||
let selected = $state<boolean[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -72,6 +77,27 @@
|
|||
source = '';
|
||||
error = null;
|
||||
createdCount = 0;
|
||||
pdfStatus = null;
|
||||
}
|
||||
|
||||
async function handlePdfPick(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
error = null;
|
||||
stage = 'reading-pdf';
|
||||
pdfStatus = `Lese ${file.name}…`;
|
||||
try {
|
||||
const result = await extractTextFromPdf(file);
|
||||
source = result.text;
|
||||
pdfStatus = `${file.name} · ${result.pageCount} Seiten · ${result.text.length} Zeichen`;
|
||||
stage = 'idle';
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? 'PDF konnte nicht gelesen werden.';
|
||||
stage = 'error';
|
||||
pdfStatus = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -97,16 +123,36 @@
|
|||
{#if stage === 'error' && error}
|
||||
<p class="mt-2 text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>{source.length} Zeichen</span>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
onclick={handleGenerate}
|
||||
disabled={!source.trim()}
|
||||
>
|
||||
Generieren
|
||||
</button>
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-neutral-500">
|
||||
<div class="flex items-center gap-3">
|
||||
<span>{source.length} Zeichen</span>
|
||||
{#if pdfStatus}<span class="text-indigo-300">📄 {pdfStatus}</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-700 px-3 py-1.5 text-neutral-300 hover:bg-neutral-800"
|
||||
onclick={() => pdfPicker?.click()}
|
||||
>
|
||||
📄 PDF laden
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-1.5 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
onclick={handleGenerate}
|
||||
disabled={!source.trim()}
|
||||
>
|
||||
Generieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
bind:this={pdfPicker}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
class="hidden"
|
||||
onchange={handlePdfPick}
|
||||
/>
|
||||
{:else if stage === 'reading-pdf'}
|
||||
<div class="py-6 text-center text-sm text-neutral-400">{pdfStatus ?? 'Lese PDF…'}</div>
|
||||
{:else if stage === 'generating'}
|
||||
<div class="py-6 text-center text-sm text-neutral-400">Modell denkt nach…</div>
|
||||
{:else if stage === 'preview'}
|
||||
|
|
|
|||
93
apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte
Normal file
93
apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* GitHub-style activity grid: 7 rows (weekdays) × N columns (weeks).
|
||||
* Each cell encodes one day's review count via 5 color steps.
|
||||
* Tooltips on hover show the date + count.
|
||||
*
|
||||
* Week-start convention: we group by ISO week starting Monday so the
|
||||
* top row is always Mondays — matches the European calendar convention.
|
||||
*/
|
||||
|
||||
import { useStudyHeatmap } from '$lib/queries';
|
||||
|
||||
interface Props {
|
||||
weeks?: number;
|
||||
}
|
||||
let { weeks = 12 }: Props = $props();
|
||||
|
||||
const dataQuery = $derived(useStudyHeatmap(weeks));
|
||||
const rawDays = $derived(($dataQuery as { date: string; count: number }[] | undefined) ?? []);
|
||||
|
||||
// Pad to align the first day to a Monday so columns are full weeks.
|
||||
const grid = $derived.by(() => {
|
||||
if (rawDays.length === 0) return [] as { date: string | null; count: number }[];
|
||||
const first = new Date(rawDays[0].date);
|
||||
const dow = (first.getDay() + 6) % 7; // 0=Mon, 6=Sun
|
||||
const padded: { date: string | null; count: number }[] = [];
|
||||
for (let i = 0; i < dow; i++) padded.push({ date: null, count: 0 });
|
||||
padded.push(...rawDays);
|
||||
return padded;
|
||||
});
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const cols: { date: string | null; count: number }[][] = [];
|
||||
for (let i = 0; i < grid.length; i += 7) cols.push(grid.slice(i, i + 7));
|
||||
return cols;
|
||||
});
|
||||
|
||||
const max = $derived(rawDays.reduce((m, d) => Math.max(m, d.count), 0));
|
||||
|
||||
function bucket(count: number): string {
|
||||
if (count === 0) return 'bg-neutral-800';
|
||||
if (count <= Math.max(1, max * 0.25)) return 'bg-emerald-900';
|
||||
if (count <= max * 0.5) return 'bg-emerald-700';
|
||||
if (count <= max * 0.75) return 'bg-emerald-500';
|
||||
return 'bg-emerald-300';
|
||||
}
|
||||
|
||||
function fmt(date: string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const total = $derived(rawDays.reduce((sum, d) => sum + d.count, 0));
|
||||
const activeDays = $derived(rawDays.filter((d) => d.count > 0).length);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-4">
|
||||
<div class="mb-3 flex items-center justify-between text-sm">
|
||||
<span class="font-medium">Lernaktivität</span>
|
||||
<span class="text-xs text-neutral-500">
|
||||
{total} Karten · {activeDays} aktive {activeDays === 1 ? 'Tag' : 'Tage'} · letzte {weeks} Wochen
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1 overflow-x-auto">
|
||||
{#each columns as col, ci (ci)}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each col as cell, ri (ri)}
|
||||
{#if cell.date === null}
|
||||
<div class="h-3 w-3"></div>
|
||||
{:else}
|
||||
<div
|
||||
class="h-3 w-3 rounded-sm {bucket(cell.count)}"
|
||||
title="{fmt(cell.date)}: {cell.count} {cell.count === 1 ? 'Karte' : 'Karten'}"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-1 text-xs text-neutral-500">
|
||||
<span>weniger</span>
|
||||
<span class="ml-1 h-3 w-3 rounded-sm bg-neutral-800"></span>
|
||||
<span class="h-3 w-3 rounded-sm bg-emerald-900"></span>
|
||||
<span class="h-3 w-3 rounded-sm bg-emerald-700"></span>
|
||||
<span class="h-3 w-3 rounded-sm bg-emerald-500"></span>
|
||||
<span class="h-3 w-3 rounded-sm bg-emerald-300"></span>
|
||||
<span class="ml-1">mehr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -185,6 +185,41 @@ export function useDueCountByDeck() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day review counts for the last `weeks * 7` days (default 12 weeks
|
||||
* = 84 days). Used by the GitHub-style heatmap on the dashboard. Days
|
||||
* with no row in cardStudyBlocks come back as count=0 so the renderer
|
||||
* doesn't have to fill gaps itself.
|
||||
*/
|
||||
export function useStudyHeatmap(weeks: number = 12) {
|
||||
return liveQuery(async () => {
|
||||
const today = new Date();
|
||||
const localKey = (d: Date) => {
|
||||
const y = d.getFullYear();
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${d.getDate()}`.padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const days = weeks * 7;
|
||||
const rows = await cardStudyBlockTable.toArray();
|
||||
const byDate = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
if (r.deletedAt) continue;
|
||||
byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.cardsReviewed);
|
||||
}
|
||||
|
||||
const out: { date: string; count: number }[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = localKey(d);
|
||||
out.push({ date: key, count: byDate.get(key) ?? 0 });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Days-in-a-row with at least one review. Walks back from today; the
|
||||
* first day with no row (or a soft-deleted/empty one) ends the count.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useAllDecks, useDueCountByDeck } from '$lib/queries';
|
||||
import { deckStore } from '$lib/stores/decks.svelte';
|
||||
import AnkiImport from '$lib/components/AnkiImport.svelte';
|
||||
import StudyHeatmap from '$lib/components/StudyHeatmap.svelte';
|
||||
import type { Deck } from '@mana/cards-core';
|
||||
|
||||
const decksQuery = $derived(useAllDecks());
|
||||
|
|
@ -142,6 +143,10 @@
|
|||
{/if}
|
||||
|
||||
<div class="mt-10">
|
||||
<StudyHeatmap />
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<AnkiImport />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue