feat(shared-uload): add shared package with ShareModal and cross-app link creation

Provides ShareModal component and initSharedUload/createShortLink utilities
for other apps to create uLoad short links with source tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 22:33:25 +02:00
parent 98ca01f466
commit 8050da31ea
7 changed files with 506 additions and 0 deletions

View file

@ -0,0 +1,31 @@
{
"name": "@manacore/shared-uload",
"version": "0.1.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-icons": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -0,0 +1,244 @@
<script lang="ts">
import { X, Copy, QrCode, Link, ArrowSquareOut } from '@manacore/shared-icons';
import type { CreatedLink, CreateShortLinkOptions } from './types';
import { createShortLink, isSharedUloadReady, getBaseUrl } from './create-link';
import { getQrCodeUrl, getShortUrl, downloadQrCode } from './utils';
interface Props {
visible: boolean;
onClose: () => void;
url: string;
title?: string;
source: string;
description?: string;
onCreated?: (link: CreatedLink) => void;
}
let { visible, onClose, url, title = '', source, description = '', onCreated }: Props = $props();
let customCode = $state('');
let useCustomCode = $state(false);
let createdLink = $state<CreatedLink | null>(null);
let creating = $state(false);
let error = $state('');
let copied = $state(false);
let showQr = $state(false);
function reset() {
customCode = '';
useCustomCode = false;
createdLink = null;
creating = false;
error = '';
copied = false;
showQr = false;
}
function handleClose() {
reset();
onClose();
}
async function handleCreate() {
if (!isSharedUloadReady()) {
error = 'uLoad ist nicht initialisiert';
return;
}
creating = true;
error = '';
try {
const options: CreateShortLinkOptions = {
url,
title: title || undefined,
source,
description: description || undefined,
customCode: useCustomCode && customCode ? customCode : undefined,
};
const link = await createShortLink(options);
createdLink = link;
onCreated?.(link);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen';
} finally {
creating = false;
}
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
handleClose();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
style="z-index: 9990;"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-md rounded-2xl border border-white/10 bg-gray-900 shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div class="flex items-center gap-2.5">
<Link size={18} class="text-indigo-400" />
<h3 class="text-base font-semibold text-white">Kurzlink erstellen</h3>
</div>
<button
onclick={handleClose}
class="rounded-lg p-1.5 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
aria-label="Schliessen"
>
<X size={18} />
</button>
</div>
<div class="p-5">
{#if !createdLink}
<!-- Create State -->
<div class="space-y-4">
<!-- URL Preview -->
<div class="rounded-lg bg-white/5 px-3 py-2.5">
<p class="text-xs text-gray-400 mb-1">Ziel-URL</p>
<p class="text-sm text-white truncate">{url}</p>
</div>
{#if title}
<div>
<label class="block text-xs text-gray-400 mb-1">Titel</label>
<p class="text-sm text-white">{title}</p>
</div>
{/if}
<!-- Custom Code Toggle -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={useCustomCode}
class="rounded border-gray-600 bg-gray-800 text-indigo-500 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-300">Eigenen Kurzcode verwenden</span>
</label>
{#if useCustomCode}
<div class="mt-2 flex items-center gap-2">
<span class="text-sm text-gray-500">ulo.ad/</span>
<input
type="text"
bind:value={customCode}
placeholder="mein-code"
class="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
/>
</div>
{/if}
</div>
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
<button
onclick={handleCreate}
disabled={creating}
class="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 transition-colors"
>
{creating ? 'Erstelle...' : 'Kurzlink erstellen'}
</button>
</div>
{:else}
<!-- Created State -->
<div class="space-y-4">
<div
class="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-center"
>
<p class="text-xs text-emerald-400 mb-1">Kurzlink erstellt</p>
<p class="font-mono text-lg font-semibold text-emerald-300">
{createdLink.shortUrl}
</p>
</div>
{#if showQr}
<div class="flex flex-col items-center gap-3">
<div class="rounded-xl bg-white p-3">
<img
src={getQrCodeUrl(createdLink.shortUrl, 200)}
alt="QR Code für {createdLink.shortCode}"
class="h-44 w-44"
/>
</div>
<button
onclick={() => downloadQrCode(createdLink!.shortCode, getBaseUrl())}
class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
>
QR herunterladen
</button>
</div>
{/if}
<!-- Actions -->
<div class="flex gap-2">
<button
onclick={() => copyToClipboard(createdLink!.shortUrl)}
class="flex flex-1 items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2.5 text-sm font-medium text-white hover:bg-white/5 transition-colors"
>
<Copy size={16} />
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
<button
onclick={() => (showQr = !showQr)}
class="flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2.5 text-sm font-medium text-white hover:bg-white/5 transition-colors {showQr
? 'bg-white/10'
: ''}"
>
<QrCode size={16} />
QR
</button>
<a
href="/my/links"
target="_blank"
class="flex items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2.5 text-sm font-medium text-white hover:bg-white/5 transition-colors"
title="In uLoad öffnen"
>
<ArrowSquareOut size={16} />
</a>
</div>
</div>
{/if}
</div>
<!-- Footer: Source badge -->
<div class="border-t border-white/10 px-5 py-3">
<p class="text-xs text-gray-500">
Erstellt via <span class="text-gray-400">{source}</span> &middot; Sichtbar in uLoad
</p>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,106 @@
import { createLocalStore, type LocalCollection } from '@manacore/local-store';
import type { UloadLink, CreateShortLinkOptions, CreatedLink } from './types';
import { generateShortCode, getShortUrl, getQrCodeUrl } from './utils';
let _linkCollection: LocalCollection<UloadLink> | null = null;
let _store: ReturnType<typeof createLocalStore> | null = null;
let _baseUrl: string | undefined;
let _initPromise: Promise<void> | null = null;
/**
* Initialize the shared uLoad utility.
*
* Option A: Pass an existing linkCollection (from inside uLoad's own app).
* Option B: Call with no collection it opens the uLoad IndexedDB directly (from any other app).
*/
export function initSharedUload(
linkCollectionOrOptions?: LocalCollection<UloadLink> | { baseUrl?: string },
options?: { baseUrl?: string }
): void {
if (linkCollectionOrOptions && 'insert' in linkCollectionOrOptions) {
// Option A: Existing collection
_linkCollection = linkCollectionOrOptions;
_baseUrl = options?.baseUrl;
} else {
// Option B: Self-initialize by opening the uLoad database
const opts = linkCollectionOrOptions as { baseUrl?: string } | undefined;
_baseUrl = opts?.baseUrl;
_store = createLocalStore({
appId: 'uload',
collections: [
{
name: 'links',
indexes: [
'shortCode',
'isActive',
'folderId',
'order',
'clickCount',
'source',
'[folderId+order]',
'[isActive+order]',
],
},
],
});
_initPromise = _store.initialize().then(() => {
_linkCollection = _store!.collection<UloadLink>('links');
});
}
}
async function ensureReady(): Promise<LocalCollection<UloadLink>> {
if (_initPromise) {
await _initPromise;
}
if (!_linkCollection) {
throw new Error(
'@manacore/shared-uload not initialized. Call initSharedUload() in your app layout.'
);
}
return _linkCollection;
}
/**
* Create a short link from any app.
* The link is inserted into the uLoad local-store and syncs automatically.
*/
export async function createShortLink(options: CreateShortLinkOptions): Promise<CreatedLink> {
const collection = await ensureReady();
const shortCode = options.customCode || generateShortCode();
const id = crypto.randomUUID();
const shortUrl = getShortUrl(shortCode, _baseUrl);
const qrCodeUrl = getQrCodeUrl(shortUrl);
await collection.insert({
id,
shortCode,
customCode: options.customCode || null,
originalUrl: options.url,
title: options.title || null,
description: options.description || null,
isActive: true,
clickCount: 0,
folderId: null,
order: 0,
source: options.source,
expiresAt: options.expiresAt || null,
qrCodeUrl,
} as UloadLink);
return { id, shortCode, shortUrl, qrCodeUrl };
}
/**
* Check if shared-uload has been initialized.
*/
export function isSharedUloadReady(): boolean {
return _linkCollection !== null || _initPromise !== null;
}
export function getBaseUrl(): string | undefined {
return _baseUrl;
}

View file

@ -0,0 +1,12 @@
// Types
export type { UloadLink, CreateShortLinkOptions, CreatedLink, AppSource } from './types';
export { APP_SOURCE_LABELS } from './types';
// Core API
export { initSharedUload, createShortLink, isSharedUloadReady } from './create-link';
// Utilities
export { generateShortCode, getQrCodeUrl, getShortUrl, downloadQrCode, QR_API } from './utils';
// Components
export { default as ShareModal } from './ShareModal.svelte';

View file

@ -0,0 +1,66 @@
import type { BaseRecord } from '@manacore/local-store';
export interface UloadLink extends BaseRecord {
shortCode: string;
customCode?: string | null;
originalUrl: string;
title?: string | null;
description?: string | null;
isActive: boolean;
password?: string | null;
maxClicks?: number | null;
expiresAt?: string | null;
clickCount: number;
qrCodeUrl?: string | null;
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
folderId?: string | null;
order: number;
source?: string | null;
}
export interface CreateShortLinkOptions {
url: string;
title?: string;
customCode?: string;
source: string;
tags?: string[];
expiresAt?: string;
description?: string;
}
export interface CreatedLink {
id: string;
shortCode: string;
shortUrl: string;
qrCodeUrl: string;
}
export type AppSource =
| 'calendar'
| 'contacts'
| 'todo'
| 'chat'
| 'storage'
| 'presi'
| 'mukke'
| 'cards'
| 'picture'
| 'uload'
| 'manacore'
| (string & {});
export const APP_SOURCE_LABELS: Record<string, string> = {
calendar: 'Kalender',
contacts: 'Kontakte',
todo: 'Todo',
chat: 'Chat',
storage: 'Storage',
presi: 'Presi',
mukke: 'Mukke',
cards: 'Cards',
picture: 'Picture',
uload: 'uLoad',
manacore: 'ManaCore',
};

View file

@ -0,0 +1,29 @@
export const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
export function generateShortCode(length = 6): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < length; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
export function getQrCodeUrl(shortUrl: string, size = 400): string {
return `${QR_API}/?size=${size}x${size}&data=${encodeURIComponent(shortUrl)}`;
}
export function getShortUrl(shortCode: string, baseUrl?: string): string {
const base =
baseUrl || (typeof window !== 'undefined' ? window.location.origin : 'https://ulo.ad');
return `${base}/${shortCode}`;
}
export function downloadQrCode(shortCode: string, baseUrl?: string): void {
const shortUrl = getShortUrl(shortCode, baseUrl);
const url = getQrCodeUrl(shortUrl, 400);
const a = document.createElement('a');
a.href = url;
a.download = `qr-${shortCode}.png`;
a.click();
}

View file

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