mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
98ca01f466
commit
8050da31ea
7 changed files with 506 additions and 0 deletions
31
packages/shared-uload/package.json
Normal file
31
packages/shared-uload/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
244
packages/shared-uload/src/ShareModal.svelte
Normal file
244
packages/shared-uload/src/ShareModal.svelte
Normal 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> · Sichtbar in uLoad
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
106
packages/shared-uload/src/create-link.ts
Normal file
106
packages/shared-uload/src/create-link.ts
Normal 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;
|
||||
}
|
||||
12
packages/shared-uload/src/index.ts
Normal file
12
packages/shared-uload/src/index.ts
Normal 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';
|
||||
66
packages/shared-uload/src/types.ts
Normal file
66
packages/shared-uload/src/types.ts
Normal 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',
|
||||
};
|
||||
29
packages/shared-uload/src/utils.ts
Normal file
29
packages/shared-uload/src/utils.ts
Normal 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();
|
||||
}
|
||||
18
packages/shared-uload/tsconfig.json
Normal file
18
packages/shared-uload/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue