feat(manacore): add QR code export to my-data page

- Add @manacore/qr-export dependency to web app
- Create qr-export.ts service to collect contacts, events, todos
- Create QRExportModal.svelte with QR preview and download buttons
- Add QR-Code button to my-data settings page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-17 10:47:24 +01:00
parent c999999456
commit d9c5554189
5 changed files with 2099 additions and 1083 deletions

View file

@ -42,6 +42,7 @@
"vitest": "^4.0.14"
},
"dependencies": {
"@manacore/qr-export": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -0,0 +1,190 @@
/**
* QR Export API Service
*
* Collects data from contacts, calendar, and todo services for QR export.
*/
import { contactsService, type Contact } from './contacts';
import { calendarService, type CalendarEvent } from './calendar';
import { todoService, type Task } from './todo';
import type { UserDataSummary } from './my-data';
import {
createManaQRExport,
type EncodeResult,
type ContactInput,
type EventInput,
type TodoInput,
type ContactRelation,
type TodoPriority,
} from '@manacore/qr-export';
/**
* Data collected for QR export
*/
export interface QRExportData {
contacts: Contact[];
events: CalendarEvent[];
tasks: Task[];
userData: UserDataSummary | null;
}
/**
* Result of QR export generation
*/
export interface QRExportResult {
encodeResult: EncodeResult;
stats: {
contactCount: number;
eventCount: number;
todoCount: number;
};
}
/**
* Map Contact to ContactInput for qr-export
*/
function mapContactToInput(contact: Contact): ContactInput {
const displayName = contactsService.getDisplayName(contact);
// Determine relation based on available data
// Default to 3 (Freund), but this could be enhanced with actual relation data
let relation: ContactRelation = 3;
return {
name: displayName,
phone: contact.mobile || contact.phone,
email: contact.email,
relation,
importance: contact.isFavorite ? 100 : 0,
};
}
/**
* Map CalendarEvent to EventInput for qr-export
*/
function mapEventToInput(event: CalendarEvent): EventInput {
const startDate = new Date(event.startTime);
const endDate = new Date(event.endTime);
return {
title: event.title,
start: startDate,
end: endDate,
location: event.location,
allDay: event.isAllDay,
};
}
/**
* Map Task to TodoInput for qr-export
*/
function mapTaskToInput(task: Task): TodoInput {
// Map priority string to number
const priorityMap: Record<string, TodoPriority> = {
urgent: 1,
high: 1,
medium: 2,
low: 3,
};
const priority = priorityMap[task.priority] || 2;
return {
title: task.title,
priority,
dueDate: task.dueDate ? new Date(task.dueDate) : undefined,
completed: task.isCompleted,
};
}
/**
* QR Export service
*/
export const qrExportService = {
/**
* Collect all data needed for QR export
*/
async collectExportData(): Promise<QRExportData> {
// Fetch all data in parallel
const [contactsResult, eventsResult, tasksResult] = await Promise.all([
contactsService.getFavoriteContacts(10), // Get more, we'll filter
calendarService.getUpcomingEvents(30), // Next 30 days
todoService.getUpcomingTasks(30), // Next 30 days
]);
return {
contacts: contactsResult.data || [],
events: eventsResult.data || [],
tasks: tasksResult.data || [],
userData: null, // Will be set by caller if needed
};
},
/**
* Generate QR export from collected data
*/
generateExport(
data: QRExportData,
options?: {
maxContacts?: number;
maxEvents?: number;
maxTodos?: number;
}
): QRExportResult {
const maxContacts = options?.maxContacts ?? 5;
const maxEvents = options?.maxEvents ?? 10;
const maxTodos = options?.maxTodos ?? 15;
// Map to input formats
const contactInputs = data.contacts.map(mapContactToInput);
const eventInputs = data.events.map(mapEventToInput);
const taskInputs = data.tasks.map(mapTaskToInput);
// Build export using the builder
const builder = createManaQRExport();
// Set user context if available
if (data.userData?.user) {
builder.user({
n: data.userData.user.name || data.userData.user.email.split('@')[0],
l: 'de', // Could be derived from user settings
z: 'Europe/Berlin', // Could be derived from user settings
});
} else {
builder.userName('ManaCore User');
}
// Add data using smart selectors
builder.contactsFrom(contactInputs, maxContacts);
builder.eventsFrom(eventInputs, maxEvents);
builder.todosFrom(taskInputs, maxTodos);
// Encode
const encodeResult = builder.encode();
return {
encodeResult,
stats: {
contactCount: Math.min(contactInputs.length, maxContacts),
eventCount: Math.min(eventInputs.length, maxEvents),
todoCount: Math.min(taskInputs.filter((t) => !t.completed).length, maxTodos),
},
};
},
/**
* Generate QR export with all data fetched automatically
*/
async generateFullExport(
userData?: UserDataSummary | null,
options?: {
maxContacts?: number;
maxEvents?: number;
maxTodos?: number;
}
): Promise<QRExportResult> {
const data = await this.collectExportData();
data.userData = userData || null;
return this.generateExport(data, options);
},
};

View file

@ -0,0 +1,252 @@
<script lang="ts">
import { ManaQRCode } from '@manacore/qr-export/svelte';
import { toDataURL, toSVG } from '@manacore/qr-export/generate';
import { qrExportService, type QRExportResult } from '$lib/api/services/qr-export';
import type { UserDataSummary } from '$lib/api/services/my-data';
interface Props {
show: boolean;
userData: UserDataSummary | null;
onClose: () => void;
}
let { show, userData, onClose }: Props = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let exportResult = $state<QRExportResult | null>(null);
// Load export data when modal opens
$effect(() => {
if (show) {
loadExportData();
} else {
// Reset state when closing
loading = true;
error = null;
exportResult = null;
}
});
async function loadExportData() {
loading = true;
error = null;
try {
exportResult = await qrExportService.generateFullExport(userData);
} catch (e) {
console.error('QR Export failed:', e);
error = e instanceof Error ? e.message : 'Export fehlgeschlagen';
} finally {
loading = false;
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
async function downloadPNG() {
if (!exportResult) return;
try {
const dataUrl = await toDataURL(exportResult.encodeResult, { size: 600 });
const link = document.createElement('a');
link.href = dataUrl;
link.download = `mana-qr-export-${new Date().toISOString().split('T')[0]}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
console.error('PNG download failed:', e);
}
}
async function downloadSVG() {
if (!exportResult) return;
try {
const svgString = await toSVG(exportResult.encodeResult, { size: 600 });
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `mana-qr-export-${new Date().toISOString().split('T')[0]}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e) {
console.error('SVG download failed:', e);
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
</script>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onclick={handleBackdropClick}
>
<div
class="bg-card rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
>
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-3 mb-6">
<div class="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">QR-Code Export</h3>
<p class="text-sm text-muted-foreground">Deine wichtigsten Daten als QR-Code</p>
</div>
</div>
{#if loading}
<!-- Loading State -->
<div class="flex flex-col items-center justify-center py-12">
<div class="relative">
<div class="w-16 h-16 border-4 border-primary/20 rounded-full animate-pulse"></div>
<div
class="absolute inset-0 w-16 h-16 border-4 border-transparent border-t-primary rounded-full animate-spin"
></div>
</div>
<p class="mt-4 text-sm text-muted-foreground">Daten werden geladen...</p>
</div>
{:else if error}
<!-- Error State -->
<div
class="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20 p-4 text-center"
>
<svg
class="h-8 w-8 text-red-500 mx-auto mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p class="text-red-600 dark:text-red-400 mb-4">{error}</p>
<button
onclick={loadExportData}
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
>
Erneut versuchen
</button>
</div>
{:else if exportResult}
<!-- Success State -->
<div class="space-y-6">
<!-- QR Code Preview -->
<div class="flex justify-center">
<div class="p-4 bg-white rounded-lg shadow-inner">
<ManaQRCode data={exportResult.encodeResult} size={256} svg={true} />
</div>
</div>
<!-- Size Info -->
<div class="rounded-lg border bg-muted/50 p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-medium">Datengrosse</span>
<span
class="text-sm font-mono {exportResult.encodeResult.fitsInQR
? 'text-green-600'
: 'text-red-600'}"
>
{formatBytes(exportResult.encodeResult.size)}
{#if exportResult.encodeResult.fitsInQR}
<span class="ml-1"></span>
{:else}
<span class="ml-1"></span>
{/if}
</span>
</div>
<div class="grid grid-cols-3 gap-2 text-center">
<div class="p-2 rounded bg-card">
<p class="text-lg font-semibold">{exportResult.stats.contactCount}</p>
<p class="text-xs text-muted-foreground">Kontakte</p>
</div>
<div class="p-2 rounded bg-card">
<p class="text-lg font-semibold">{exportResult.stats.eventCount}</p>
<p class="text-xs text-muted-foreground">Termine</p>
</div>
<div class="p-2 rounded bg-card">
<p class="text-lg font-semibold">{exportResult.stats.todoCount}</p>
<p class="text-xs text-muted-foreground">Todos</p>
</div>
</div>
</div>
<!-- Info Text -->
<p class="text-xs text-muted-foreground text-center">
Scanne diesen QR-Code mit einem kompatiblen Gerat, um deine wichtigsten Daten zu
ubertragen. Der Code enthalt Kontakte, anstehende Termine und offene Aufgaben.
</p>
<!-- Download Buttons -->
<div class="flex gap-3">
<button
onclick={downloadPNG}
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
PNG
</button>
<button
onclick={downloadSVG}
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
SVG
</button>
</div>
</div>
{/if}
<!-- Close Button -->
<button
onclick={onClose}
class="mt-6 w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Schliessen
</button>
</div>
</div>
</div>
{/if}

View file

@ -5,6 +5,7 @@
import StatCard from '$lib/components/admin/StatCard.svelte';
import ProjectDataCard from '$lib/components/admin/ProjectDataCard.svelte';
import DeleteConfirmationModal from '$lib/components/my-data/DeleteConfirmationModal.svelte';
import QRExportModal from '$lib/components/my-data/QRExportModal.svelte';
import { myDataService, type UserDataSummary } from '$lib/api/services/my-data';
import type { DeleteUserDataResponse } from '$lib/api/services/admin';
import { authStore } from '$lib/stores/auth.svelte';
@ -20,6 +21,9 @@
let deleteResult = $state<DeleteUserDataResponse | null>(null);
let deleteError = $state<string | null>(null);
// QR Export dialog state
let showQRDialog = $state(false);
async function loadMyData() {
loading = true;
error = null;
@ -119,33 +123,56 @@
</div>
</div>
{#if userData}
<button
onclick={handleExport}
disabled={exporting}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{#if exporting}
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<div class="flex items-center gap-2">
<button
onclick={() => (showQRDialog = true)}
class="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-muted transition-colors"
title="Als QR-Code exportieren"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
/>
</svg>
{/if}
<span>{exporting ? 'Exportiere...' : 'Daten exportieren'}</span>
</button>
<span>QR-Code</span>
</button>
<button
onclick={handleExport}
disabled={exporting}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{#if exporting}
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{/if}
<span>{exporting ? 'Exportiere...' : 'Daten exportieren'}</span>
</button>
</div>
{/if}
</div>
@ -465,3 +492,6 @@
onConfirm={handleDelete}
onClose={handleDeleteModalClose}
/>
<!-- QR Export Modal -->
<QRExportModal show={showQRDialog} {userData} onClose={() => (showQRDialog = false)} />

2669
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff