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);
- /{link.value.shortCode} - → {link.value.originalUrl} -
+ {#if link.value} ++ /{link.value.shortCode} + → {link.value.originalUrl} +
+ {/if}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') : '-'} +
Noch keine Daten für diesen Zeitraum
+ {/if} +Keine Daten
+ {/if} +Keine Daten
+ {/if} +Keine Daten
+ {/if} +