feat(wallpaper-generator): add device wallpaper generation package

Add new @manacore/wallpaper-generator package for creating device
wallpapers from QR codes and images.

Features:
- 30 device presets (phones, tablets, desktops)
- 3 layout types (center, corner, pattern)
- 5 gradient presets + solid color backgrounds
- Browser (Canvas) and Node.js (Sharp) renderers
- Svelte WallpaperModal UI component

Integrations:
- @manacore/qr-export: toWallpaper() function
- @manacore/spiral-db: toWallpaper() function
- QRExportModal: "Als Wallpaper" button

Also includes the full @manacore/qr-export package which was
previously untracked.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-17 12:57:43 +01:00
parent c480231128
commit e5109da732
37 changed files with 5393 additions and 676 deletions

View file

@ -43,6 +43,7 @@
},
"dependencies": {
"@manacore/qr-export": "workspace:*",
"@manacore/wallpaper-generator": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -3,6 +3,7 @@
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';
import { WallpaperModal } from '@manacore/wallpaper-generator/svelte';
interface Props {
show: boolean;
@ -15,6 +16,8 @@
let loading = $state(true);
let error = $state<string | null>(null);
let exportResult = $state<QRExportResult | null>(null);
let showWallpaperModal = $state(false);
let qrDataUrl = $state<string | null>(null);
// Load export data when modal opens
$effect(() => {
@ -87,6 +90,18 @@
if (bytes < 1024) return `${bytes} Bytes`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
async function openWallpaperModal() {
if (!exportResult) return;
try {
// Generate QR code as data URL for wallpaper generation
qrDataUrl = await toDataURL(exportResult.encodeResult, { size: 600 });
showWallpaperModal = true;
} catch (e) {
console.error('Failed to generate QR data URL:', e);
}
}
</script>
{#if show}
@ -236,6 +251,22 @@
SVG
</button>
</div>
<!-- Wallpaper Button -->
<button
onclick={openWallpaperModal}
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 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 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Als Wallpaper
</button>
</div>
{/if}
@ -250,3 +281,12 @@
</div>
</div>
{/if}
<!-- Wallpaper Modal -->
{#if qrDataUrl}
<WallpaperModal
show={showWallpaperModal}
imageDataUrl={qrDataUrl}
onClose={() => (showWallpaperModal = false)}
/>
{/if}

View file

@ -0,0 +1,48 @@
{
"name": "@manacore/qr-export",
"version": "0.1.0",
"private": true,
"description": "QR code export/import for personal data (contacts, events, todos, user context)",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./svelte": "./src/svelte/index.ts",
"./generate": "./src/generate.ts",
"./wallpaper": "./src/wallpaper.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"pako": "^2.1.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"svelte": "^5.0.0",
"jsqr": "^1.4.0",
"@manacore/wallpaper-generator": "workspace:*"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
},
"jsqr": {
"optional": true
},
"@manacore/wallpaper-generator": {
"optional": true
}
}
}

View file

@ -0,0 +1,247 @@
/**
* ManaQR Export Builder
*
* Fluent API for building QR exports easily.
*/
import type {
ManaQRExport,
ManaQRUserContext,
ManaQRContact,
ManaQREvent,
ManaQRTodo,
EncodeResult,
ContactRelation,
TodoPriority,
} from './types';
import { MANA_QR_LIMITS } from './types';
import { encode, estimateSize } from './encoder';
import {
selectTopContacts,
selectUpcomingEvents,
selectPriorityTodos,
type ContactInput,
type EventInput,
type TodoInput,
} from './selectors';
/**
* Builder for creating ManaQR exports
*
* @example
* ```ts
* const result = createManaQRExport()
* .user({ n: 'Till', z: 'Europe/Berlin', l: 'de' })
* .addContact({ n: 'Mama', p: '+491701234567', r: 1 })
* .addContact({ n: 'Papa', p: '+491707654321', r: 1 })
* .addEvent({ t: 'Zahnarzt', s: Date.now() + 86400000, d: 60 })
* .addTodo({ t: 'Steuererklärung', p: 1, d: 14 })
* .encode();
* ```
*/
export class ManaQRExportBuilder {
private _user: ManaQRUserContext = { n: '' };
private _contacts: ManaQRContact[] = [];
private _events: ManaQREvent[] = [];
private _todos: ManaQRTodo[] = [];
private _timestamp: number = Math.floor(Date.now() / 1000);
/**
* Set user context
*/
user(context: ManaQRUserContext): this {
this._user = context;
return this;
}
/**
* Set user name (shorthand)
*/
userName(name: string): this {
this._user.n = name;
return this;
}
/**
* Set user timezone
*/
timezone(tz: string): this {
this._user.z = tz;
return this;
}
/**
* Set user language
*/
language(lang: string): this {
this._user.l = lang;
return this;
}
/**
* Set user location
*/
location(loc: string): this {
this._user.w = loc;
return this;
}
/**
* Add a single contact
*/
addContact(contact: ManaQRContact): this {
this._contacts.push(contact);
return this;
}
/**
* Add multiple contacts
*/
contacts(contacts: ManaQRContact[]): this {
this._contacts.push(...contacts);
return this;
}
/**
* Add contacts from input format (auto-selects top contacts)
*/
contactsFrom(inputs: ContactInput[], limit: number = MANA_QR_LIMITS.MAX_CONTACTS): this {
this._contacts = selectTopContacts(inputs, limit);
return this;
}
/**
* Add a single event
*/
addEvent(event: ManaQREvent): this {
this._events.push(event);
return this;
}
/**
* Add multiple events
*/
events(events: ManaQREvent[]): this {
this._events.push(...events);
return this;
}
/**
* Add events from input format (auto-selects upcoming)
*/
eventsFrom(inputs: EventInput[], limit: number = MANA_QR_LIMITS.MAX_EVENTS): this {
this._events = selectUpcomingEvents(inputs, limit);
return this;
}
/**
* Add a single todo
*/
addTodo(todo: ManaQRTodo): this {
this._todos.push(todo);
return this;
}
/**
* Add multiple todos
*/
todos(todos: ManaQRTodo[]): this {
this._todos.push(...todos);
return this;
}
/**
* Add todos from input format (auto-selects by priority)
*/
todosFrom(inputs: TodoInput[], limit: number = MANA_QR_LIMITS.MAX_TODOS): this {
this._todos = selectPriorityTodos(inputs, limit);
return this;
}
/**
* Set custom timestamp (Unix seconds)
*/
timestamp(ts: number): this {
this._timestamp = ts;
return this;
}
/**
* Build the export data (without encoding)
*/
build(): ManaQRExport {
return {
v: 1,
ts: this._timestamp,
u: this._user,
c: this._contacts,
e: this._events,
t: this._todos,
};
}
/**
* Estimate encoded size in bytes
*/
estimateSize(): number {
return estimateSize(this.build());
}
/**
* Check if current data will fit in a QR code
*/
willFit(): boolean {
return this.estimateSize() <= MANA_QR_LIMITS.MAX_QR_BYTES;
}
/**
* Encode to QR-ready string
*/
encode(): EncodeResult {
return encode(this.build());
}
}
/**
* Create a new ManaQR export builder
*/
export function createManaQRExport(): ManaQRExportBuilder {
return new ManaQRExportBuilder();
}
// --- Quick helpers for common formats ---
/**
* Create a contact in compact format
*/
export function contact(
name: string,
phone?: string,
relation: ContactRelation = 3
): ManaQRContact {
return { n: name, p: phone, r: relation };
}
/**
* Create an event in compact format
*/
export function event(
title: string,
startDate: Date,
durationMinutes = 60,
location?: string
): ManaQREvent {
return {
t: title,
s: Math.floor(startDate.getTime() / 1000),
d: durationMinutes,
l: location,
};
}
/**
* Create a todo in compact format
*/
export function todo(title: string, priority: TodoPriority = 2, dueDays?: number): ManaQRTodo {
return { t: title, p: priority, d: dueDays };
}

View file

@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { encode, decode, estimateSize, willFitInQR, MANA_QR_PREFIX } from './encoder';
import { createManaQRExport, contact, event, todo } from './builder';
import type { ManaQRExport } from './types';
describe('encoder', () => {
const sampleExport: ManaQRExport = {
v: 1,
ts: 1708185600,
u: {
n: 'Till',
z: 'Europe/Berlin',
l: 'de',
w: 'Berlin',
},
c: [
{ n: 'Mama', p: '+491701234567', r: 1 },
{ n: 'Papa', p: '+491707654321', r: 1 },
],
e: [
{ t: 'Zahnarzt', s: 1708272000, d: 60, l: 'Praxis Dr. Weber' },
{ t: 'Team Meeting', s: 1708358400, d: 30 },
],
t: [
{ t: 'Steuererklärung abgeben', p: 1, d: 14 },
{ t: 'Backup machen', p: 3 },
],
};
describe('encode', () => {
it('should encode data with correct prefix', () => {
const result = encode(sampleExport);
expect(result.data.startsWith(MANA_QR_PREFIX)).toBe(true);
expect(result.size).toBeGreaterThan(0);
});
it('should report fitsInQR correctly for small data', () => {
const result = encode(sampleExport);
expect(result.fitsInQR).toBe(true);
expect(result.size).toBeLessThan(2500);
});
it('should compress data significantly', () => {
const jsonSize = JSON.stringify(sampleExport).length;
const result = encode(sampleExport);
// Encoded should be smaller than raw JSON
expect(result.size).toBeLessThan(jsonSize);
});
});
describe('decode', () => {
it('should decode encoded data correctly', () => {
const encoded = encode(sampleExport);
const decoded = decode(encoded.data);
expect(decoded.success).toBe(true);
if (decoded.success) {
expect(decoded.data.u.n).toBe('Till');
expect(decoded.data.c).toHaveLength(2);
expect(decoded.data.e).toHaveLength(2);
expect(decoded.data.t).toHaveLength(2);
}
});
it('should fail for invalid prefix', () => {
const result = decode('INVALID:xyz');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('INVALID_PREFIX');
}
});
it('should fail for invalid base64', () => {
const result = decode(MANA_QR_PREFIX + '!!!invalid!!!');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('INVALID_BASE64');
}
});
});
describe('roundtrip', () => {
it('should preserve all data through encode/decode', () => {
const encoded = encode(sampleExport);
const decoded = decode(encoded.data);
expect(decoded.success).toBe(true);
if (decoded.success) {
expect(decoded.data).toEqual(sampleExport);
}
});
});
describe('estimateSize', () => {
it('should estimate size within reasonable range', () => {
const estimated = estimateSize(sampleExport);
const actual = encode(sampleExport).size;
// Estimate should be within 50% of actual
expect(estimated).toBeGreaterThan(actual * 0.5);
expect(estimated).toBeLessThan(actual * 1.5);
});
});
describe('willFitInQR', () => {
it('should return true for small data', () => {
expect(willFitInQR(sampleExport)).toBe(true);
});
});
});
describe('builder', () => {
it('should build export with fluent API', () => {
const result = createManaQRExport()
.user({ n: 'Test', z: 'UTC', l: 'en' })
.addContact(contact('Alice', '+1234567890', 1))
.addEvent(event('Meeting', new Date('2024-02-20T10:00:00Z'), 60))
.addTodo(todo('Task 1', 1, 7))
.build();
expect(result.v).toBe(1);
expect(result.u.n).toBe('Test');
expect(result.c).toHaveLength(1);
expect(result.e).toHaveLength(1);
expect(result.t).toHaveLength(1);
});
it('should encode to valid QR string', () => {
const result = createManaQRExport()
.userName('Test')
.timezone('UTC')
.addContact({ n: 'Bob', r: 3 })
.encode();
expect(result.data.startsWith(MANA_QR_PREFIX)).toBe(true);
expect(result.fitsInQR).toBe(true);
});
it('should estimate size correctly', () => {
const builder = createManaQRExport().userName('Test').addContact({ n: 'Alice', r: 1 });
const estimated = builder.estimateSize();
const _actual = builder.encode().size;
expect(estimated).toBeGreaterThan(0);
expect(builder.willFit()).toBe(true);
});
});

View file

@ -0,0 +1,231 @@
/**
* ManaQR Encoder/Decoder
*
* Encodes/decodes ManaQRExport data for QR codes using gzip compression.
*/
import pako from 'pako';
import type { ManaQRExport, EncodeResult, DecodeResult, DecodeError } from './types';
import { MANA_QR_LIMITS } from './types';
/** Prefix for ManaQR data (helps identify valid codes) */
export const MANA_QR_PREFIX = 'MANA1:';
/** Current format version */
export const MANA_QR_VERSION = 1;
/**
* Encode ManaQRExport data to a compressed string for QR codes
*/
export function encode(data: ManaQRExport): EncodeResult {
// Ensure version is set
const exportData: ManaQRExport = {
...data,
v: MANA_QR_VERSION,
ts: data.ts || Math.floor(Date.now() / 1000),
};
// Convert to JSON
const json = JSON.stringify(exportData);
// Compress with gzip
const compressed = pako.deflate(json);
// Convert to base64
const base64 = uint8ArrayToBase64(compressed);
// Add prefix
const result = MANA_QR_PREFIX + base64;
return {
data: result,
size: result.length,
fitsInQR: result.length <= MANA_QR_LIMITS.MAX_QR_BYTES,
};
}
/**
* Decode a ManaQR string back to ManaQRExport data
*/
export function decode(qrString: string): DecodeResult {
// Check prefix
if (!qrString.startsWith(MANA_QR_PREFIX)) {
return {
success: false,
error: 'INVALID_PREFIX',
message: `String must start with "${MANA_QR_PREFIX}"`,
};
}
// Extract base64 part
const base64 = qrString.slice(MANA_QR_PREFIX.length);
// Decode base64
let compressed: Uint8Array;
try {
compressed = base64ToUint8Array(base64);
} catch {
return {
success: false,
error: 'INVALID_BASE64',
message: 'Invalid base64 encoding',
};
}
// Decompress
let json: string;
try {
json = pako.inflate(compressed, { to: 'string' });
} catch {
return {
success: false,
error: 'DECOMPRESSION_FAILED',
message: 'Failed to decompress data',
};
}
// Parse JSON
let data: unknown;
try {
data = JSON.parse(json);
} catch {
return {
success: false,
error: 'INVALID_JSON',
message: 'Invalid JSON data',
};
}
// Validate structure
const validation = validateExport(data);
if (!validation.valid) {
return {
success: false,
error: validation.error,
message: validation.message,
};
}
return {
success: true,
data: data as ManaQRExport,
};
}
/**
* Estimate the encoded size without actually encoding
* Useful for checking if data will fit before encoding
*/
export function estimateSize(data: ManaQRExport): number {
const json = JSON.stringify(data);
// Rough estimate: gzip typically achieves 60-70% compression on JSON
// Base64 adds ~33% overhead
// Add prefix length
const estimatedCompressed = json.length * 0.35;
const estimatedBase64 = estimatedCompressed * 1.33;
return Math.ceil(estimatedBase64 + MANA_QR_PREFIX.length);
}
/**
* Check if data will likely fit in a single QR code
*/
export function willFitInQR(data: ManaQRExport): boolean {
return estimateSize(data) <= MANA_QR_LIMITS.MAX_QR_BYTES;
}
// --- Validation ---
interface ValidationResult {
valid: boolean;
error: DecodeError;
message: string;
}
function validateExport(data: unknown): ValidationResult {
if (typeof data !== 'object' || data === null) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Data must be an object',
};
}
const obj = data as Record<string, unknown>;
// Check version
if (obj.v !== MANA_QR_VERSION) {
return {
valid: false,
error: 'INVALID_VERSION',
message: `Unsupported version: ${obj.v}`,
};
}
// Check required fields
if (typeof obj.ts !== 'number') {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Missing or invalid timestamp',
};
}
if (typeof obj.u !== 'object' || obj.u === null) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Missing user context',
};
}
if (!Array.isArray(obj.c)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Contacts must be an array',
};
}
if (!Array.isArray(obj.e)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Events must be an array',
};
}
if (!Array.isArray(obj.t)) {
return {
valid: false,
error: 'INVALID_STRUCTURE',
message: 'Todos must be an array',
};
}
return { valid: true, error: 'INVALID_STRUCTURE', message: '' };
}
// --- Base64 utilities (browser & Node compatible) ---
function uint8ArrayToBase64(bytes: Uint8Array): string {
if (typeof btoa === 'function') {
// Browser
return btoa(String.fromCharCode(...bytes));
}
// Node.js
return Buffer.from(bytes).toString('base64');
}
function base64ToUint8Array(base64: string): Uint8Array {
if (typeof atob === 'function') {
// Browser
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Node.js
return new Uint8Array(Buffer.from(base64, 'base64'));
}

View file

@ -0,0 +1,140 @@
/**
* QR Code Generation utilities
*
* Framework-agnostic functions for generating QR codes.
*/
import QRCode from 'qrcode';
import type { ManaQRExport, EncodeResult } from './types';
import { encode } from './encoder';
/** QR Code generation options */
export interface QRGenerateOptions {
/** Width/height in pixels (default: 300) */
size?: number;
/** Margin in modules (default: 2) */
margin?: number;
/** Error correction level (default: 'M') */
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
/** Dark color (default: '#000000') */
darkColor?: string;
/** Light color (default: '#ffffff') */
lightColor?: string;
}
const DEFAULT_OPTIONS: Required<QRGenerateOptions> = {
size: 300,
margin: 2,
errorCorrectionLevel: 'M',
darkColor: '#000000',
lightColor: '#ffffff',
};
/**
* Generate QR code as Data URL (for <img src="...">)
*/
export async function toDataURL(
data: string | ManaQRExport | EncodeResult,
options?: QRGenerateOptions
): Promise<string> {
const qrData = resolveData(data);
const opts = { ...DEFAULT_OPTIONS, ...options };
return QRCode.toDataURL(qrData, {
errorCorrectionLevel: opts.errorCorrectionLevel,
margin: opts.margin,
width: opts.size,
color: {
dark: opts.darkColor,
light: opts.lightColor,
},
});
}
/**
* Generate QR code as SVG string
*/
export async function toSVG(
data: string | ManaQRExport | EncodeResult,
options?: QRGenerateOptions
): Promise<string> {
const qrData = resolveData(data);
const opts = { ...DEFAULT_OPTIONS, ...options };
return QRCode.toString(qrData, {
type: 'svg',
errorCorrectionLevel: opts.errorCorrectionLevel,
margin: opts.margin,
width: opts.size,
color: {
dark: opts.darkColor,
light: opts.lightColor,
},
});
}
/**
* Generate QR code for terminal output
*/
export async function toTerminal(data: string | ManaQRExport | EncodeResult): Promise<string> {
const qrData = resolveData(data);
return QRCode.toString(qrData, { type: 'terminal', small: true });
}
/**
* Draw QR code to canvas element
*/
export async function toCanvas(
canvas: HTMLCanvasElement,
data: string | ManaQRExport | EncodeResult,
options?: QRGenerateOptions
): Promise<void> {
const qrData = resolveData(data);
const opts = { ...DEFAULT_OPTIONS, ...options };
await QRCode.toCanvas(canvas, qrData, {
errorCorrectionLevel: opts.errorCorrectionLevel,
margin: opts.margin,
width: opts.size,
color: {
dark: opts.darkColor,
light: opts.lightColor,
},
});
}
/**
* Save QR code to file (Node.js only)
*/
export async function toFile(
path: string,
data: string | ManaQRExport | EncodeResult,
options?: QRGenerateOptions
): Promise<void> {
const qrData = resolveData(data);
const opts = { ...DEFAULT_OPTIONS, ...options };
await QRCode.toFile(path, qrData, {
errorCorrectionLevel: opts.errorCorrectionLevel,
margin: opts.margin,
width: opts.size,
color: {
dark: opts.darkColor,
light: opts.lightColor,
},
});
}
// --- Helpers ---
function resolveData(data: string | ManaQRExport | EncodeResult): string {
if (typeof data === 'string') {
return data;
}
if ('data' in data && typeof data.data === 'string') {
// EncodeResult
return data.data;
}
// ManaQRExport - encode it
return encode(data as ManaQRExport).data;
}

View file

@ -0,0 +1,66 @@
/**
* @manacore/qr-export
*
* QR code export/import for personal data (contacts, events, todos, user context).
* Compresses data to fit within a single QR code (~2,500 bytes).
*
* @example
* ```ts
* import { createManaQRExport, decode } from '@manacore/qr-export';
*
* // Create export
* const result = createManaQRExport()
* .user({ n: 'Till', z: 'Europe/Berlin', l: 'de' })
* .addContact({ n: 'Mama', p: '+491701234567', r: 1 })
* .addEvent({ t: 'Meeting', s: Date.now() / 1000, d: 60 })
* .addTodo({ t: 'Backup machen', p: 1, d: 7 })
* .encode();
*
* console.log(result.data); // "MANA1:eJy..." (use for QR code)
* console.log(result.fitsInQR); // true
*
* // Decode
* const decoded = decode(result.data);
* if (decoded.success) {
* console.log(decoded.data.u.n); // "Till"
* }
* ```
*/
// Types
export type {
ManaQRExport,
ManaQRUserContext,
ManaQRContact,
ManaQREvent,
ManaQRTodo,
ContactRelation,
TodoPriority,
EncodeResult,
DecodeResult,
DecodeError,
} from './types';
export { MANA_QR_LIMITS, RELATION_LABELS, PRIORITY_LABELS } from './types';
// Encoder/Decoder
export {
encode,
decode,
estimateSize,
willFitInQR,
MANA_QR_PREFIX,
MANA_QR_VERSION,
} from './encoder';
// Selectors
export type { ContactInput, EventInput, TodoInput } from './selectors';
export { selectTopContacts, selectUpcomingEvents, selectPriorityTodos } from './selectors';
// Builder
export { ManaQRExportBuilder, createManaQRExport, contact, event, todo } from './builder';
// QR Code Generation
export type { QRGenerateOptions } from './generate';
export { toDataURL, toSVG, toTerminal, toCanvas, toFile } from './generate';

View file

@ -0,0 +1,232 @@
/**
* Selectors for choosing the most important data for QR export
*
* These helpers select and prioritize data to fit within QR code limits.
*/
import type {
ManaQRContact,
ManaQREvent,
ManaQRTodo,
ContactRelation,
TodoPriority,
} from './types';
import { MANA_QR_LIMITS } from './types';
// --- Contact Selectors ---
/** Input format for contact selection */
export interface ContactInput {
name: string;
phone?: string;
email?: string;
relation?: ContactRelation;
/** Higher = more important */
importance?: number;
/** Is emergency contact */
isEmergency?: boolean;
/** Is family member */
isFamily?: boolean;
}
/**
* Select the most important contacts for QR export
*
* Priority order:
* 1. Emergency contacts (relation = 5)
* 2. Family (relation = 1)
* 3. Partner (relation = 2)
* 4. By importance score
* 5. By provided order
*/
export function selectTopContacts(
contacts: ContactInput[],
limit: number = MANA_QR_LIMITS.MAX_CONTACTS
): ManaQRContact[] {
const scored = contacts.map((c, index) => {
let score = 0;
// Emergency contacts highest priority
if (c.isEmergency || c.relation === 5) score += 1000;
// Family second
if (c.isFamily || c.relation === 1) score += 500;
// Partner third
if (c.relation === 2) score += 400;
// Work contacts
if (c.relation === 4) score += 100;
// Custom importance
if (c.importance) score += c.importance;
// Prefer contacts with phone numbers
if (c.phone) score += 50;
// Original order as tiebreaker
score -= index * 0.01;
return { contact: c, score };
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
// Take top N and convert to compact format
return scored.slice(0, limit).map(({ contact }) => ({
n: contact.name,
p: contact.phone,
e: contact.email,
r: contact.relation || 3, // Default to "Freund"
}));
}
// --- Event Selectors ---
/** Input format for event selection */
export interface EventInput {
title: string;
/** Start time as Date or Unix timestamp (ms) */
start: Date | number;
/** End time as Date or Unix timestamp (ms) */
end?: Date | number;
/** Duration in minutes (alternative to end) */
durationMinutes?: number;
location?: string;
/** Is all-day event */
allDay?: boolean;
/** Higher = more important */
importance?: number;
}
/**
* Select upcoming events for QR export
*
* Only includes future events, sorted by start time.
* Truncates titles to fit size limits.
*/
export function selectUpcomingEvents(
events: EventInput[],
limit: number = MANA_QR_LIMITS.MAX_EVENTS,
fromDate: Date = new Date()
): ManaQREvent[] {
const fromTimestamp = fromDate.getTime();
// Filter and sort future events
const futureEvents = events
.map((e) => ({
event: e,
startMs: e.start instanceof Date ? e.start.getTime() : e.start,
}))
.filter(({ startMs }) => startMs >= fromTimestamp)
.sort((a, b) => a.startMs - b.startMs);
// Take top N and convert to compact format
return futureEvents.slice(0, limit).map(({ event, startMs }) => {
// Calculate duration
let durationMinutes = event.durationMinutes || 60;
if (event.end) {
const endMs = event.end instanceof Date ? event.end.getTime() : event.end;
durationMinutes = Math.round((endMs - startMs) / 60000);
}
if (event.allDay) {
durationMinutes = 1440; // 24 hours
}
return {
t: truncate(event.title, MANA_QR_LIMITS.MAX_EVENT_TITLE),
s: Math.floor(startMs / 1000), // Unix seconds
d: durationMinutes,
l: event.location ? truncate(event.location, 20) : undefined,
};
});
}
// --- Todo Selectors ---
/** Input format for todo selection */
export interface TodoInput {
title: string;
priority?: TodoPriority;
/** Due date as Date or Unix timestamp (ms) */
dueDate?: Date | number;
/** Is completed */
completed?: boolean;
/** Higher = more important */
importance?: number;
}
/**
* Select the most important todos for QR export
*
* Priority order:
* 1. Priority 1 (high)
* 2. Priority 2 (medium)
* 3. Priority 3 (low)
* 4. By due date (sooner = higher)
* 5. By importance score
*
* Excludes completed todos.
*/
export function selectPriorityTodos(
todos: TodoInput[],
limit: number = MANA_QR_LIMITS.MAX_TODOS,
fromDate: Date = new Date()
): ManaQRTodo[] {
const fromTimestamp = fromDate.getTime();
const fromDayStart = new Date(fromDate);
fromDayStart.setHours(0, 0, 0, 0);
// Filter out completed todos and score the rest
const scored = todos
.filter((t) => !t.completed)
.map((t, index) => {
let score = 0;
// Priority is most important
const priority = t.priority || 3;
score += (4 - priority) * 1000; // P1=3000, P2=2000, P3=1000
// Due date matters
if (t.dueDate) {
const dueMs = t.dueDate instanceof Date ? t.dueDate.getTime() : t.dueDate;
const daysUntilDue = Math.floor((dueMs - fromTimestamp) / 86400000);
// Overdue items get highest boost
if (daysUntilDue < 0) {
score += 500;
} else if (daysUntilDue <= 7) {
score += 300 - daysUntilDue * 10;
}
}
// Custom importance
if (t.importance) score += t.importance;
// Original order as tiebreaker
score -= index * 0.01;
return { todo: t, score };
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
// Take top N and convert to compact format
return scored.slice(0, limit).map(({ todo }) => {
let dueDays: number | undefined;
if (todo.dueDate) {
const dueMs = todo.dueDate instanceof Date ? todo.dueDate.getTime() : todo.dueDate;
const daysFromNow = Math.floor((dueMs - fromDayStart.getTime()) / 86400000);
// Clamp to 0-255 range
dueDays = Math.max(0, Math.min(255, daysFromNow));
}
return {
t: truncate(todo.title, MANA_QR_LIMITS.MAX_TODO_TITLE),
p: todo.priority || 3,
d: dueDays,
};
});
}
// --- Utility ---
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 1) + '…';
}

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { toDataURL, toSVG } from '../generate';
import type { ManaQRExport, EncodeResult } from '../types';
interface Props {
/** Encoded QR data string, EncodeResult, or ManaQRExport object */
data: string | ManaQRExport | EncodeResult;
/** Width/height in pixels */
size?: number;
/** Margin in QR modules */
margin?: number;
/** Error correction level */
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
/** Dark color (QR modules) */
darkColor?: string;
/** Light color (background) */
lightColor?: string;
/** Render as SVG instead of PNG (sharper, smaller file) */
svg?: boolean;
/** Alt text for accessibility */
alt?: string;
/** Additional CSS classes */
class?: string;
}
let {
data,
size = 300,
margin = 2,
errorCorrectionLevel = 'M',
darkColor = '#000000',
lightColor = '#ffffff',
svg = false,
alt = 'ManaQR Code',
class: className = '',
}: Props = $props();
let qrOutput = $state<string>('');
let error = $state<string | null>(null);
const options = $derived({
size,
margin,
errorCorrectionLevel,
darkColor,
lightColor,
});
$effect(() => {
generateQR();
});
async function generateQR() {
try {
error = null;
if (svg) {
qrOutput = await toSVG(data, options);
} else {
qrOutput = await toDataURL(data, options);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to generate QR code';
qrOutput = '';
}
}
</script>
{#if error}
<div class="mana-qr-error {className}" role="alert">
<span>QR Error: {error}</span>
</div>
{:else if qrOutput}
{#if svg}
<div
class="mana-qr-svg {className}"
style="width: {size}px; height: {size}px;"
role="img"
aria-label={alt}
>
{@html qrOutput}
</div>
{:else}
<img src={qrOutput} {alt} class="mana-qr-img {className}" width={size} height={size} />
{/if}
{:else}
<div
class="mana-qr-loading {className}"
style="width: {size}px; height: {size}px;"
role="status"
aria-label="Loading QR code"
>
<span class="mana-qr-spinner"></span>
</div>
{/if}
<style>
.mana-qr-img {
display: block;
image-rendering: pixelated;
}
.mana-qr-svg {
display: block;
}
.mana-qr-svg :global(svg) {
width: 100%;
height: 100%;
}
.mana-qr-loading {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
}
.mana-qr-spinner {
width: 24px;
height: 24px;
border: 3px solid #e0e0e0;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mana-qr-error {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #fee;
color: #c00;
border-radius: 8px;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,338 @@
<script lang="ts">
import { decode } from '../encoder';
import type { ManaQRExport, DecodeResult } from '../types';
interface Props {
/** Called when a valid ManaQR code is scanned */
onScan?: (data: ManaQRExport) => void;
/** Called on any scan error */
onError?: (error: string) => void;
/** Width of the scanner view */
width?: number;
/** Height of the scanner view */
height?: number;
/** Show scan overlay guide */
showOverlay?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
onScan,
onError,
width = 300,
height = 300,
showOverlay = true,
class: className = '',
}: Props = $props();
let videoElement = $state<HTMLVideoElement | null>(null);
let canvasElement = $state<HTMLCanvasElement | null>(null);
let isScanning = $state(false);
let error = $state<string | null>(null);
let stream = $state<MediaStream | null>(null);
// Dynamically import jsQR only when needed
let jsQR: typeof import('jsqr').default | null = null;
$effect(() => {
return () => {
stopScanning();
};
});
export async function startScanning() {
if (isScanning) return;
try {
// Lazy load jsQR
if (!jsQR) {
const module = await import('jsqr');
jsQR = module.default;
}
error = null;
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width, height },
});
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
isScanning = true;
requestAnimationFrame(scanFrame);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Camera access denied';
error = msg;
onError?.(msg);
}
}
export function stopScanning() {
isScanning = false;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
if (videoElement) {
videoElement.srcObject = null;
}
}
function scanFrame() {
if (!isScanning || !videoElement || !canvasElement || !jsQR) return;
const ctx = canvasElement.getContext('2d');
if (!ctx) return;
if (videoElement.readyState === videoElement.HAVE_ENOUGH_DATA) {
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
ctx.drawImage(videoElement, 0, 0);
const imageData = ctx.getImageData(0, 0, canvasElement.width, canvasElement.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code?.data) {
handleScan(code.data);
}
}
if (isScanning) {
requestAnimationFrame(scanFrame);
}
}
function handleScan(qrData: string) {
const result = decode(qrData);
if (result.success) {
stopScanning();
onScan?.(result.data);
} else {
// Not a valid ManaQR - might be scanning something else
// Don't stop, keep scanning
}
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
// Lazy load jsQR
if (!jsQR) {
const module = await import('jsqr');
jsQR = module.default;
}
const img = new Image();
img.src = URL.createObjectURL(file);
await new Promise((resolve) => (img.onload = resolve));
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code?.data) {
handleScan(code.data);
} else {
const msg = 'No QR code found in image';
error = msg;
onError?.(msg);
}
URL.revokeObjectURL(img.src);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to read image';
error = msg;
onError?.(msg);
}
input.value = '';
}
</script>
<div class="mana-qr-scanner {className}" style="width: {width}px;">
{#if error}
<div class="scanner-error" role="alert">
<span>{error}</span>
<button onclick={() => (error = null)}>Dismiss</button>
</div>
{/if}
<div class="scanner-view" style="width: {width}px; height: {height}px;">
<video bind:this={videoElement} playsinline muted class:hidden={!isScanning}></video>
<canvas bind:this={canvasElement} class="hidden"></canvas>
{#if showOverlay && isScanning}
<div class="scanner-overlay">
<div class="scanner-frame"></div>
</div>
{/if}
{#if !isScanning}
<div class="scanner-placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
<span>Ready to scan</span>
</div>
{/if}
</div>
<div class="scanner-controls">
{#if isScanning}
<button class="btn-stop" onclick={stopScanning}>Stop Scanning</button>
{:else}
<button class="btn-start" onclick={startScanning}>Start Camera</button>
<label class="btn-upload">
Upload Image
<input type="file" accept="image/*" onchange={handleFileUpload} />
</label>
{/if}
</div>
</div>
<style>
.mana-qr-scanner {
display: flex;
flex-direction: column;
gap: 1rem;
}
.scanner-view {
position: relative;
background: #111;
border-radius: 12px;
overflow: hidden;
}
.scanner-view video {
width: 100%;
height: 100%;
object-fit: cover;
}
.hidden {
display: none;
}
.scanner-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.scanner-frame {
width: 70%;
height: 70%;
border: 3px solid rgba(255, 255, 255, 0.8);
border-radius: 16px;
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.4),
inset 0 0 20px rgba(255, 255, 255, 0.1);
}
.scanner-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: #666;
}
.scanner-controls {
display: flex;
gap: 0.5rem;
}
.scanner-controls button,
.scanner-controls label {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-align: center;
}
.btn-start {
background: #000;
color: #fff;
}
.btn-start:hover {
background: #333;
}
.btn-stop {
background: #c00;
color: #fff;
}
.btn-stop:hover {
background: #a00;
}
.btn-upload {
background: #f0f0f0;
color: #333;
}
.btn-upload:hover {
background: #e0e0e0;
}
.btn-upload input {
display: none;
}
.scanner-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: #fee;
color: #c00;
border-radius: 8px;
font-size: 0.875rem;
}
.scanner-error button {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid currentColor;
border-radius: 4px;
color: inherit;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,36 @@
/**
* Svelte components for ManaQR
*
* @example
* ```svelte
* <script>
* import { ManaQRCode, ManaQRScanner } from '@manacore/qr-export/svelte';
* import { createManaQRExport } from '@manacore/qr-export';
*
* const exportData = createManaQRExport()
* .user({ n: 'Till' })
* .encode();
*
* function handleScan(data) {
* console.log('Scanned:', data);
* }
* </script>
*
* <ManaQRCode data={exportData} size={250} />
* <ManaQRScanner onScan={handleScan} />
* ```
*/
export { default as ManaQRCode } from './ManaQRCode.svelte';
export { default as ManaQRScanner } from './ManaQRScanner.svelte';
// Re-export types for convenience
export type {
ManaQRExport,
ManaQRUserContext,
ManaQRContact,
ManaQREvent,
ManaQRTodo,
EncodeResult,
DecodeResult,
} from '../types';

View file

@ -0,0 +1,142 @@
/**
* ManaQR Export Format Types
*
* Compact format for exporting personal data to QR codes.
* Target size: ~2,500 bytes (fits in single QR code)
*/
/** Relation type for contacts */
export type ContactRelation =
| 1 // Familie
| 2 // Partner
| 3 // Freund
| 4 // Arbeit
| 5; // Notfall
/** Priority level for todos */
export type TodoPriority =
| 1 // Hoch
| 2 // Mittel
| 3; // Niedrig
/** Minimal contact data */
export interface ManaQRContact {
/** Name (required) */
n: string;
/** Phone number */
p?: string;
/** Email */
e?: string;
/** Relation type */
r: ContactRelation;
}
/** Minimal calendar event data */
export interface ManaQREvent {
/** Title (max 30 chars recommended) */
t: string;
/** Start time as Unix timestamp (seconds) */
s: number;
/** Duration in minutes */
d: number;
/** Location (short) */
l?: string;
}
/** Minimal todo data */
export interface ManaQRTodo {
/** Title (max 40 chars recommended) */
t: string;
/** Priority 1-3 */
p: TodoPriority;
/** Due date as days from export date (0-255) */
d?: number;
}
/** User context data */
export interface ManaQRUserContext {
/** Name */
n: string;
/** Timezone (e.g., "Europe/Berlin") */
z?: string;
/** Language code (e.g., "de") */
l?: string;
/** Location/City */
w?: string;
/** Profession/Job */
b?: string;
/** Status/Motto */
m?: string;
}
/** Main export format */
export interface ManaQRExport {
/** Format version */
v: 1;
/** Export timestamp (Unix seconds) */
ts: number;
/** User context */
u: ManaQRUserContext;
/** Top contacts (recommended: 5) */
c: ManaQRContact[];
/** Upcoming events (recommended: 10) */
e: ManaQREvent[];
/** Priority todos (recommended: 15) */
t: ManaQRTodo[];
}
/** Result of encoding */
export interface EncodeResult {
/** Encoded string for QR code */
data: string;
/** Size in bytes */
size: number;
/** Whether it fits in a single QR code */
fitsInQR: boolean;
}
/** Decode error types */
export type DecodeError =
| 'INVALID_PREFIX'
| 'INVALID_BASE64'
| 'DECOMPRESSION_FAILED'
| 'INVALID_JSON'
| 'INVALID_VERSION'
| 'INVALID_STRUCTURE';
/** Result of decoding */
export type DecodeResult =
| { success: true; data: ManaQRExport }
| { success: false; error: DecodeError; message: string };
/** Limits for QR export */
export const MANA_QR_LIMITS = {
/** Max bytes for QR code (with some buffer) */
MAX_QR_BYTES: 2500,
/** Recommended max contacts */
MAX_CONTACTS: 5,
/** Recommended max events */
MAX_EVENTS: 10,
/** Recommended max todos */
MAX_TODOS: 15,
/** Max title length for events */
MAX_EVENT_TITLE: 30,
/** Max title length for todos */
MAX_TODO_TITLE: 40,
} as const;
/** Relation labels (for UI) */
export const RELATION_LABELS: Record<ContactRelation, string> = {
1: 'Familie',
2: 'Partner',
3: 'Freund',
4: 'Arbeit',
5: 'Notfall',
};
/** Priority labels (for UI) */
export const PRIORITY_LABELS: Record<TodoPriority, string> = {
1: 'Hoch',
2: 'Mittel',
3: 'Niedrig',
};

View file

@ -0,0 +1,124 @@
/**
* QR Code Wallpaper Generation
*
* Creates device wallpapers from QR codes using @manacore/wallpaper-generator.
*/
import type { ManaQRExport, EncodeResult } from './types';
import type {
WallpaperOptions,
WallpaperResult,
DeviceOption,
Layout,
Background,
} from '@manacore/wallpaper-generator';
import {
createWallpaperGenerator,
DEFAULT_CENTER_LAYOUT,
DEFAULT_BACKGROUND,
} from '@manacore/wallpaper-generator';
import { toDataURL } from './generate';
/** Options for QR wallpaper generation */
export interface QRWallpaperOptions {
/** Target device (preset ID like 'iphone-15-pro-max' or custom dimensions) */
device: DeviceOption;
/** Layout configuration (default: center) */
layout?: Layout;
/** Background configuration (default: dark gradient) */
background?: Background;
/** QR code size before placing on wallpaper (default: 600) */
qrSize?: number;
/** Output format (default: 'png') */
format?: 'png' | 'jpeg';
/** JPEG quality 0-100 (default: 90) */
quality?: number;
}
const DEFAULT_QR_WALLPAPER_OPTIONS: Partial<QRWallpaperOptions> = {
layout: DEFAULT_CENTER_LAYOUT,
background: DEFAULT_BACKGROUND,
qrSize: 600,
format: 'png',
quality: 90,
};
/**
* Generate a device wallpaper from QR code data.
*
* @param data - QR code data (string, ManaQRExport, or EncodeResult)
* @param options - Wallpaper generation options
* @returns Promise with wallpaper result
*
* @example
* ```ts
* import { toWallpaper } from '@manacore/qr-export/wallpaper';
*
* const result = await toWallpaper(encodeResult, {
* device: 'iphone-15-pro-max',
* layout: { type: 'center', scale: 0.7 },
* background: { type: 'gradient', colors: ['#1a1a2e', '#16213e'] },
* });
*
* // result.dataUrl contains the wallpaper as data URL
* ```
*/
export async function toWallpaper(
data: string | ManaQRExport | EncodeResult,
options: QRWallpaperOptions
): Promise<WallpaperResult> {
const opts = { ...DEFAULT_QR_WALLPAPER_OPTIONS, ...options };
// Generate QR code as data URL
const qrDataUrl = await toDataURL(data, {
size: opts.qrSize,
errorCorrectionLevel: 'M',
darkColor: '#000000',
lightColor: '#ffffff',
});
// Create wallpaper generator
const generator = createWallpaperGenerator();
// Generate wallpaper
const wallpaperOptions: WallpaperOptions = {
device: opts.device,
layout: opts.layout ?? DEFAULT_CENTER_LAYOUT,
background: opts.background ?? DEFAULT_BACKGROUND,
format: opts.format,
quality: opts.quality,
};
return generator.generate({ type: 'dataUrl', data: qrDataUrl }, wallpaperOptions);
}
/**
* Generate a preview of the QR wallpaper (smaller, faster).
*
* @param data - QR code data
* @param options - Wallpaper generation options
* @returns Promise with preview data URL
*/
export async function toWallpaperPreview(
data: string | ManaQRExport | EncodeResult,
options: QRWallpaperOptions
): Promise<string> {
const opts = { ...DEFAULT_QR_WALLPAPER_OPTIONS, ...options };
// Generate QR code at smaller size for preview
const qrSize = opts.qrSize ?? 600;
const qrDataUrl = await toDataURL(data, {
size: Math.round(qrSize / 2),
errorCorrectionLevel: 'M',
});
const generator = createWallpaperGenerator();
const wallpaperOptions: WallpaperOptions = {
device: opts.device,
layout: opts.layout ?? DEFAULT_CENTER_LAYOUT,
background: opts.background ?? DEFAULT_BACKGROUND,
};
return generator.preview({ type: 'dataUrl', data: qrDataUrl }, wallpaperOptions);
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "vitest.config.ts"],
"exclude": ["node_modules", "src/**/*.svelte"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

View file

@ -10,11 +10,15 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./wallpaper": {
"types": "./dist/wallpaper.d.ts",
"import": "./dist/wallpaper.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --dts --watch",
"build": "tsup src/index.ts src/wallpaper.ts --format esm --dts --clean",
"dev": "tsup src/index.ts src/wallpaper.ts --format esm --dts --watch",
"test": "vitest",
"test:run": "vitest run",
"type-check": "tsc --noEmit"
@ -31,11 +35,15 @@
"vitest": "^1.6.1"
},
"peerDependencies": {
"sharp": "^0.33.0"
"sharp": "^0.33.0",
"@manacore/wallpaper-generator": "workspace:*"
},
"peerDependenciesMeta": {
"sharp": {
"optional": true
},
"@manacore/wallpaper-generator": {
"optional": true
}
},
"files": [

View file

@ -0,0 +1,157 @@
/**
* Spiral-DB Wallpaper Generation
*
* Creates device wallpapers from SpiralDB images using @manacore/wallpaper-generator.
*/
import type { SpiralImage } from './types.js';
import type {
WallpaperOptions,
WallpaperResult,
DeviceOption,
Layout,
Background,
} from '@manacore/wallpaper-generator';
import {
createWallpaperGenerator,
DEFAULT_CENTER_LAYOUT,
DEFAULT_BACKGROUND,
} from '@manacore/wallpaper-generator';
import { exportToDataUrl } from './png.js';
/** Options for Spiral wallpaper generation */
export interface SpiralWallpaperOptions {
/** Target device (preset ID like 'iphone-15-pro-max' or custom dimensions) */
device: DeviceOption;
/** Layout configuration (default: center) */
layout?: Layout;
/** Background configuration (default: dark gradient) */
background?: Background;
/** Scale factor for the spiral image (default: 10 for crisp pixel art) */
scale?: number;
/** Output format (default: 'png') */
format?: 'png' | 'jpeg';
/** JPEG quality 0-100 (default: 90) */
quality?: number;
}
const DEFAULT_SPIRAL_WALLPAPER_OPTIONS: Partial<SpiralWallpaperOptions> = {
layout: DEFAULT_CENTER_LAYOUT,
background: DEFAULT_BACKGROUND,
scale: 10,
format: 'png',
quality: 90,
};
/**
* Generate a device wallpaper from a SpiralDB image.
*
* @param image - SpiralDB image
* @param options - Wallpaper generation options
* @returns Promise with wallpaper result
*
* @example
* ```ts
* import { SpiralDB, createTodoSchema } from '@manacore/spiral-db';
* import { toWallpaper } from '@manacore/spiral-db/wallpaper';
*
* const db = new SpiralDB({ schema: createTodoSchema() });
* db.insert({ title: 'My Todo', ... });
*
* const image = db.getImage();
* const result = await toWallpaper(image, {
* device: 'iphone-15-pro-max',
* layout: { type: 'corner', position: 'bottom-right', scale: 0.3 },
* background: { type: 'gradient', colors: ['#0f0f23', '#1a1a2e'] },
* });
*
* // result.dataUrl contains the wallpaper as data URL
* ```
*/
export async function toWallpaper(
image: SpiralImage,
options: SpiralWallpaperOptions
): Promise<WallpaperResult> {
const opts = { ...DEFAULT_SPIRAL_WALLPAPER_OPTIONS, ...options };
// Convert spiral image to data URL
const dataUrl = exportToDataUrl(image);
// Create wallpaper generator
const generator = createWallpaperGenerator();
// Apply scale to layout
const layoutConfig = opts.layout ?? DEFAULT_CENTER_LAYOUT;
const scaleValue = opts.scale ?? 10;
const layout = applyScaleToLayout(layoutConfig, scaleValue);
// Generate wallpaper
const wallpaperOptions: WallpaperOptions = {
device: opts.device,
layout,
background: opts.background ?? DEFAULT_BACKGROUND,
format: opts.format,
quality: opts.quality,
};
return generator.generate({ type: 'dataUrl', data: dataUrl }, wallpaperOptions);
}
/**
* Generate a preview of the Spiral wallpaper (smaller, faster).
*
* @param image - SpiralDB image
* @param options - Wallpaper generation options
* @returns Promise with preview data URL
*/
export async function toWallpaperPreview(
image: SpiralImage,
options: SpiralWallpaperOptions
): Promise<string> {
const opts = { ...DEFAULT_SPIRAL_WALLPAPER_OPTIONS, ...options };
// Convert spiral image to data URL
const dataUrl = exportToDataUrl(image);
const generator = createWallpaperGenerator();
// Apply scale to layout (reduced for preview)
const layoutConfig = opts.layout ?? DEFAULT_CENTER_LAYOUT;
const scaleValue = opts.scale ?? 10;
const layout = applyScaleToLayout(layoutConfig, scaleValue * 0.5);
const wallpaperOptions: WallpaperOptions = {
device: opts.device,
layout,
background: opts.background ?? DEFAULT_BACKGROUND,
};
return generator.preview({ type: 'dataUrl', data: dataUrl }, wallpaperOptions);
}
/**
* Apply scale factor to layout
* For spiral images which are typically small (e.g., 11x11 pixels),
* we multiply the scale to make them visible on wallpapers.
*/
function applyScaleToLayout(layout: Layout, scale: number): Layout {
switch (layout.type) {
case 'center':
return {
...layout,
scale: (layout.scale ?? 1.0) * scale,
};
case 'corner':
return {
...layout,
scale: (layout.scale ?? 0.3) * scale,
};
case 'pattern':
return {
...layout,
scale: (layout.scale ?? 0.5) * scale,
};
default:
return layout;
}
}

View file

@ -0,0 +1,38 @@
{
"name": "@manacore/wallpaper-generator",
"version": "0.1.0",
"private": true,
"description": "Device wallpaper generator from QR codes and images",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./svelte": "./src/svelte/index.ts",
"./presets": "./src/presets/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"svelte": "^5.0.0",
"sharp": "^0.33.0"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
},
"sharp": {
"optional": true
}
}
}

View file

@ -0,0 +1,160 @@
/**
* Gradient Background Renderer
*
* Creates linear gradients on canvas.
*/
import { parseHexColor } from './solid.js';
/**
* Calculate gradient end points from angle
* Angle: 0 = bottom to top, 90 = left to right, 180 = top to bottom
*/
function getGradientCoordinates(
width: number,
height: number,
angleDegrees: number
): { x0: number; y0: number; x1: number; y1: number } {
// Convert angle to radians (CSS gradient angles: 0deg = to top, 180deg = to bottom)
const angleRad = ((angleDegrees - 90) * Math.PI) / 180;
// Calculate the diagonal length for proper coverage
const diagonal = Math.sqrt(width * width + height * height);
const centerX = width / 2;
const centerY = height / 2;
const dx = Math.cos(angleRad) * diagonal;
const dy = Math.sin(angleRad) * diagonal;
return {
x0: centerX - dx / 2,
y0: centerY - dy / 2,
x1: centerX + dx / 2,
y1: centerY + dy / 2,
};
}
/**
* Fill canvas with linear gradient (browser)
*/
export function fillGradient(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
colors: string[],
angle = 180
): void {
if (colors.length === 0) {
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, width, height);
return;
}
if (colors.length === 1) {
ctx.fillStyle = colors[0];
ctx.fillRect(0, 0, width, height);
return;
}
const { x0, y0, x1, y1 } = getGradientCoordinates(width, height, angle);
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
// Distribute color stops evenly
colors.forEach((color, index) => {
const stop = index / (colors.length - 1);
gradient.addColorStop(stop, color);
});
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
/**
* Interpolate between two colors
*/
function interpolateColor(
color1: { r: number; g: number; b: number },
color2: { r: number; g: number; b: number },
t: number
): { r: number; g: number; b: number } {
return {
r: Math.round(color1.r + (color2.r - color1.r) * t),
g: Math.round(color1.g + (color2.g - color1.g) * t),
b: Math.round(color1.b + (color2.b - color1.b) * t),
};
}
/**
* Get color at position in gradient
*/
function getGradientColorAt(
colors: { r: number; g: number; b: number }[],
position: number
): { r: number; g: number; b: number } {
if (colors.length === 0) return { r: 0, g: 0, b: 0 };
if (colors.length === 1) return colors[0];
if (position <= 0) return colors[0];
if (position >= 1) return colors[colors.length - 1];
const scaledPosition = position * (colors.length - 1);
const index = Math.floor(scaledPosition);
const t = scaledPosition - index;
return interpolateColor(colors[index], colors[Math.min(index + 1, colors.length - 1)], t);
}
/**
* Create gradient buffer for Node.js/Sharp
*/
export function createGradientBuffer(
width: number,
height: number,
colors: string[],
angle = 180
): Uint8Array {
const buffer = new Uint8Array(width * height * 3);
if (colors.length === 0) {
return buffer; // All zeros (black)
}
const parsedColors = colors.map(parseHexColor);
if (colors.length === 1) {
const color = parsedColors[0];
for (let i = 0; i < width * height; i++) {
buffer[i * 3] = color.r;
buffer[i * 3 + 1] = color.g;
buffer[i * 3 + 2] = color.b;
}
return buffer;
}
// Convert angle to radians for calculation
const angleRad = ((angle - 90) * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
// Calculate the projection for each pixel
const diagonal = Math.sqrt(width * width + height * height);
const centerX = width / 2;
const centerY = height / 2;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Calculate position along gradient axis
const dx = x - centerX;
const dy = y - centerY;
const projection = (dx * cos + dy * sin) / diagonal + 0.5;
const color = getGradientColorAt(parsedColors, Math.max(0, Math.min(1, projection)));
const i = (y * width + x) * 3;
buffer[i] = color.r;
buffer[i + 1] = color.g;
buffer[i + 2] = color.b;
}
}
return buffer;
}

View file

@ -0,0 +1,6 @@
/**
* Background Exports
*/
export { fillSolid, createSolidBuffer, parseHexColor } from './solid.js';
export { fillGradient, createGradientBuffer } from './gradient.js';

View file

@ -0,0 +1,57 @@
/**
* Solid Background Renderer
*
* Fills canvas with a solid color.
*/
/**
* Parse hex color to RGB values
*/
export function parseHexColor(hex: string): { r: number; g: number; b: number } {
// Remove # if present
const cleanHex = hex.replace('#', '');
// Handle shorthand hex (e.g., #fff)
const fullHex =
cleanHex.length === 3
? cleanHex
.split('')
.map((c) => c + c)
.join('')
: cleanHex;
const r = parseInt(fullHex.substring(0, 2), 16);
const g = parseInt(fullHex.substring(2, 4), 16);
const b = parseInt(fullHex.substring(4, 6), 16);
return { r, g, b };
}
/**
* Fill canvas with solid color (browser)
*/
export function fillSolid(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
color: string
): void {
ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height);
}
/**
* Create solid color buffer for Node.js/Sharp
*/
export function createSolidBuffer(width: number, height: number, color: string): Uint8Array {
const { r, g, b } = parseHexColor(color);
const buffer = new Uint8Array(width * height * 3);
for (let i = 0; i < width * height; i++) {
buffer[i * 3] = r;
buffer[i * 3 + 1] = g;
buffer[i * 3 + 2] = b;
}
return buffer;
}

View file

@ -0,0 +1,140 @@
/**
* @manacore/wallpaper-generator
*
* Device wallpaper generator from QR codes, Spiral-DB images, and other sources.
* Supports both browser (Canvas) and Node.js (Sharp) environments.
*
* @example Browser usage:
* ```ts
* import { createWallpaperGenerator } from '@manacore/wallpaper-generator';
*
* const generator = createWallpaperGenerator();
*
* const result = await generator.generate(
* { type: 'dataUrl', data: qrCodeDataUrl },
* {
* device: 'iphone-15-pro-max',
* layout: { type: 'center', scale: 0.8 },
* background: { type: 'gradient', colors: ['#1a1a2e', '#16213e'] },
* }
* );
*
* // Download the wallpaper
* downloadWallpaper(result, 'my-wallpaper.png');
* ```
*
* @example Node.js usage:
* ```ts
* import { createWallpaperGenerator, saveWallpaperToFile } from '@manacore/wallpaper-generator';
*
* const generator = createWallpaperGenerator();
*
* const result = await generator.generate(
* { type: 'dataUrl', data: imageDataUrl },
* {
* device: 'desktop-4k',
* layout: { type: 'corner', position: 'bottom-right', scale: 0.2 },
* background: { type: 'solid', color: '#0f0f23' },
* }
* );
*
* await saveWallpaperToFile(result, './wallpaper.png');
* ```
*/
// Types
export type {
// Image Sources
ImageSource,
DataUrlSource,
CanvasSource,
BufferSource,
// Device Presets
DevicePreset,
DeviceCategory,
CustomDevice,
DeviceOption,
// Layouts
Layout,
CenterLayout,
CornerLayout,
PatternLayout,
CornerPosition,
// Backgrounds
Background,
SolidBackground,
GradientBackground,
// Options & Results
WallpaperOptions,
WallpaperResult,
OutputFormat,
// Generator Interface
WallpaperGenerator,
// Svelte Props
WallpaperModalProps,
} from './types.js';
// Constants
export {
DEFAULT_CENTER_LAYOUT,
DEFAULT_CORNER_LAYOUT,
DEFAULT_PATTERN_LAYOUT,
DEFAULT_BACKGROUND,
GRADIENT_PRESETS,
} from './types.js';
// Device Presets
export {
PHONE_PRESETS,
TABLET_PRESETS,
DESKTOP_PRESETS,
ALL_DEVICE_PRESETS,
getDevicePreset,
getPresetsByCategory,
getRecommendedPresets,
} from './presets/index.js';
// Renderers
export {
createBrowserGenerator,
downloadWallpaper,
copyWallpaperToClipboard,
} from './renderers/browser.js';
export { createNodeGenerator, saveWallpaperToFile } from './renderers/node.js';
// =============================================================================
// FACTORY FUNCTION
// =============================================================================
import type { WallpaperGenerator } from './types.js';
import { createBrowserGenerator } from './renderers/browser.js';
import { createNodeGenerator } from './renderers/node.js';
/**
* Detect if running in browser environment
*/
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined';
}
/**
* Create a wallpaper generator appropriate for the current environment.
*
* - In browser: Uses Canvas API
* - In Node.js: Uses Sharp
*
* @param options - Optional configuration
* @param options.preferNode - Force Node.js renderer even in browser (requires Sharp)
* @returns WallpaperGenerator instance
*/
export function createWallpaperGenerator(options?: { preferNode?: boolean }): WallpaperGenerator {
if (options?.preferNode) {
return createNodeGenerator();
}
if (isBrowser()) {
return createBrowserGenerator();
}
return createNodeGenerator();
}

View file

@ -0,0 +1,59 @@
/**
* Center Layout
*
* Places image centered on the wallpaper.
*/
import type { CenterLayout } from '../types.js';
export interface CenterPosition {
x: number;
y: number;
width: number;
height: number;
}
/**
* Calculate center position for image
*/
export function calculateCenterPosition(
canvasWidth: number,
canvasHeight: number,
imageWidth: number,
imageHeight: number,
layout: CenterLayout
): CenterPosition {
const scale = layout.scale ?? 1.0;
const offset = layout.offset ?? [0, 0];
const scaledWidth = imageWidth * scale;
const scaledHeight = imageHeight * scale;
// Center the image
const x = (canvasWidth - scaledWidth) / 2 + offset[0];
const y = (canvasHeight - scaledHeight) / 2 + offset[1];
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(scaledWidth),
height: Math.round(scaledHeight),
};
}
/**
* Draw image centered on canvas (browser)
*/
export function drawCentered(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement | HTMLCanvasElement,
canvasWidth: number,
canvasHeight: number,
layout: CenterLayout
): void {
const pos = calculateCenterPosition(canvasWidth, canvasHeight, image.width, image.height, layout);
// Use crisp rendering for pixel art / QR codes
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, pos.x, pos.y, pos.width, pos.height);
}

View file

@ -0,0 +1,79 @@
/**
* Corner Layout
*
* Places image in one of the four corners.
*/
import type { CornerLayout } from '../types.js';
export interface CornerPositionResult {
x: number;
y: number;
width: number;
height: number;
}
/**
* Calculate corner position for image
*/
export function calculateCornerPosition(
canvasWidth: number,
canvasHeight: number,
imageWidth: number,
imageHeight: number,
layout: CornerLayout
): CornerPositionResult {
const scale = layout.scale ?? 0.3;
const padding = layout.padding ?? 40;
const position = layout.position ?? 'bottom-right';
const scaledWidth = imageWidth * scale;
const scaledHeight = imageHeight * scale;
let x: number;
let y: number;
switch (position) {
case 'top-left':
x = padding;
y = padding;
break;
case 'top-right':
x = canvasWidth - scaledWidth - padding;
y = padding;
break;
case 'bottom-left':
x = padding;
y = canvasHeight - scaledHeight - padding;
break;
case 'bottom-right':
default:
x = canvasWidth - scaledWidth - padding;
y = canvasHeight - scaledHeight - padding;
break;
}
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(scaledWidth),
height: Math.round(scaledHeight),
};
}
/**
* Draw image in corner on canvas (browser)
*/
export function drawCorner(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement | HTMLCanvasElement,
canvasWidth: number,
canvasHeight: number,
layout: CornerLayout
): void {
const pos = calculateCornerPosition(canvasWidth, canvasHeight, image.width, image.height, layout);
// Use crisp rendering for pixel art / QR codes
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, pos.x, pos.y, pos.width, pos.height);
}

View file

@ -0,0 +1,11 @@
/**
* Layout Exports
*/
export { calculateCenterPosition, drawCentered } from './center.js';
export { calculateCornerPosition, drawCorner } from './corner.js';
export { calculatePatternTiles, drawPattern } from './pattern.js';
export type { CenterPosition } from './center.js';
export type { CornerPositionResult } from './corner.js';
export type { PatternTile } from './pattern.js';

View file

@ -0,0 +1,88 @@
/**
* Pattern Layout
*
* Tiles image across the wallpaper as a repeating pattern.
*/
import type { PatternLayout } from '../types.js';
export interface PatternTile {
x: number;
y: number;
width: number;
height: number;
}
/**
* Calculate tile positions for pattern
*/
export function calculatePatternTiles(
canvasWidth: number,
canvasHeight: number,
imageWidth: number,
imageHeight: number,
layout: PatternLayout
): PatternTile[] {
const scale = layout.scale ?? 0.5;
const gap = layout.gap ?? 20;
const tileWidth = imageWidth * scale;
const tileHeight = imageHeight * scale;
const tiles: PatternTile[] = [];
// Calculate how many tiles fit (with overlap to cover edges)
const cols = Math.ceil(canvasWidth / (tileWidth + gap)) + 1;
const rows = Math.ceil(canvasHeight / (tileHeight + gap)) + 1;
// Center the pattern grid
const totalPatternWidth = cols * tileWidth + (cols - 1) * gap;
const totalPatternHeight = rows * tileHeight + (rows - 1) * gap;
const startX = (canvasWidth - totalPatternWidth) / 2;
const startY = (canvasHeight - totalPatternHeight) / 2;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
tiles.push({
x: Math.round(startX + col * (tileWidth + gap)),
y: Math.round(startY + row * (tileHeight + gap)),
width: Math.round(tileWidth),
height: Math.round(tileHeight),
});
}
}
return tiles;
}
/**
* Draw pattern on canvas (browser)
*/
export function drawPattern(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement | HTMLCanvasElement,
canvasWidth: number,
canvasHeight: number,
layout: PatternLayout
): void {
const opacity = layout.opacity ?? 0.15;
const tiles = calculatePatternTiles(canvasWidth, canvasHeight, image.width, image.height, layout);
// Save current state
ctx.save();
// Set opacity for pattern
ctx.globalAlpha = opacity;
// Use crisp rendering for pixel art / QR codes
ctx.imageSmoothingEnabled = false;
// Draw all tiles
for (const tile of tiles) {
ctx.drawImage(image, tile.x, tile.y, tile.width, tile.height);
}
// Restore state
ctx.restore();
}

View file

@ -0,0 +1,336 @@
/**
* Device Presets
*
* Standard device screen dimensions for wallpaper generation.
* Dimensions are in portrait orientation (height > width).
*/
import type { DevicePreset, DeviceCategory } from '../types.js';
// =============================================================================
// PHONE PRESETS
// =============================================================================
export const PHONE_PRESETS: DevicePreset[] = [
// Apple iPhones
{
id: 'iphone-15-pro-max',
name: 'iPhone 15 Pro Max',
category: 'phone',
width: 1290,
height: 2796,
pixelRatio: 3,
},
{
id: 'iphone-15-pro',
name: 'iPhone 15 Pro',
category: 'phone',
width: 1179,
height: 2556,
pixelRatio: 3,
},
{
id: 'iphone-15',
name: 'iPhone 15',
category: 'phone',
width: 1179,
height: 2556,
pixelRatio: 3,
},
{
id: 'iphone-14',
name: 'iPhone 14',
category: 'phone',
width: 1170,
height: 2532,
pixelRatio: 3,
},
{
id: 'iphone-se',
name: 'iPhone SE',
category: 'phone',
width: 750,
height: 1334,
pixelRatio: 2,
},
// Google Pixels
{
id: 'pixel-8-pro',
name: 'Pixel 8 Pro',
category: 'phone',
width: 1344,
height: 2992,
pixelRatio: 3,
},
{
id: 'pixel-8',
name: 'Pixel 8',
category: 'phone',
width: 1080,
height: 2400,
pixelRatio: 2.625,
},
{
id: 'pixel-7a',
name: 'Pixel 7a',
category: 'phone',
width: 1080,
height: 2400,
pixelRatio: 2.5,
},
// Samsung Galaxy
{
id: 'samsung-s24-ultra',
name: 'Samsung S24 Ultra',
category: 'phone',
width: 1440,
height: 3120,
pixelRatio: 3.75,
},
{
id: 'samsung-s24',
name: 'Samsung S24',
category: 'phone',
width: 1080,
height: 2340,
pixelRatio: 2.625,
},
{
id: 'samsung-a54',
name: 'Samsung A54',
category: 'phone',
width: 1080,
height: 2340,
pixelRatio: 2.625,
},
// Generic Android
{
id: 'android-fhd',
name: 'Android FHD+',
category: 'phone',
width: 1080,
height: 2400,
pixelRatio: 2.5,
},
{
id: 'android-hd',
name: 'Android HD+',
category: 'phone',
width: 720,
height: 1600,
pixelRatio: 2,
},
];
// =============================================================================
// TABLET PRESETS
// =============================================================================
export const TABLET_PRESETS: DevicePreset[] = [
// Apple iPads
{
id: 'ipad-pro-12.9',
name: 'iPad Pro 12.9"',
category: 'tablet',
width: 2048,
height: 2732,
pixelRatio: 2,
},
{
id: 'ipad-pro-11',
name: 'iPad Pro 11"',
category: 'tablet',
width: 1668,
height: 2388,
pixelRatio: 2,
},
{
id: 'ipad-air',
name: 'iPad Air',
category: 'tablet',
width: 1640,
height: 2360,
pixelRatio: 2,
},
{
id: 'ipad-mini',
name: 'iPad mini',
category: 'tablet',
width: 1488,
height: 2266,
pixelRatio: 2,
},
{
id: 'ipad-10th',
name: 'iPad 10th Gen',
category: 'tablet',
width: 1640,
height: 2360,
pixelRatio: 2,
},
// Android Tablets
{
id: 'samsung-tab-s9-ultra',
name: 'Samsung Tab S9 Ultra',
category: 'tablet',
width: 1848,
height: 2960,
pixelRatio: 2,
},
{
id: 'samsung-tab-s9',
name: 'Samsung Tab S9',
category: 'tablet',
width: 1600,
height: 2560,
pixelRatio: 2,
},
{
id: 'android-tablet-generic',
name: 'Android Tablet',
category: 'tablet',
width: 1600,
height: 2560,
pixelRatio: 2,
},
];
// =============================================================================
// DESKTOP PRESETS
// =============================================================================
export const DESKTOP_PRESETS: DevicePreset[] = [
// Standard Resolutions
{
id: 'desktop-4k',
name: '4K UHD',
category: 'desktop',
width: 3840,
height: 2160,
pixelRatio: 1,
},
{
id: 'desktop-2k',
name: '2K QHD',
category: 'desktop',
width: 2560,
height: 1440,
pixelRatio: 1,
},
{
id: 'desktop-fhd',
name: 'Full HD',
category: 'desktop',
width: 1920,
height: 1080,
pixelRatio: 1,
},
// Apple MacBooks
{
id: 'macbook-pro-16',
name: 'MacBook Pro 16"',
category: 'desktop',
width: 3456,
height: 2234,
pixelRatio: 2,
},
{
id: 'macbook-pro-14',
name: 'MacBook Pro 14"',
category: 'desktop',
width: 3024,
height: 1964,
pixelRatio: 2,
},
{
id: 'macbook-air-15',
name: 'MacBook Air 15"',
category: 'desktop',
width: 2880,
height: 1864,
pixelRatio: 2,
},
{
id: 'macbook-air-13',
name: 'MacBook Air 13"',
category: 'desktop',
width: 2560,
height: 1664,
pixelRatio: 2,
},
// Ultrawide
{
id: 'ultrawide-34',
name: 'Ultrawide 34"',
category: 'desktop',
width: 3440,
height: 1440,
pixelRatio: 1,
},
{
id: 'ultrawide-49',
name: 'Super Ultrawide 49"',
category: 'desktop',
width: 5120,
height: 1440,
pixelRatio: 1,
},
];
// =============================================================================
// ALL PRESETS
// =============================================================================
/** All device presets combined */
export const ALL_DEVICE_PRESETS: DevicePreset[] = [
...PHONE_PRESETS,
...TABLET_PRESETS,
...DESKTOP_PRESETS,
];
/** Map of preset ID to preset for quick lookup */
export const DEVICE_PRESET_MAP: Map<string, DevicePreset> = new Map(
ALL_DEVICE_PRESETS.map((preset) => [preset.id, preset])
);
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Get device preset by ID
*/
export function getDevicePreset(id: string): DevicePreset | undefined {
return DEVICE_PRESET_MAP.get(id);
}
/**
* Get all presets for a category
*/
export function getPresetsByCategory(category: DeviceCategory): DevicePreset[] {
return ALL_DEVICE_PRESETS.filter((preset) => preset.category === category);
}
/**
* Get recommended presets (most common devices)
*/
export function getRecommendedPresets(): DevicePreset[] {
const recommended = [
'iphone-15-pro-max',
'iphone-15',
'samsung-s24-ultra',
'pixel-8-pro',
'ipad-pro-12.9',
'macbook-pro-16',
'desktop-4k',
];
return recommended
.map((id) => DEVICE_PRESET_MAP.get(id))
.filter((preset): preset is DevicePreset => preset !== undefined);
}

View file

@ -0,0 +1,14 @@
/**
* Device Presets Exports
*/
export {
PHONE_PRESETS,
TABLET_PRESETS,
DESKTOP_PRESETS,
ALL_DEVICE_PRESETS,
DEVICE_PRESET_MAP,
getDevicePreset,
getPresetsByCategory,
getRecommendedPresets,
} from './devices.js';

View file

@ -0,0 +1,316 @@
/**
* Browser Renderer
*
* Canvas-based wallpaper generation for browser environments.
*/
import type {
ImageSource,
WallpaperOptions,
WallpaperResult,
WallpaperGenerator,
DevicePreset,
DeviceCategory,
Layout,
Background,
} from '../types.js';
import { ALL_DEVICE_PRESETS, getDevicePreset, getPresetsByCategory } from '../presets/index.js';
import { fillSolid } from '../backgrounds/solid.js';
import { fillGradient } from '../backgrounds/gradient.js';
import { drawCentered } from '../layouts/center.js';
import { drawCorner } from '../layouts/corner.js';
import { drawPattern } from '../layouts/pattern.js';
// =============================================================================
// IMAGE SOURCE LOADING
// =============================================================================
/**
* Load image from data URL
*/
async function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error(`Failed to load image: ${e}`));
img.src = dataUrl;
});
}
/**
* Load image from canvas
*/
function loadImageFromCanvas(canvas: HTMLCanvasElement): HTMLCanvasElement {
return canvas;
}
/**
* Load image from buffer (convert to canvas)
*/
function loadImageFromBuffer(
buffer: Uint8Array,
width: number,
height: number,
channels: 3 | 4 = 4
): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get canvas context');
const imageData = ctx.createImageData(width, height);
if (channels === 4) {
// RGBA - direct copy
imageData.data.set(buffer);
} else {
// RGB - need to add alpha channel
for (let i = 0; i < width * height; i++) {
imageData.data[i * 4] = buffer[i * 3];
imageData.data[i * 4 + 1] = buffer[i * 3 + 1];
imageData.data[i * 4 + 2] = buffer[i * 3 + 2];
imageData.data[i * 4 + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas;
}
/**
* Convert ImageSource to drawable element
*/
async function loadSourceImage(source: ImageSource): Promise<HTMLImageElement | HTMLCanvasElement> {
switch (source.type) {
case 'dataUrl':
return loadImageFromDataUrl(source.data);
case 'canvas':
return loadImageFromCanvas(source.canvas);
case 'buffer':
return loadImageFromBuffer(source.buffer, source.width, source.height, source.channels ?? 4);
default:
throw new Error('Unknown image source type');
}
}
// =============================================================================
// BACKGROUND RENDERING
// =============================================================================
/**
* Draw background on canvas
*/
function drawBackground(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
background: Background
): void {
if (background.type === 'solid') {
fillSolid(ctx, width, height, background.color);
} else if (background.type === 'gradient') {
fillGradient(ctx, width, height, background.colors, background.angle ?? 180);
}
}
// =============================================================================
// LAYOUT RENDERING
// =============================================================================
/**
* Draw image with layout
*/
function drawWithLayout(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement | HTMLCanvasElement,
canvasWidth: number,
canvasHeight: number,
layout: Layout
): void {
switch (layout.type) {
case 'center':
drawCentered(ctx, image, canvasWidth, canvasHeight, layout);
break;
case 'corner':
drawCorner(ctx, image, canvasWidth, canvasHeight, layout);
break;
case 'pattern':
drawPattern(ctx, image, canvasWidth, canvasHeight, layout);
break;
default:
throw new Error('Unknown layout type');
}
}
// =============================================================================
// DIMENSION RESOLUTION
// =============================================================================
/**
* Resolve device option to dimensions
*/
function resolveDimensions(device: string | { width: number; height: number }): {
width: number;
height: number;
} {
if (typeof device === 'string') {
const preset = getDevicePreset(device);
if (!preset) {
throw new Error(`Unknown device preset: ${device}`);
}
return { width: preset.width, height: preset.height };
}
return device;
}
// =============================================================================
// BROWSER GENERATOR
// =============================================================================
/**
* Create browser-based wallpaper generator
*/
export function createBrowserGenerator(): WallpaperGenerator {
return {
async generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult> {
const { width, height } = resolveDimensions(options.device);
const format = options.format ?? 'png';
const quality = options.quality ?? 90;
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get canvas context');
// Draw background
drawBackground(ctx, width, height, options.background);
// Load and draw source image
const sourceImage = await loadSourceImage(source);
drawWithLayout(ctx, sourceImage, width, height, options.layout);
// Export to data URL
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
const dataUrl = canvas.toDataURL(mimeType, quality / 100);
// Estimate size from base64
const base64Length = dataUrl.split(',')[1]?.length ?? 0;
const size = Math.ceil((base64Length * 3) / 4);
return {
dataUrl,
width,
height,
format,
size,
};
},
async preview(source: ImageSource, options: WallpaperOptions): Promise<string> {
// Generate at 1/4 resolution for preview
const { width, height } = resolveDimensions(options.device);
const previewWidth = Math.round(width / 4);
const previewHeight = Math.round(height / 4);
const canvas = document.createElement('canvas');
canvas.width = previewWidth;
canvas.height = previewHeight;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get canvas context');
// Draw background
drawBackground(ctx, previewWidth, previewHeight, options.background);
// Load and draw source image (with scaled layout)
const sourceImage = await loadSourceImage(source);
// Scale down the layout parameters
const scaledLayout = scaleLayout(options.layout, 0.25);
drawWithLayout(ctx, sourceImage, previewWidth, previewHeight, scaledLayout);
return canvas.toDataURL('image/jpeg', 0.7);
},
getSupportedDevices(): DevicePreset[] {
return ALL_DEVICE_PRESETS;
},
getDevicesByCategory(category: DeviceCategory): DevicePreset[] {
return getPresetsByCategory(category);
},
};
}
/**
* Scale layout parameters for preview
*/
function scaleLayout(layout: Layout, scaleFactor: number): Layout {
switch (layout.type) {
case 'center':
return {
...layout,
offset: layout.offset
? [layout.offset[0] * scaleFactor, layout.offset[1] * scaleFactor]
: undefined,
};
case 'corner':
return {
...layout,
padding: (layout.padding ?? 40) * scaleFactor,
};
case 'pattern':
return {
...layout,
gap: (layout.gap ?? 20) * scaleFactor,
};
default:
return layout;
}
}
// =============================================================================
// BROWSER UTILITIES
// =============================================================================
/**
* Download wallpaper to user's device
*/
export function downloadWallpaper(result: WallpaperResult, filename?: string): void {
const defaultFilename = `wallpaper-${result.width}x${result.height}.${result.format}`;
const link = document.createElement('a');
link.href = result.dataUrl;
link.download = filename ?? defaultFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Copy wallpaper to clipboard (if supported)
*/
export async function copyWallpaperToClipboard(result: WallpaperResult): Promise<boolean> {
if (!navigator.clipboard?.write) {
return false;
}
try {
// Convert data URL to blob
const response = await fetch(result.dataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,6 @@
/**
* Renderer Exports
*/
export { createBrowserGenerator, downloadWallpaper, copyWallpaperToClipboard } from './browser.js';
export { createNodeGenerator, saveWallpaperToFile } from './node.js';

View file

@ -0,0 +1,397 @@
/**
* Node.js Renderer
*
* Sharp-based wallpaper generation for Node.js environments.
*/
import type {
ImageSource,
WallpaperOptions,
WallpaperResult,
WallpaperGenerator,
DevicePreset,
DeviceCategory,
Layout,
Background,
CenterLayout,
CornerLayout,
PatternLayout,
} from '../types.js';
import { ALL_DEVICE_PRESETS, getDevicePreset, getPresetsByCategory } from '../presets/index.js';
import { createSolidBuffer } from '../backgrounds/solid.js';
import { createGradientBuffer } from '../backgrounds/gradient.js';
import { calculateCenterPosition } from '../layouts/center.js';
import { calculateCornerPosition } from '../layouts/corner.js';
import { calculatePatternTiles } from '../layouts/pattern.js';
// =============================================================================
// TYPES
// =============================================================================
interface SharpInstance {
metadata(): Promise<{ width?: number; height?: number }>;
resize(width: number, height: number, options?: object): SharpInstance;
composite(inputs: CompositeInput[]): SharpInstance;
png(options?: { compressionLevel?: number }): SharpInstance;
jpeg(options?: { quality?: number }): SharpInstance;
toBuffer(): Promise<Buffer>;
}
interface CompositeInput {
input:
| Buffer
| {
create: {
width: number;
height: number;
channels: number;
background: { r: number; g: number; b: number; alpha: number };
};
};
top?: number;
left?: number;
blend?: string;
}
type SharpConstructor = {
(
input?:
| Buffer
| string
| {
create: {
width: number;
height: number;
channels: 3 | 4;
background: { r: number; g: number; b: number; alpha?: number };
};
}
): SharpInstance;
(
buffer: Buffer,
options: { raw: { width: number; height: number; channels: 3 | 4 } }
): SharpInstance;
};
// =============================================================================
// SHARP LOADING
// =============================================================================
let sharpModule: SharpConstructor | null = null;
async function getSharp(): Promise<SharpConstructor> {
if (sharpModule) return sharpModule;
try {
const module = await import('sharp');
sharpModule = module.default as SharpConstructor;
return sharpModule;
} catch {
throw new Error(
'Sharp is required for Node.js wallpaper generation. Install it with: npm install sharp'
);
}
}
// =============================================================================
// IMAGE SOURCE LOADING
// =============================================================================
/**
* Convert data URL to Buffer
*/
function dataUrlToBuffer(dataUrl: string): Buffer {
const base64 = dataUrl.split(',')[1];
if (!base64) throw new Error('Invalid data URL');
return Buffer.from(base64, 'base64');
}
/**
* Load source image and get dimensions
*/
async function loadSourceBuffer(
source: ImageSource
): Promise<{ buffer: Buffer; width: number; height: number; isRaw?: boolean; channels?: 3 | 4 }> {
const sharp = await getSharp();
switch (source.type) {
case 'dataUrl': {
const buffer = dataUrlToBuffer(source.data);
const img = sharp(buffer);
const metadata = await img.metadata();
return {
buffer,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
isRaw: false,
};
}
case 'canvas': {
throw new Error('Canvas source is not supported in Node.js environment');
}
case 'buffer': {
return {
buffer: Buffer.from(source.buffer),
width: source.width,
height: source.height,
isRaw: true,
channels: source.channels ?? 3,
};
}
default:
throw new Error('Unknown image source type');
}
}
// =============================================================================
// BACKGROUND CREATION
// =============================================================================
/**
* Create background buffer
*/
function createBackgroundBuffer(width: number, height: number, background: Background): Buffer {
let buffer: Uint8Array;
if (background.type === 'solid') {
buffer = createSolidBuffer(width, height, background.color);
} else if (background.type === 'gradient') {
buffer = createGradientBuffer(width, height, background.colors, background.angle ?? 180);
} else {
buffer = createSolidBuffer(width, height, '#000000');
}
return Buffer.from(buffer);
}
// =============================================================================
// DIMENSION RESOLUTION
// =============================================================================
/**
* Resolve device option to dimensions
*/
function resolveDimensions(device: string | { width: number; height: number }): {
width: number;
height: number;
} {
if (typeof device === 'string') {
const preset = getDevicePreset(device);
if (!preset) {
throw new Error(`Unknown device preset: ${device}`);
}
return { width: preset.width, height: preset.height };
}
return device;
}
// =============================================================================
// NODE.JS GENERATOR
// =============================================================================
/**
* Create Node.js-based wallpaper generator using Sharp
*/
export function createNodeGenerator(): WallpaperGenerator {
return {
async generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult> {
const sharp = await getSharp();
const { width, height } = resolveDimensions(options.device);
const format = options.format ?? 'png';
const quality = options.quality ?? 90;
// Create background
const bgBuffer = createBackgroundBuffer(width, height, options.background);
let canvas = sharp(bgBuffer, { raw: { width, height, channels: 3 } });
// Load source image
const sourceData = await loadSourceBuffer(source);
// Calculate composite operations based on layout
const composites: CompositeInput[] = [];
const { layout } = options;
// Create sharp instance for source image
const createSourceSharp = () => {
if (sourceData.isRaw) {
return sharp(sourceData.buffer, {
raw: {
width: sourceData.width,
height: sourceData.height,
channels: sourceData.channels ?? 3,
},
});
}
return sharp(sourceData.buffer);
};
if (layout.type === 'center') {
const pos = calculateCenterPosition(
width,
height,
sourceData.width,
sourceData.height,
layout as CenterLayout
);
// Resize source image
const resizedSource = await createSourceSharp()
.resize(pos.width, pos.height, { fit: 'fill' })
.png()
.toBuffer();
composites.push({
input: resizedSource,
top: Math.max(0, pos.y),
left: Math.max(0, pos.x),
});
} else if (layout.type === 'corner') {
const pos = calculateCornerPosition(
width,
height,
sourceData.width,
sourceData.height,
layout as CornerLayout
);
const resizedSource = await createSourceSharp()
.resize(pos.width, pos.height, { fit: 'fill' })
.png()
.toBuffer();
composites.push({
input: resizedSource,
top: Math.max(0, pos.y),
left: Math.max(0, pos.x),
});
} else if (layout.type === 'pattern') {
const tiles = calculatePatternTiles(
width,
height,
sourceData.width,
sourceData.height,
layout as PatternLayout
);
// Resize source once for all tiles
const tileSize = tiles[0];
if (tileSize) {
const resizedTile = await createSourceSharp()
.resize(tileSize.width, tileSize.height, { fit: 'fill' })
.png()
.toBuffer();
// Add each tile (limited opacity in Sharp requires different approach)
for (const tile of tiles) {
if (tile.x >= 0 && tile.y >= 0 && tile.x < width && tile.y < height) {
composites.push({
input: resizedTile,
top: tile.y,
left: tile.x,
blend: 'over',
});
}
}
}
}
// Apply composites
if (composites.length > 0) {
canvas = canvas.composite(composites);
}
// Export
let outputBuffer: Buffer;
if (format === 'jpeg') {
outputBuffer = await canvas.jpeg({ quality }).toBuffer();
} else {
outputBuffer = await canvas.png({ compressionLevel: 9 }).toBuffer();
}
// Convert to data URL
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
const dataUrl = `data:${mimeType};base64,${outputBuffer.toString('base64')}`;
return {
dataUrl,
width,
height,
format,
size: outputBuffer.length,
};
},
async preview(source: ImageSource, options: WallpaperOptions): Promise<string> {
// Generate at 1/4 resolution for preview
const { width, height } = resolveDimensions(options.device);
const previewWidth = Math.round(width / 4);
const previewHeight = Math.round(height / 4);
const previewOptions: WallpaperOptions = {
...options,
device: { width: previewWidth, height: previewHeight },
format: 'jpeg',
quality: 70,
};
// Scale layout parameters
previewOptions.layout = scaleLayout(options.layout, 0.25);
const result = await this.generate(source, previewOptions);
return result.dataUrl;
},
getSupportedDevices(): DevicePreset[] {
return ALL_DEVICE_PRESETS;
},
getDevicesByCategory(category: DeviceCategory): DevicePreset[] {
return getPresetsByCategory(category);
},
};
}
/**
* Scale layout parameters for preview
*/
function scaleLayout(layout: Layout, scaleFactor: number): Layout {
switch (layout.type) {
case 'center':
return {
...layout,
offset: layout.offset
? [layout.offset[0] * scaleFactor, layout.offset[1] * scaleFactor]
: undefined,
} as CenterLayout;
case 'corner':
return {
...layout,
padding: ((layout as CornerLayout).padding ?? 40) * scaleFactor,
} as CornerLayout;
case 'pattern':
return {
...layout,
gap: ((layout as PatternLayout).gap ?? 20) * scaleFactor,
} as PatternLayout;
default:
return layout;
}
}
// =============================================================================
// NODE.JS UTILITIES
// =============================================================================
/**
* Save wallpaper to file
*/
export async function saveWallpaperToFile(
result: WallpaperResult,
filePath: string
): Promise<void> {
const fs = await import('fs/promises');
const base64 = result.dataUrl.split(',')[1];
if (!base64) throw new Error('Invalid data URL');
const buffer = Buffer.from(base64, 'base64');
await fs.writeFile(filePath, buffer);
}

View file

@ -0,0 +1,453 @@
<script lang="ts">
import type {
WallpaperResult,
DevicePreset,
DeviceCategory,
Layout,
Background,
CornerPosition,
} from '../types.js';
import { createBrowserGenerator, downloadWallpaper } from '../renderers/browser.js';
import {
PHONE_PRESETS,
TABLET_PRESETS,
DESKTOP_PRESETS,
getRecommendedPresets,
} from '../presets/index.js';
import { GRADIENT_PRESETS } from '../types.js';
interface Props {
show: boolean;
imageDataUrl: string;
imageSize?: { width: number; height: number };
onClose: () => void;
onGenerate?: (result: WallpaperResult) => void;
}
let { show, imageDataUrl, imageSize, onClose, onGenerate }: Props = $props();
// State
let selectedCategory = $state<DeviceCategory>('phone');
let selectedDeviceId = $state<string>('iphone-15-pro-max');
let layoutType = $state<'center' | 'corner' | 'pattern'>('center');
let cornerPosition = $state<CornerPosition>('bottom-right');
let layoutScale = $state(0.6);
let backgroundType = $state<'solid' | 'gradient'>('gradient');
let solidColor = $state('#1a1a2e');
let selectedGradient = $state('dark');
let generating = $state(false);
let previewUrl = $state<string | null>(null);
let result = $state<WallpaperResult | null>(null);
// Computed
const devicesByCategory = $derived<Record<DeviceCategory, DevicePreset[]>>({
phone: PHONE_PRESETS,
tablet: TABLET_PRESETS,
desktop: DESKTOP_PRESETS,
});
const currentDevices = $derived(devicesByCategory[selectedCategory] || []);
const currentLayout = $derived<Layout>(() => {
if (layoutType === 'center') {
return { type: 'center', scale: layoutScale };
} else if (layoutType === 'corner') {
return { type: 'corner', position: cornerPosition, scale: layoutScale * 0.5, padding: 60 };
} else {
return { type: 'pattern', scale: layoutScale * 0.3, gap: 40, opacity: 0.15 };
}
});
const currentBackground = $derived<Background>(() => {
if (backgroundType === 'solid') {
return { type: 'solid', color: solidColor };
} else {
const preset = GRADIENT_PRESETS[selectedGradient] || GRADIENT_PRESETS['dark'];
return preset;
}
});
// Generator
const generator = createBrowserGenerator();
// Generate preview when settings change
$effect(() => {
if (show && imageDataUrl) {
generatePreview();
}
});
async function generatePreview() {
try {
const url = await generator.preview(
{ type: 'dataUrl', data: imageDataUrl },
{
device: selectedDeviceId,
layout: currentLayout(),
background: currentBackground(),
}
);
previewUrl = url;
} catch (e) {
console.error('Preview generation failed:', e);
}
}
async function generateWallpaper() {
generating = true;
result = null;
try {
result = await generator.generate(
{ type: 'dataUrl', data: imageDataUrl },
{
device: selectedDeviceId,
layout: currentLayout(),
background: currentBackground(),
format: 'png',
}
);
onGenerate?.(result);
} catch (e) {
console.error('Wallpaper generation failed:', e);
} finally {
generating = false;
}
}
function handleDownload() {
if (result) {
const device = currentDevices.find((d) => d.id === selectedDeviceId);
const deviceName = device?.name.replace(/\s+/g, '-').toLowerCase() || 'custom';
downloadWallpaper(result, `wallpaper-${deviceName}.png`);
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Reset state when modal closes
$effect(() => {
if (!show) {
result = null;
previewUrl = null;
}
});
</script>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
onclick={handleBackdropClick}
>
<div
class="bg-card rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
>
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Wallpaper erstellen</h3>
<p class="text-sm text-muted-foreground">Erstelle ein Wallpaper fur dein Gerat</p>
</div>
</div>
<button
onclick={onClose}
class="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Schliessen"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="grid md:grid-cols-2 gap-6">
<!-- Preview -->
<div class="space-y-4">
<h4 class="font-medium text-sm">Vorschau</h4>
<div
class="aspect-[9/16] bg-muted rounded-lg overflow-hidden flex items-center justify-center border"
>
{#if previewUrl}
<img
src={previewUrl}
alt="Wallpaper Preview"
class="w-full h-full object-contain"
/>
{:else}
<div class="text-muted-foreground text-sm">Wird generiert...</div>
{/if}
</div>
</div>
<!-- Settings -->
<div class="space-y-6">
<!-- Device Selection -->
<div class="space-y-3">
<h4 class="font-medium text-sm">Gerat</h4>
<!-- Category Tabs -->
<div class="flex gap-1 p-1 bg-muted rounded-lg">
{#each ['phone', 'tablet', 'desktop'] as category}
<button
onclick={() => {
selectedCategory = category as DeviceCategory;
selectedDeviceId = devicesByCategory[category as DeviceCategory][0]?.id || '';
}}
class="flex-1 px-3 py-1.5 text-sm rounded-md transition-colors {selectedCategory ===
category
? 'bg-card shadow-sm font-medium'
: 'hover:bg-card/50'}"
>
{category === 'phone' ? 'Handy' : category === 'tablet' ? 'Tablet' : 'Desktop'}
</button>
{/each}
</div>
<!-- Device Dropdown -->
<select
bind:value={selectedDeviceId}
class="w-full px-3 py-2 bg-muted border rounded-lg text-sm"
>
{#each currentDevices as device}
<option value={device.id}>
{device.name} ({device.width}x{device.height})
</option>
{/each}
</select>
</div>
<!-- Layout -->
<div class="space-y-3">
<h4 class="font-medium text-sm">Layout</h4>
<div class="flex gap-2">
{#each ['center', 'corner', 'pattern'] as type}
<button
onclick={() => (layoutType = type as 'center' | 'corner' | 'pattern')}
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {layoutType ===
type
? 'bg-primary text-primary-foreground border-primary'
: 'hover:bg-muted'}"
>
{type === 'center' ? 'Zentriert' : type === 'corner' ? 'Ecke' : 'Muster'}
</button>
{/each}
</div>
{#if layoutType === 'corner'}
<div class="grid grid-cols-2 gap-2">
{#each ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as pos}
<button
onclick={() => (cornerPosition = pos as CornerPosition)}
class="px-3 py-2 text-xs border rounded-lg transition-colors {cornerPosition ===
pos
? 'bg-primary/10 border-primary'
: 'hover:bg-muted'}"
>
{pos === 'top-left'
? 'Oben Links'
: pos === 'top-right'
? 'Oben Rechts'
: pos === 'bottom-left'
? 'Unten Links'
: 'Unten Rechts'}
</button>
{/each}
</div>
{/if}
<!-- Scale Slider -->
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Grosse</span>
<span>{Math.round(layoutScale * 100)}%</span>
</div>
<input
type="range"
min="0.1"
max="1.5"
step="0.05"
bind:value={layoutScale}
class="w-full"
/>
</div>
</div>
<!-- Background -->
<div class="space-y-3">
<h4 class="font-medium text-sm">Hintergrund</h4>
<div class="flex gap-2">
<button
onclick={() => (backgroundType = 'gradient')}
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {backgroundType ===
'gradient'
? 'bg-primary text-primary-foreground border-primary'
: 'hover:bg-muted'}"
>
Verlauf
</button>
<button
onclick={() => (backgroundType = 'solid')}
class="flex-1 px-3 py-2 text-sm border rounded-lg transition-colors {backgroundType ===
'solid'
? 'bg-primary text-primary-foreground border-primary'
: 'hover:bg-muted'}"
>
Einfarbig
</button>
</div>
{#if backgroundType === 'gradient'}
<div class="grid grid-cols-5 gap-2">
{#each Object.entries(GRADIENT_PRESETS) as [key, preset]}
<button
onclick={() => (selectedGradient = key)}
class="aspect-square rounded-lg border-2 transition-all {selectedGradient ===
key
? 'border-primary scale-105'
: 'border-transparent hover:border-muted-foreground/30'}"
style="background: linear-gradient({preset.angle ??
180}deg, {preset.colors.join(', ')})"
aria-label={key}
></button>
{/each}
</div>
{:else}
<div class="flex items-center gap-3">
<input
type="color"
bind:value={solidColor}
class="w-12 h-10 rounded cursor-pointer"
/>
<input
type="text"
bind:value={solidColor}
class="flex-1 px-3 py-2 bg-muted border rounded-lg text-sm font-mono"
pattern="^#[0-9A-Fa-f]{6}$"
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Result Info -->
{#if result}
<div
class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="h-5 w-5 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="font-medium text-green-800 dark:text-green-200"
>Wallpaper erstellt!</span
>
</div>
<span class="text-sm text-green-700 dark:text-green-300">
{result.width}x{result.height} · {formatSize(result.size)}
</span>
</div>
</div>
{/if}
<!-- Actions -->
<div class="mt-6 flex gap-3">
<button
onclick={onClose}
class="flex-1 px-4 py-2.5 border rounded-lg hover:bg-muted transition-colors"
>
Abbrechen
</button>
{#if result}
<button
onclick={handleDownload}
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 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>
Herunterladen
</button>
{:else}
<button
onclick={generateWallpaper}
disabled={generating}
class="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{#if generating}
<div
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
></div>
Generiert...
{: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 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Wallpaper erstellen
{/if}
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,5 @@
/**
* Svelte Exports
*/
export { default as WallpaperModal } from './WallpaperModal.svelte';

View file

@ -0,0 +1,282 @@
/**
* Wallpaper Generator Types
*
* Type definitions for the wallpaper generator package.
*/
// =============================================================================
// IMAGE SOURCE TYPES
// =============================================================================
/** Data URL image source (e.g., from QR code or canvas export) */
export interface DataUrlSource {
type: 'dataUrl';
data: string;
}
/** HTML Canvas element source */
export interface CanvasSource {
type: 'canvas';
canvas: HTMLCanvasElement;
}
/** Raw pixel buffer source */
export interface BufferSource {
type: 'buffer';
buffer: Uint8Array;
width: number;
height: number;
/** Number of channels (3 for RGB, 4 for RGBA) */
channels?: 3 | 4;
}
/** All supported image source types */
export type ImageSource = DataUrlSource | CanvasSource | BufferSource;
// =============================================================================
// DEVICE PRESETS
// =============================================================================
/** Device category */
export type DeviceCategory = 'phone' | 'tablet' | 'desktop';
/** Device preset definition */
export interface DevicePreset {
/** Unique identifier (e.g., 'iphone-15-pro-max') */
id: string;
/** Display name (e.g., 'iPhone 15 Pro Max') */
name: string;
/** Device category */
category: DeviceCategory;
/** Width in pixels */
width: number;
/** Height in pixels */
height: number;
/** Pixel density ratio (optional) */
pixelRatio?: number;
}
/** Custom device dimensions */
export interface CustomDevice {
width: number;
height: number;
}
/** Device option - either a preset ID or custom dimensions */
export type DeviceOption = string | CustomDevice;
// =============================================================================
// LAYOUT OPTIONS
// =============================================================================
/** Corner position for corner layout */
export type CornerPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
/** Center layout - image centered on wallpaper */
export interface CenterLayout {
type: 'center';
/** Scale factor (default: 1.0) */
scale?: number;
/** Offset from center in pixels [x, y] */
offset?: [number, number];
}
/** Corner layout - image in one corner */
export interface CornerLayout {
type: 'corner';
/** Which corner to place the image */
position: CornerPosition;
/** Scale factor (default: 1.0) */
scale?: number;
/** Padding from edges in pixels (default: 40) */
padding?: number;
}
/** Pattern layout - tiled image */
export interface PatternLayout {
type: 'pattern';
/** Scale factor for each tile (default: 0.5) */
scale?: number;
/** Gap between tiles in pixels (default: 20) */
gap?: number;
/** Opacity of pattern (default: 0.15) */
opacity?: number;
}
/** All layout types */
export type Layout = CenterLayout | CornerLayout | PatternLayout;
// =============================================================================
// BACKGROUND OPTIONS
// =============================================================================
/** Solid color background */
export interface SolidBackground {
type: 'solid';
/** Color in hex format (e.g., '#1a1a2e') */
color: string;
}
/** Gradient background */
export interface GradientBackground {
type: 'gradient';
/** Array of color stops in hex format */
colors: string[];
/** Gradient angle in degrees (default: 180 = top to bottom) */
angle?: number;
}
/** All background types */
export type Background = SolidBackground | GradientBackground;
// =============================================================================
// WALLPAPER OPTIONS
// =============================================================================
/** Output image format */
export type OutputFormat = 'png' | 'jpeg';
/** Complete wallpaper generation options */
export interface WallpaperOptions {
/** Target device (preset ID or custom dimensions) */
device: DeviceOption;
/** Image layout configuration */
layout: Layout;
/** Background configuration */
background: Background;
/** Output format (default: 'png') */
format?: OutputFormat;
/** JPEG quality 0-100 (only for jpeg format, default: 90) */
quality?: number;
}
// =============================================================================
// RESULT TYPES
// =============================================================================
/** Wallpaper generation result */
export interface WallpaperResult {
/** Data URL of the generated wallpaper */
dataUrl: string;
/** Width of the generated image */
width: number;
/** Height of the generated image */
height: number;
/** Output format */
format: OutputFormat;
/** Size in bytes (approximate) */
size: number;
}
// =============================================================================
// GENERATOR INTERFACE
// =============================================================================
/** Wallpaper generator interface */
export interface WallpaperGenerator {
/**
* Generate a wallpaper from an image source
* @param source - The source image (data URL, canvas, or buffer)
* @param options - Wallpaper generation options
* @returns Promise with the generated wallpaper result
*/
generate(source: ImageSource, options: WallpaperOptions): Promise<WallpaperResult>;
/**
* Generate a preview (smaller, faster) of the wallpaper
* @param source - The source image
* @param options - Wallpaper generation options
* @returns Promise with data URL of preview image
*/
preview(source: ImageSource, options: WallpaperOptions): Promise<string>;
/**
* Get list of all supported device presets
* @returns Array of device presets
*/
getSupportedDevices(): DevicePreset[];
/**
* Get device presets by category
* @param category - Device category to filter by
* @returns Array of device presets in that category
*/
getDevicesByCategory(category: DeviceCategory): DevicePreset[];
}
// =============================================================================
// SVELTE COMPONENT PROPS
// =============================================================================
/** Props for WallpaperModal Svelte component */
export interface WallpaperModalProps {
/** Whether the modal is visible */
show: boolean;
/** Source image as data URL */
imageDataUrl: string;
/** Optional source image dimensions (for better scaling) */
imageSize?: { width: number; height: number };
/** Callback when modal is closed */
onClose: () => void;
/** Optional callback when wallpaper is generated */
onGenerate?: (result: WallpaperResult) => void;
}
// =============================================================================
// PRESET CONSTANTS
// =============================================================================
/** Default layout options */
export const DEFAULT_CENTER_LAYOUT: CenterLayout = {
type: 'center',
scale: 1.0,
};
export const DEFAULT_CORNER_LAYOUT: CornerLayout = {
type: 'corner',
position: 'bottom-right',
scale: 0.3,
padding: 40,
};
export const DEFAULT_PATTERN_LAYOUT: PatternLayout = {
type: 'pattern',
scale: 0.5,
gap: 20,
opacity: 0.15,
};
/** Default background */
export const DEFAULT_BACKGROUND: SolidBackground = {
type: 'solid',
color: '#1a1a2e',
};
/** Default gradient backgrounds */
export const GRADIENT_PRESETS: Record<string, GradientBackground> = {
dark: {
type: 'gradient',
colors: ['#1a1a2e', '#16213e', '#0f3460'],
angle: 180,
},
sunset: {
type: 'gradient',
colors: ['#ff6b6b', '#feca57', '#48dbfb'],
angle: 135,
},
ocean: {
type: 'gradient',
colors: ['#0f0c29', '#302b63', '#24243e'],
angle: 180,
},
forest: {
type: 'gradient',
colors: ['#134e5e', '#71b280'],
angle: 180,
},
purple: {
type: 'gradient',
colors: ['#667eea', '#764ba2'],
angle: 135,
},
};

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1463
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff