From cfe3fc422ef0a38b43f42e91f452e6d6c1f78e40 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 15:03:04 +0200 Subject: [PATCH] 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) --- apps/uload/apps/server/src/index.ts | 4 +- apps/uload/apps/server/src/routes/public.ts | 35 ++ apps/uload/apps/web/package.json | 2 + apps/uload/apps/web/src/lib/i18n/index.ts | 11 + .../apps/web/src/lib/i18n/locales/de.json | 95 ++++ .../apps/web/src/lib/i18n/locales/en.json | 95 ++++ .../(app)/my/analytics/[id]/+page.svelte | 220 +++++--- .../src/routes/(app)/my/links/+page.svelte | 487 +++++++++++++++--- apps/uload/apps/web/src/routes/+layout.svelte | 7 +- .../web/src/routes/u/[username]/+page.svelte | 99 ++++ apps/uload/apps/web/static/manifest.json | 132 ----- apps/uload/apps/web/static/sw.js | 244 --------- apps/uload/apps/web/vite.config.ts | 30 +- pnpm-lock.yaml | 6 + 14 files changed, 942 insertions(+), 525 deletions(-) create mode 100644 apps/uload/apps/server/src/routes/public.ts create mode 100644 apps/uload/apps/web/src/lib/i18n/index.ts create mode 100644 apps/uload/apps/web/src/lib/i18n/locales/de.json create mode 100644 apps/uload/apps/web/src/lib/i18n/locales/en.json create mode 100644 apps/uload/apps/web/src/routes/u/[username]/+page.svelte delete mode 100644 apps/uload/apps/web/static/manifest.json delete mode 100644 apps/uload/apps/web/static/sw.js diff --git a/apps/uload/apps/server/src/index.ts b/apps/uload/apps/server/src/index.ts index 13e01b3b5..75f24f4e0 100644 --- a/apps/uload/apps/server/src/index.ts +++ b/apps/uload/apps/server/src/index.ts @@ -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)); diff --git a/apps/uload/apps/server/src/routes/public.ts b/apps/uload/apps/server/src/routes/public.ts new file mode 100644 index 000000000..ad07509cc --- /dev/null +++ b/apps/uload/apps/server/src/routes/public.ts @@ -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 }); + }); +} diff --git a/apps/uload/apps/web/package.json b/apps/uload/apps/web/package.json index e6d92055f..a3b785111 100644 --- a/apps/uload/apps/web/package.json +++ b/apps/uload/apps/web/package.json @@ -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:*", diff --git a/apps/uload/apps/web/src/lib/i18n/index.ts b/apps/uload/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..6d107a35a --- /dev/null +++ b/apps/uload/apps/web/src/lib/i18n/index.ts @@ -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', + }); +} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/de.json b/apps/uload/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..2b0ae9e44 --- /dev/null +++ b/apps/uload/apps/web/src/lib/i18n/locales/de.json @@ -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" + } +} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/en.json b/apps/uload/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..6c5f159ac --- /dev/null +++ b/apps/uload/apps/web/src/lib/i18n/locales/en.json @@ -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" + } +} diff --git a/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte index f97e18491..8362d89e1 100644 --- a/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte @@ -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);
- {#if link.value} -
+ +
+ + + + + +

Analytics

-

- /{link.value.shortCode} - → {link.value.originalUrl} -

+ {#if link.value} +

+ /{link.value.shortCode} + → {link.value.originalUrl} +

+ {/if}
- {/if} +
{#if loading}
- {#each Array(3) as _} + {#each Array(4) as _}
{/each}
@@ -68,7 +94,7 @@ class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600" >

Analytics nur für angemeldete Nutzer

-

Lokale Click-Counts: {link.value?.clickCount ?? 0}

+

Lokale Click-Counts: {link.value?.clickCount ?? 0}

{:else} -
+
-

Total Clicks

-

{stats?.totalClicks ?? link.value?.clickCount ?? 0}

+

Clicks

+

{stats?.totalClicks ?? link.value?.clickCount ?? 0}

-

Unique Visitors

-

{stats?.uniqueVisitors ?? '-'}

+

Unique

+

{stats?.uniqueVisitors ?? '-'}

-

Status

-

{link.value?.isActive ? '🟢 Aktiv' : '🔴 Inaktiv'}

+

Status

+

{link.value?.isActive ? '🟢' : '🔴'}

+
+
+

Erstellt

+

+ {link.value?.createdAt ? new Date(link.value.createdAt).toLocaleDateString('de') : '-'} +

- {#if timeline.length > 0} -
-

Clicks (30 Tage)

-
- {#each timeline as day} - {@const maxCount = Math.max(...timeline.map((t) => t.count), 1)} -
-
-
+
+
+

Clicks über Zeit

+
+ {#each [7, 30, 90] as d} + {/each}
- {/if} + {#if timeline.length > 0} +
+ {#each timeline as day} +
+
+ +
+ {/each} +
+
+ {timeline[0]?.date} + {timeline[timeline.length - 1]?.date} +
+ {:else} +

Noch keine Daten für diesen Zeitraum

+ {/if} +
- -
- {#if devices.length > 0} -
-

Geräte

-
+ +
+ +
+

Geräte

+ {#if devices.length > 0} +
{#each devices as d} -
- {d.deviceType || 'Unbekannt'} - {d.count} +
+
+ {d.deviceType || 'Unbekannt'} + {Math.round((d.count / totalDevices) * 100)}% +
+
+
+
{/each}
-
- {/if} + {:else} +

Keine Daten

+ {/if} +
- {#if referrers.length > 0} -
-

Referrer

+ +
+

Referrer

+ {#if referrers.length > 0}
- {#each referrers as r} + {#each referrers.slice(0, 8) as r}
- {r.referer || 'Direkt'} - {r.count} + {r.referer || 'Direkt'} + {r.count}
{/each}
-
- {/if} + {:else} +

Keine Daten

+ {/if} +
+ + +
+

Länder

+ {#if countries.length > 0} +
+ {#each countries.slice(0, 8) as c} +
+
+ {c.country || 'Unbekannt'} + {Math.round((c.count / totalCountries) * 100)}% +
+
+
+
+
+ {/each} +
+ {:else} +

Keine Daten

+ {/if} +
{/if}
diff --git a/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte index 5dd4e3cac..18e2645ba 100644 --- a/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte @@ -1,15 +1,12 @@
@@ -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()} />
@@ -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} />
@@ -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} />
+ + + + {#if showUtm} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
- - +
+ + + + + {link.clickCount} + + + + + + +
{/each} @@ -284,3 +456,148 @@ {/if}
+ + +{#if editingLink} +
(editingLink = null)} + > +
e.stopPropagation()} + > +
+

Link bearbeiten

+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ /{editingLink.shortCode} + (nicht änderbar) +
+
+ +
+

UTM-Parameter

+
+ + + +
+
+
+ +
+ + +
+
+
+{/if} + + +{#if qrLink} +
(qrLink = null)} + > +
e.stopPropagation()} + > +
+

QR-Code

+ +
+ +
+
+ QR Code für {qrLink.shortCode} +
+

{getShortUrl(qrLink.shortCode)}

+
+ + +
+
+
+
+{/if} diff --git a/apps/uload/apps/web/src/routes/+layout.svelte b/apps/uload/apps/web/src/routes/+layout.svelte index 98ef389f8..fd2ef118a 100644 --- a/apps/uload/apps/web/src/routes/+layout.svelte +++ b/apps/uload/apps/web/src/routes/+layout.svelte @@ -1,17 +1,22 @@ + +
+ {#if loading} +
+
+
+ {:else if notFound} +
+

🔗

+

Nutzer nicht gefunden

+

+ @{username} existiert nicht oder hat kein öffentliches Profil. +

+ Zur Startseite +
+ {:else if user} + +
+
+ {(user.name || user.username).charAt(0).toUpperCase()} +
+

{user.name || user.username}

+

@{user.username}

+ {#if user.bio} +

{user.bio}

+ {/if} +
+ + + {#if userLinks.length === 0} +

Keine öffentlichen Links

+ {:else} + + {/if} + {/if} +
diff --git a/apps/uload/apps/web/static/manifest.json b/apps/uload/apps/web/static/manifest.json deleted file mode 100644 index a8bfecbdc..000000000 --- a/apps/uload/apps/web/static/manifest.json +++ /dev/null @@ -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" - } - } -} diff --git a/apps/uload/apps/web/static/sw.js b/apps/uload/apps/web/static/sw.js deleted file mode 100644 index a5ef27fe8..000000000 --- a/apps/uload/apps/web/static/sw.js +++ /dev/null @@ -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(); -} diff --git a/apps/uload/apps/web/vite.config.ts b/apps/uload/apps/web/vite.config.ts index da1f9251e..21cd11325 100644 --- a/apps/uload/apps/web/vite.config.ts +++ b/apps/uload/apps/web/vite.config.ts @@ -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', + }, + ], + }) + ), + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd42ab021..1326591ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)