feat(uload): add QR codes, link editing, UTM params, analytics, i18n, PWA

Features added to links page:
- QR code generation with modal and download
- Edit modal for title, URL, UTM parameters
- Collapsible UTM parameter fields (source, medium, campaign)
- Click count links to analytics page
- Confirm dialog before delete

Analytics dashboard improvements:
- Country breakdown with progress bars
- Device breakdown with percentages
- Time period switcher (7/30/90 days)
- Tooltip on timeline bars
- Back navigation

Other:
- Public profile page /u/[username] via Hono endpoint
- i18n setup with svelte-i18n (DE/EN locale files)
- PWA support via @vite-pwa/sveltekit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 15:03:04 +02:00
parent ecd7770eba
commit cfe3fc422e
14 changed files with 942 additions and 525 deletions

View file

@ -11,6 +11,7 @@ import { createRedirectRoutes } from './routes/redirect';
import { createAnalyticsRoutes } from './routes/analytics';
import { createStripeRoutes } from './routes/stripe';
import { createEmailRoutes } from './routes/email';
import { createPublicRoutes } from './routes/public';
const config = loadConfig();
const db = getDb(config.databaseUrl);
@ -26,8 +27,9 @@ app.use('*', cors({ origin: config.cors.origins, credentials: true }));
// Health (no auth)
app.route('/health', healthRoutes);
// Redirect (no auth — public)
// Public routes (no auth)
app.route('/r', createRedirectRoutes(redirectService));
app.route('/public', createPublicRoutes(db));
// Analytics API (auth required)
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));

View file

@ -0,0 +1,35 @@
import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm';
import { links, users } from '@manacore/uload-database';
import type { Database } from '../db/connection';
export function createPublicRoutes(db: Database) {
return new Hono().get('/u/:username', async (c) => {
const username = c.req.param('username');
const [user] = await db
.select({ id: users.id, username: users.username, name: users.name, bio: users.bio })
.from(users)
.where(and(eq(users.username, username), eq(users.publicProfile, true)))
.limit(1);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const userLinks = await db
.select({
shortCode: links.shortCode,
title: links.title,
description: links.description,
clickCount: links.clickCount,
createdAt: links.createdAt,
})
.from(links)
.where(and(eq(links.userId, user.id), eq(links.isActive, true)))
.orderBy(desc(links.createdAt))
.limit(50);
return c.json({ user, links: userLinks });
});
}

View file

@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/vite": "^4.1.11",
"@vite-pwa/sveltekit": "^1.1.0",
"@types/node": "^22.10.7",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.1",
@ -33,6 +34,7 @@
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-pwa": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-stores": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",

View file

@ -0,0 +1,11 @@
import { register, init, getLocaleFromNavigator } from 'svelte-i18n';
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
export function initI18n() {
init({
fallbackLocale: 'de',
initialLocale: getLocaleFromNavigator()?.startsWith('de') ? 'de' : 'en',
});
}

View file

@ -0,0 +1,95 @@
{
"nav": {
"links": "Links",
"tags": "Tags",
"analytics": "Analytics",
"settings": "Einstellungen"
},
"links": {
"title": "Links",
"newLink": "Neuer Link",
"hide": "Ausblenden",
"url": "URL",
"urlPlaceholder": "https://example.com/long-url-here",
"titleLabel": "Titel (optional)",
"titlePlaceholder": "Mein Link",
"customCode": "Custom Code (optional)",
"customCodePlaceholder": "mein-link",
"utmParams": "UTM-Parameter",
"create": "Link erstellen",
"search": "Links durchsuchen...",
"all": "Alle",
"active": "Aktiv",
"inactive": "Inaktiv",
"allFolders": "Alle Ordner",
"noLinks": "Noch keine Links",
"noLinksDesc": "Erstelle deinen ersten gekürzten Link!",
"copied": "Link kopiert!",
"created": "Link erstellt",
"updated": "Link aktualisiert",
"deleted": "Link gelöscht",
"edit": "Bearbeiten",
"editTitle": "Link bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"deleteConfirm": "wirklich löschen?",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
"copyLink": "Link kopieren",
"qrCode": "QR-Code",
"qrDownload": "QR herunterladen",
"clicks": "clicks"
},
"tags": {
"title": "Tags",
"newTag": "Neuer Tag",
"name": "Name",
"namePlaceholder": "z.B. Social Media",
"color": "Farbe",
"create": "Erstellen",
"noTags": "Noch keine Tags",
"noTagsDesc": "Erstelle Tags um deine Links zu organisieren.",
"created": "Tag erstellt",
"updated": "Tag aktualisiert",
"deleted": "Tag gelöscht",
"linksCount": "Links"
},
"analytics": {
"title": "Analytics",
"clicks": "Clicks",
"unique": "Unique",
"status": "Status",
"created": "Erstellt",
"clicksOverTime": "Clicks über Zeit",
"devices": "Geräte",
"referrers": "Referrer",
"countries": "Länder",
"noData": "Keine Daten",
"noDataPeriod": "Noch keine Daten für diesen Zeitraum",
"authRequired": "Analytics nur für angemeldete Nutzer",
"localClicks": "Lokale Click-Counts",
"unknown": "Unbekannt",
"direct": "Direkt"
},
"settings": {
"title": "Einstellungen",
"account": "Account",
"email": "E-Mail",
"name": "Name",
"data": "Daten",
"clearData": "Lokale Daten löschen",
"clearConfirm": "Alle lokalen Daten löschen? Dies kann nicht rückgängig gemacht werden.",
"cleared": "Lokale Daten gelöscht",
"logout": "Abmelden",
"guestHint": "Du bist als Gast unterwegs.",
"loginToSync": "Anmelden um Daten zu synchronisieren."
},
"common": {
"back": "Zurück",
"login": "Anmelden",
"source": "Source",
"medium": "Medium",
"campaign": "Campaign"
}
}

View file

@ -0,0 +1,95 @@
{
"nav": {
"links": "Links",
"tags": "Tags",
"analytics": "Analytics",
"settings": "Settings"
},
"links": {
"title": "Links",
"newLink": "New Link",
"hide": "Hide",
"url": "URL",
"urlPlaceholder": "https://example.com/long-url-here",
"titleLabel": "Title (optional)",
"titlePlaceholder": "My Link",
"customCode": "Custom Code (optional)",
"customCodePlaceholder": "my-link",
"utmParams": "UTM Parameters",
"create": "Create Link",
"search": "Search links...",
"all": "All",
"active": "Active",
"inactive": "Inactive",
"allFolders": "All Folders",
"noLinks": "No links yet",
"noLinksDesc": "Create your first shortened link!",
"copied": "Link copied!",
"created": "Link created",
"updated": "Link updated",
"deleted": "Link deleted",
"edit": "Edit",
"editTitle": "Edit Link",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirm": "really delete?",
"activate": "Activate",
"deactivate": "Deactivate",
"copyLink": "Copy link",
"qrCode": "QR Code",
"qrDownload": "Download QR",
"clicks": "clicks"
},
"tags": {
"title": "Tags",
"newTag": "New Tag",
"name": "Name",
"namePlaceholder": "e.g. Social Media",
"color": "Color",
"create": "Create",
"noTags": "No tags yet",
"noTagsDesc": "Create tags to organize your links.",
"created": "Tag created",
"updated": "Tag updated",
"deleted": "Tag deleted",
"linksCount": "Links"
},
"analytics": {
"title": "Analytics",
"clicks": "Clicks",
"unique": "Unique",
"status": "Status",
"created": "Created",
"clicksOverTime": "Clicks over time",
"devices": "Devices",
"referrers": "Referrers",
"countries": "Countries",
"noData": "No data",
"noDataPeriod": "No data for this period yet",
"authRequired": "Analytics only for logged-in users",
"localClicks": "Local click counts",
"unknown": "Unknown",
"direct": "Direct"
},
"settings": {
"title": "Settings",
"account": "Account",
"email": "Email",
"name": "Name",
"data": "Data",
"clearData": "Clear local data",
"clearConfirm": "Clear all local data? This cannot be undone.",
"cleared": "Local data cleared",
"logout": "Sign out",
"guestHint": "You are in guest mode.",
"loginToSync": "Sign in to sync your data."
},
"common": {
"back": "Back",
"login": "Sign in",
"source": "Source",
"medium": "Medium",
"campaign": "Campaign"
}
}

View file

@ -14,7 +14,9 @@
let timeline = $state<{ date: string; count: number }[]>([]);
let devices = $state<{ deviceType: string; count: number }[]>([]);
let referrers = $state<{ referer: string; count: number }[]>([]);
let countries = $state<{ country: string; count: number }[]>([]);
let loading = $state(true);
let days = $state(30);
async function fetchAnalytics() {
if (!authStore.isAuthenticated) {
@ -26,40 +28,64 @@
const token = await authStore.getValidToken();
const headers = { Authorization: `Bearer ${token}` };
const [statsRes, timelineRes, devicesRes, referrersRes] = await Promise.all([
const [statsRes, timelineRes, devicesRes, referrersRes, countriesRes] = await Promise.all([
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}`, { headers }),
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}/timeline?days=30`, { headers }),
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}/timeline?days=${days}`, { headers }),
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}/devices`, { headers }),
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}/referrers`, { headers }),
fetch(`${ULOAD_SERVER}/api/v1/analytics/${linkId}/countries`, { headers }),
]);
if (statsRes.ok) stats = await statsRes.json();
if (timelineRes.ok) timeline = await timelineRes.json();
if (devicesRes.ok) devices = await devicesRes.json();
if (referrersRes.ok) referrers = await referrersRes.json();
if (countriesRes.ok) countries = await countriesRes.json();
} catch {
// Analytics not available (server offline or guest mode)
// Analytics not available
}
loading = false;
}
function changeDays(d: number) {
days = d;
loading = true;
fetchAnalytics();
}
let maxTimelineCount = $derived(Math.max(...(timeline.map((t) => t.count) || [0]), 1));
let totalDevices = $derived(devices.reduce((sum, d) => sum + d.count, 0) || 1);
let totalCountries = $derived(countries.reduce((sum, c) => sum + c.count, 0) || 1);
onMount(fetchAnalytics);
</script>
<div class="mx-auto max-w-4xl">
{#if link.value}
<div class="mb-6">
<!-- Header -->
<div class="mb-6 flex items-center gap-4">
<a
href="/my/links"
class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Zurück"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
<p class="mt-1 text-sm opacity-60">
<span class="font-mono text-indigo-600">/{link.value.shortCode}</span>
{link.value.originalUrl}
</p>
{#if link.value}
<p class="mt-1 text-sm opacity-60">
<span class="font-mono text-indigo-600">/{link.value.shortCode}</span>
<span class="truncate">{link.value.originalUrl}</span>
</p>
{/if}
</div>
{/if}
</div>
{#if loading}
<div class="space-y-4">
{#each Array(3) as _}
{#each Array(4) as _}
<div class="h-32 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800"></div>
{/each}
</div>
@ -68,7 +94,7 @@
class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600"
>
<p class="text-lg font-medium opacity-60">Analytics nur für angemeldete Nutzer</p>
<p class="mt-1 text-sm opacity-40">Lokale Click-Counts: {link.value?.clickCount ?? 0}</p>
<p class="mt-2 text-sm opacity-40">Lokale Click-Counts: {link.value?.clickCount ?? 0}</p>
<a
href="/login"
class="mt-4 inline-block rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
@ -77,81 +103,153 @@
</div>
{:else}
<!-- Stats Overview -->
<div class="mb-6 grid gap-4 sm:grid-cols-3">
<div class="mb-6 grid gap-4 sm:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm opacity-60">Total Clicks</p>
<p class="text-3xl font-bold">{stats?.totalClicks ?? link.value?.clickCount ?? 0}</p>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Clicks</p>
<p class="mt-1 text-3xl font-bold">{stats?.totalClicks ?? link.value?.clickCount ?? 0}</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm opacity-60">Unique Visitors</p>
<p class="text-3xl font-bold">{stats?.uniqueVisitors ?? '-'}</p>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Unique</p>
<p class="mt-1 text-3xl font-bold">{stats?.uniqueVisitors ?? '-'}</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm opacity-60">Status</p>
<p class="text-3xl font-bold">{link.value?.isActive ? '🟢 Aktiv' : '🔴 Inaktiv'}</p>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Status</p>
<p class="mt-1 text-3xl font-bold">{link.value?.isActive ? '🟢' : '🔴'}</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Erstellt</p>
<p class="mt-1 text-lg font-bold">
{link.value?.createdAt ? new Date(link.value.createdAt).toLocaleDateString('de') : '-'}
</p>
</div>
</div>
<!-- Timeline -->
{#if timeline.length > 0}
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Clicks (30 Tage)</h2>
<div class="flex h-40 items-end gap-1">
{#each timeline as day}
{@const maxCount = Math.max(...timeline.map((t) => t.count), 1)}
<div class="flex flex-1 flex-col items-center gap-1">
<div
class="w-full rounded-t bg-indigo-500"
style="height: {(day.count / maxCount) * 100}%"
title="{day.date}: {day.count} clicks"
></div>
</div>
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Clicks über Zeit</h2>
<div class="flex gap-1">
{#each [7, 30, 90] as d}
<button
onclick={() => changeDays(d)}
class="rounded-md px-3 py-1 text-xs font-medium transition-colors {days === d
? 'bg-indigo-600 text-white'
: 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'}"
>
{d}T
</button>
{/each}
</div>
</div>
{/if}
{#if timeline.length > 0}
<div class="flex h-48 items-end gap-px">
{#each timeline as day}
<div class="group relative flex flex-1 flex-col items-center">
<div
class="w-full rounded-t bg-indigo-500 transition-colors hover:bg-indigo-400"
style="height: {Math.max((day.count / maxTimelineCount) * 100, 2)}%"
></div>
<div
class="pointer-events-none absolute -top-8 hidden rounded bg-gray-900 px-2 py-1 text-xs text-white group-hover:block"
>
{day.count}
</div>
</div>
{/each}
</div>
<div class="mt-1 flex justify-between text-xs opacity-40">
<span>{timeline[0]?.date}</span>
<span>{timeline[timeline.length - 1]?.date}</span>
</div>
{:else}
<p class="py-8 text-center text-sm opacity-40">Noch keine Daten für diesen Zeitraum</p>
{/if}
</div>
<!-- Devices & Referrers -->
<div class="grid gap-6 md:grid-cols-2">
{#if devices.length > 0}
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Geräte</h2>
<div class="space-y-2">
<!-- Breakdown Grid -->
<div class="grid gap-6 md:grid-cols-3">
<!-- Devices -->
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Geräte</h2>
{#if devices.length > 0}
<div class="space-y-3">
{#each devices as d}
<div class="flex items-center justify-between text-sm">
<span>{d.deviceType || 'Unbekannt'}</span>
<span class="font-medium">{d.count}</span>
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span>{d.deviceType || 'Unbekannt'}</span>
<span class="font-medium">{Math.round((d.count / totalDevices) * 100)}%</span>
</div>
<div class="h-2 rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-indigo-500"
style="width: {(d.count / totalDevices) * 100}%"
></div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{:else}
<p class="text-sm opacity-40">Keine Daten</p>
{/if}
</div>
{#if referrers.length > 0}
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Referrer</h2>
<!-- Referrers -->
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Referrer</h2>
{#if referrers.length > 0}
<div class="space-y-2">
{#each referrers as r}
{#each referrers.slice(0, 8) as r}
<div class="flex items-center justify-between text-sm">
<span class="truncate">{r.referer || 'Direkt'}</span>
<span class="font-medium">{r.count}</span>
<span class="max-w-[140px] truncate">{r.referer || 'Direkt'}</span>
<span class="font-medium tabular-nums">{r.count}</span>
</div>
{/each}
</div>
</div>
{/if}
{:else}
<p class="text-sm opacity-40">Keine Daten</p>
{/if}
</div>
<!-- Countries -->
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Länder</h2>
{#if countries.length > 0}
<div class="space-y-3">
{#each countries.slice(0, 8) as c}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span>{c.country || 'Unbekannt'}</span>
<span class="font-medium">{Math.round((c.count / totalCountries) * 100)}%</span>
</div>
<div class="h-2 rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-emerald-500"
style="width: {(c.count / totalCountries) * 100}%"
></div>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm opacity-40">Keine Daten</p>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -1,15 +1,12 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import {
linkCollection,
tagCollection,
folderCollection,
linkTagCollection,
} from '$lib/data/local-store';
import { linkCollection, tagCollection, folderCollection } from '$lib/data/local-store';
import type { LocalLink } from '$lib/data/local-store';
import { toast } from 'svelte-sonner';
// Live queries — auto-update when IndexedDB changes
const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
// Live queries
const links = useLiveQuery(() =>
linkCollection.getAll({}, { sortBy: 'order', sortDirection: 'asc' })
);
@ -18,14 +15,32 @@
folderCollection.getAll({}, { sortBy: 'order', sortDirection: 'asc' })
);
// State
// Filter state
let searchQuery = $state('');
let selectedStatus = $state<'all' | 'active' | 'inactive'>('all');
let selectedFolderId = $state<string | null>(null);
// Create form state
let showCreateForm = $state(false);
let newUrl = $state('');
let newTitle = $state('');
let newCustomCode = $state('');
let showUtm = $state(false);
let newUtmSource = $state('');
let newUtmMedium = $state('');
let newUtmCampaign = $state('');
// Edit modal state
let editingLink = $state<LocalLink | null>(null);
let editUrl = $state('');
let editTitle = $state('');
let editCustomCode = $state('');
let editUtmSource = $state('');
let editUtmMedium = $state('');
let editUtmCampaign = $state('');
// QR modal state
let qrLink = $state<LocalLink | null>(null);
// Filtered links
let filteredLinks = $derived.by(() => {
@ -54,9 +69,12 @@
return code;
}
function getShortUrl(code: string): string {
return `${window.location.origin}/${code}`;
}
async function createLink() {
if (!newUrl) return;
const shortCode = newCustomCode || generateShortCode();
await linkCollection.insert({
id: crypto.randomUUID(),
@ -68,12 +86,42 @@
clickCount: 0,
folderId: selectedFolderId,
order: links.value?.length ?? 0,
utmSource: newUtmSource || null,
utmMedium: newUtmMedium || null,
utmCampaign: newUtmCampaign || null,
});
toast.success(`Link erstellt: ${shortCode}`);
newUrl = '';
newTitle = '';
newCustomCode = '';
newUtmSource = '';
newUtmMedium = '';
newUtmCampaign = '';
showUtm = false;
}
function openEdit(link: LocalLink) {
editingLink = link;
editUrl = link.originalUrl;
editTitle = link.title ?? '';
editCustomCode = link.customCode ?? '';
editUtmSource = link.utmSource ?? '';
editUtmMedium = link.utmMedium ?? '';
editUtmCampaign = link.utmCampaign ?? '';
}
async function saveEdit() {
if (!editingLink || !editUrl) return;
await linkCollection.update(editingLink.id, {
originalUrl: editUrl,
title: editTitle || null,
customCode: editCustomCode || null,
utmSource: editUtmSource || null,
utmMedium: editUtmMedium || null,
utmCampaign: editUtmCampaign || null,
});
toast.success('Link aktualisiert');
editingLink = null;
}
async function toggleActive(link: LocalLink) {
@ -81,14 +129,28 @@
}
async function deleteLink(link: LocalLink) {
if (!confirm(`"${link.title || link.shortCode}" wirklich löschen?`)) return;
await linkCollection.delete(link.id);
toast.success('Link gelöscht');
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(`${window.location.origin}/${code}`);
navigator.clipboard.writeText(getShortUrl(code));
toast.success('Link kopiert!');
}
function downloadQr(code: string) {
const url = `${QR_API}/?size=400x400&data=${encodeURIComponent(getShortUrl(code))}`;
const a = document.createElement('a');
a.href = url;
a.download = `qr-${code}.png`;
a.click();
}
const inputClass =
'w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700';
const inputSmClass =
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700';
</script>
<div class="min-h-screen">
@ -122,7 +184,7 @@
type="url"
bind:value={newUrl}
placeholder="https://example.com/long-url-here"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700"
class={inputClass}
onkeydown={(e) => e.key === 'Enter' && createLink()}
/>
</div>
@ -133,7 +195,7 @@
type="text"
bind:value={newTitle}
placeholder="Mein Link"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700"
class={inputClass}
/>
</div>
<div>
@ -143,10 +205,72 @@
type="text"
bind:value={newCustomCode}
placeholder="mein-link"
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700"
class={inputClass}
/>
</div>
</div>
<!-- UTM Parameters (collapsible) -->
<button
onclick={() => (showUtm = !showUtm)}
class="mt-3 flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700"
>
<svg
class="h-4 w-4 transition-transform {showUtm ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
UTM-Parameter
</button>
{#if showUtm}
<div class="mt-3 grid gap-3 md:grid-cols-3">
<div>
<label for="utm-source" class="mb-1 block text-xs font-medium opacity-70"
>Source</label
>
<input
id="utm-source"
type="text"
bind:value={newUtmSource}
placeholder="newsletter"
class={inputSmClass}
/>
</div>
<div>
<label for="utm-medium" class="mb-1 block text-xs font-medium opacity-70"
>Medium</label
>
<input
id="utm-medium"
type="text"
bind:value={newUtmMedium}
placeholder="email"
class={inputSmClass}
/>
</div>
<div>
<label for="utm-campaign" class="mb-1 block text-xs font-medium opacity-70"
>Campaign</label
>
<input
id="utm-campaign"
type="text"
bind:value={newUtmCampaign}
placeholder="spring-2026"
class={inputSmClass}
/>
</div>
</div>
{/if}
<div class="mt-4 flex justify-end">
<button
onclick={createLink}
@ -165,21 +289,16 @@
type="text"
bind:value={searchQuery}
placeholder="Links durchsuchen..."
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700"
class={inputSmClass}
style="max-width: 240px"
/>
<select
bind:value={selectedStatus}
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700"
>
<select bind:value={selectedStatus} class={inputSmClass} style="max-width: 140px">
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
{#if folders.value && folders.value.length > 0}
<select
bind:value={selectedFolderId}
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700"
>
<select bind:value={selectedFolderId} class={inputSmClass} style="max-width: 160px">
<option value={null}>Alle Ordner</option>
{#each folders.value as folder}
<option value={folder.id}>{folder.name}</option>
@ -212,71 +331,124 @@
<div class="space-y-3">
{#each filteredLinks as link (link.id)}
<div
class="group flex items-center justify-between rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
class="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="inline-block h-2 w-2 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-gray-400'}"
></span>
<h3 class="truncate font-semibold">{link.title || link.shortCode}</h3>
<span
class="rounded bg-indigo-100 px-2 py-0.5 font-mono text-xs text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
/{link.shortCode}
</span>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-gray-400'}"
></span>
<h3 class="truncate font-semibold">{link.title || link.shortCode}</h3>
<span
class="shrink-0 rounded bg-indigo-100 px-2 py-0.5 font-mono text-xs text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
/{link.shortCode}
</span>
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<span
class="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300"
>UTM</span
>
{/if}
</div>
<p class="mt-1 truncate text-sm opacity-60">{link.originalUrl}</p>
</div>
<p class="mt-1 truncate text-sm opacity-60">{link.originalUrl}</p>
</div>
<div class="ml-4 flex items-center gap-2">
<span class="whitespace-nowrap text-sm font-medium opacity-60">
{link.clickCount} clicks
</span>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Link kopieren"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title={link.isActive ? 'Deaktivieren' : 'Aktivieren'}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title="Löschen"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<div class="ml-4 flex items-center gap-1">
<a
href="/my/analytics/{link.id}"
class="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm font-medium opacity-60 transition-all hover:bg-gray-100 hover:opacity-100 dark:hover:bg-gray-700"
title="Analytics"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
{link.clickCount}
</a>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Link kopieren"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
<button
onclick={() => (qrLink = link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="QR-Code"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
/>
</svg>
</button>
<button
onclick={() => openEdit(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Bearbeiten"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title={link.isActive ? 'Deaktivieren' : 'Aktivieren'}
>
<svg
class="h-4 w-4 {link.isActive ? 'text-green-500' : 'text-gray-400'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title="Löschen"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
{/each}
@ -284,3 +456,148 @@
{/if}
</div>
</div>
<!-- Edit Modal -->
{#if editingLink}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (editingLink = null)}
>
<div
class="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">Link bearbeiten</h3>
<button
onclick={() => (editingLink = null)}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label for="edit-url" class="mb-1 block text-sm font-medium">URL</label>
<input id="edit-url" type="url" bind:value={editUrl} class={inputClass} />
</div>
<div>
<label for="edit-title" class="mb-1 block text-sm font-medium">Titel</label>
<input id="edit-title" type="text" bind:value={editTitle} class={inputClass} />
</div>
<div>
<label for="edit-code" class="mb-1 block text-sm font-medium">Short Code</label>
<div class="flex items-center gap-2">
<span class="text-sm opacity-50">/{editingLink.shortCode}</span>
<span class="text-xs opacity-30">(nicht änderbar)</span>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<p class="mb-2 text-sm font-medium opacity-70">UTM-Parameter</p>
<div class="grid gap-3 md:grid-cols-3">
<input
type="text"
bind:value={editUtmSource}
placeholder="Source"
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmMedium}
placeholder="Medium"
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmCampaign}
placeholder="Campaign"
class={inputSmClass}
/>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={() => (editingLink = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={saveEdit}
disabled={!editUrl}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
{/if}
<!-- QR Code Modal -->
{#if qrLink}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (qrLink = null)}
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">QR-Code</h3>
<button
onclick={() => (qrLink = null)}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex flex-col items-center gap-4">
<div class="rounded-lg bg-white p-4">
<img
src="{QR_API}/?size=200x200&data={encodeURIComponent(getShortUrl(qrLink.shortCode))}"
alt="QR Code für {qrLink.shortCode}"
class="h-48 w-48"
/>
</div>
<p class="font-mono text-sm text-indigo-600">{getShortUrl(qrLink.shortCode)}</p>
<div class="flex w-full gap-2">
<button
onclick={() => {
copyShortUrl(qrLink!.shortCode);
}}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Link kopieren
</button>
<button
onclick={() => downloadQr(qrLink!.shortCode)}
class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
QR herunterladen
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -1,17 +1,22 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
import favicon from '$lib/assets/favicon.svg';
import { themeStore } from '$lib/theme.svelte';
import { onMount } from 'svelte';
import { isLoading as i18nLoading } from 'svelte-i18n';
import { Toaster } from 'svelte-sonner';
import { uloadStore } from '$lib/data/local-store';
import { authStore } from '$lib/stores/auth.svelte';
import { initI18n } from '$lib/i18n';
let { children } = $props();
let loading = $state(true);
let appReady = $derived(!loading && !$i18nLoading);
onMount(async () => {
initI18n();
themeStore.initialize();
await authStore.initialize();
await uloadStore.initialize();
@ -23,7 +28,7 @@
<link rel="icon" href={favicon} />
</svelte:head>
{#if loading}
{#if !appReady}
<div class="flex min-h-screen items-center justify-center">
<div
class="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-indigo-500 border-r-transparent"

View file

@ -0,0 +1,99 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
const ULOAD_SERVER = import.meta.env.PUBLIC_ULOAD_SERVER_URL || 'http://localhost:3070';
let username = $derived($page.params.username ?? '');
let user = $state<{ username: string; name: string | null; bio: string | null } | null>(null);
let userLinks = $state<
{
shortCode: string;
title: string | null;
description: string | null;
clickCount: number;
createdAt: string;
}[]
>([]);
let loading = $state(true);
let notFound = $state(false);
onMount(async () => {
try {
const res = await fetch(`${ULOAD_SERVER}/public/u/${username}`);
if (res.ok) {
const data = await res.json();
user = data.user;
userLinks = data.links;
} else {
notFound = true;
}
} catch {
notFound = true;
}
loading = false;
});
</script>
<div class="mx-auto max-w-2xl px-4 py-12">
{#if loading}
<div class="flex justify-center py-20">
<div
class="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-indigo-500 border-r-transparent"
></div>
</div>
{:else if notFound}
<div class="py-20 text-center">
<p class="text-6xl">🔗</p>
<h1 class="mt-4 text-2xl font-bold">Nutzer nicht gefunden</h1>
<p class="mt-2 text-sm opacity-50">
@{username} existiert nicht oder hat kein öffentliches Profil.
</p>
<a
href="/"
class="mt-6 inline-block rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>Zur Startseite</a
>
</div>
{:else if user}
<!-- Profile Header -->
<div class="mb-8 text-center">
<div
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-indigo-100 text-2xl font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-300"
>
{(user.name || user.username).charAt(0).toUpperCase()}
</div>
<h1 class="text-2xl font-bold">{user.name || user.username}</h1>
<p class="text-sm opacity-50">@{user.username}</p>
{#if user.bio}
<p class="mt-2 text-sm opacity-70">{user.bio}</p>
{/if}
</div>
<!-- Links -->
{#if userLinks.length === 0}
<p class="text-center text-sm opacity-40">Keine öffentlichen Links</p>
{:else}
<div class="space-y-3">
{#each userLinks as link}
<a
href="/{link.shortCode}"
class="block rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{link.title || link.shortCode}</h3>
{#if link.description}
<p class="mt-1 text-sm opacity-60">{link.description}</p>
{/if}
</div>
<div class="text-right text-sm opacity-50">
<p>{link.clickCount} clicks</p>
</div>
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -1,132 +0,0 @@
{
"name": "uLoad - URL Shortener & Link Management",
"short_name": "uLoad",
"description": "Professional URL shortener with analytics, QR codes, and link management",
"start_url": "/",
"display": "standalone",
"background_color": "#1e1b4b",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"scope": "/",
"categories": ["productivity", "utilities", "business"],
"lang": "en",
"dir": "ltr",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icons/icon-72x72.svg",
"sizes": "72x72",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-96x96.svg",
"sizes": "96x96",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-128x128.svg",
"sizes": "128x128",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-144x144.svg",
"sizes": "144x144",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-152x152.svg",
"sizes": "152x152",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-384x384.svg",
"sizes": "384x384",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Create Link",
"short_name": "New Link",
"description": "Create a new short link",
"url": "/my",
"icons": [
{
"src": "/icons/icon-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
}
]
},
{
"name": "Analytics",
"short_name": "Stats",
"description": "View link analytics",
"url": "/my/links",
"icons": [
{
"src": "/icons/icon-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
}
]
},
{
"name": "QR Codes",
"short_name": "QR",
"description": "Generate QR codes",
"url": "/my/links",
"icons": [
{
"src": "/icons/icon-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
}
]
}
],
"related_applications": [],
"prefer_related_applications": false,
"share_target": {
"action": "/my",
"method": "GET",
"params": {
"url": "url"
}
}
}

View file

@ -1,244 +0,0 @@
// uLoad Service Worker für PWA-Funktionalität
const CACHE_NAME = 'uload-v1';
const OFFLINE_URL = '/offline';
// Assets die gecacht werden sollen
const CACHE_ASSETS = ['/', '/my', '/offline', '/manifest.json'];
// Install Event - Cache initialisieren
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing');
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log('Service Worker: Caching assets');
return cache.addAll(CACHE_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activate Event - Alte Caches löschen
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating');
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('Service Worker: Deleting old cache', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch Event - Network-first mit Cache-Fallback
self.addEventListener('fetch', (event) => {
// Nur GET Requests handhaben
if (event.request.method !== 'GET') return;
// Spezielle Behandlung für Navigation Requests
if (event.request.mode === 'navigate') {
event.respondWith(handleNavigate(event.request));
return;
}
// API Requests - Network-first
if (event.request.url.includes('/api/')) {
event.respondWith(handleApiRequest(event.request));
return;
}
// Static Assets - Cache-first
if (isStaticAsset(event.request.url)) {
event.respondWith(handleStaticAsset(event.request));
return;
}
// Default: Network-first
event.respondWith(handleDefault(event.request));
});
// Navigation Requests handhaben
async function handleNavigate(request) {
try {
// Versuche Network Request
const response = await fetch(request);
// Bei Erfolg: Response cachen und zurückgeben
if (response.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Bei Netzwerk-Fehler: Cache prüfen
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Offline-Seite anzeigen
return caches.match(OFFLINE_URL);
}
}
// API Requests handhaben
async function handleApiRequest(request) {
try {
// API Requests immer vom Netzwerk
const response = await fetch(request);
// Bei Erfolg: kurzzeitig cachen (für Read-only Endpoints)
if (response.status === 200 && request.method === 'GET') {
const cache = await caches.open(CACHE_NAME);
// Clone erstellen da Response nur einmal gelesen werden kann
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Bei Offline: gecachte Response zurückgeben (falls vorhanden)
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Offline-Antwort für API
return new Response(JSON.stringify({ error: 'Offline - Please try again when online' }), {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' },
});
}
}
// Static Assets handhaben
async function handleStaticAsset(request) {
// Cache-first für statische Assets
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(request);
// Bei Erfolg: Cache aktualisieren
if (response.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Bei Offline und nicht im Cache: Default-Response
return new Response('Asset not available offline', { status: 404 });
}
}
// Default Request handhaben
async function handleDefault(request) {
try {
const response = await fetch(request);
return response;
} catch (error) {
const cachedResponse = await caches.match(request);
return cachedResponse || new Response('Offline', { status: 503 });
}
}
// Prüfen ob URL ein statisches Asset ist
function isStaticAsset(url) {
const staticExtensions = [
'.css',
'.js',
'.png',
'.jpg',
'.jpeg',
'.svg',
'.ico',
'.woff',
'.woff2',
];
return staticExtensions.some((ext) => url.includes(ext));
}
// Background Sync für Offline-Aktionen
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(handleBackgroundSync());
}
});
async function handleBackgroundSync() {
// Hier können Offline-Aktionen synchronisiert werden
console.log('Service Worker: Background sync triggered');
// Beispiel: Gespeicherte Links hochladen
try {
const pendingLinks = await getPendingLinks();
for (const link of pendingLinks) {
await syncLink(link);
}
} catch (error) {
console.error('Background sync failed:', error);
}
}
// Push Notifications (für zukünftige Features)
self.addEventListener('push', (event) => {
if (event.data) {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-72x72.png',
tag: 'uload-notification',
renotify: true,
actions: [
{
action: 'view',
title: 'View',
},
{
action: 'dismiss',
title: 'Dismiss',
},
],
};
event.waitUntil(self.registration.showNotification(data.title || 'uLoad', options));
}
});
// Notification Click Event
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view') {
event.waitUntil(clients.openWindow('/'));
}
});
// Helper Funktionen für IndexedDB (für Offline-Speicher)
function getPendingLinks() {
// Implementierung für IndexedDB
return Promise.resolve([]);
}
function syncLink(link) {
// Implementierung für Link-Synchronisation
return Promise.resolve();
}

View file

@ -1,7 +1,35 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
plugins: [
tailwindcss(),
sveltekit(),
SvelteKitPWA(
createPWAConfig({
name: 'uLoad - URL Shortener',
shortName: 'uLoad',
description: 'Kürze URLs, tracke Klicks und verwalte deine Links',
themeColor: '#6366f1',
devEnabled: false,
shortcuts: [
{
name: 'Neuer Link',
short_name: 'Neu',
description: 'Neuen Link erstellen',
url: '/my/links?action=new',
},
{
name: 'Analytics',
short_name: 'Analytics',
description: 'Link-Analytics anzeigen',
url: '/my/analytics',
},
],
})
),
],
});

6
pnpm-lock.yaml generated
View file

@ -4953,6 +4953,9 @@ importers:
'@manacore/shared-branding':
specifier: workspace:*
version: link:../../../../packages/shared-branding
'@manacore/shared-pwa':
specifier: workspace:*
version: link:../../../../packages/shared-pwa
'@manacore/shared-ui':
specifier: workspace:*
version: link:../../../../packages/shared-ui
@ -4981,6 +4984,9 @@ importers:
'@types/node':
specifier: ^22.10.7
version: 22.19.1
'@vite-pwa/sveltekit':
specifier: ^1.1.0
version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
eslint:
specifier: ^9.20.0
version: 9.39.1(jiti@2.6.1)