mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:41:09 +02:00
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:
parent
ecd7770eba
commit
cfe3fc422e
14 changed files with 942 additions and 525 deletions
|
|
@ -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));
|
||||
|
|
|
|||
35
apps/uload/apps/server/src/routes/public.ts
Normal file
35
apps/uload/apps/server/src/routes/public.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
11
apps/uload/apps/web/src/lib/i18n/index.ts
Normal file
11
apps/uload/apps/web/src/lib/i18n/index.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
95
apps/uload/apps/web/src/lib/i18n/locales/de.json
Normal file
95
apps/uload/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
apps/uload/apps/web/src/lib/i18n/locales/en.json
Normal file
95
apps/uload/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
99
apps/uload/apps/web/src/routes/u/[username]/+page.svelte
Normal file
99
apps/uload/apps/web/src/routes/u/[username]/+page.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
6
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue