chore(mana): uload aus unified-App entfernen
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run

uLoad ist nach Code/uload/ als eigenständiger Hono+Bun-Server
migriert (siehe mana/docs/playbooks/ULOAD_GREENFIELD.md, υ-0..υ-7
durch). Live auf:
- uload.mana.how → :3108 (SvelteKit-Web, Standalone)
- uload-api.mana.how → :3107 (Hono-API, eigene Postgres-DB im
  `uload`-Schema)
- ulo.ad → :3107 (Short-Redirect-Domain)

Gelöscht / abgebaut:
- Module: apps/mana/.../modules/uload + Routen + Locales
- Top-Level: apps/uload/ (alter SvelteKit-Web + Hono-Server-Code)
- docker-compose.macmini.yml uload-server Service-Block (alter
  Container :3070 wurde durch Standalone-Stack auf :3107 ersetzt)
- mana-web env: PUBLIC_ULOAD_SERVER_URL / _CLIENT in compose +
  hooks.server.ts (env-Injection, window.__-Export, CSP-connectSrc),
  status/+page.server.ts Service-List
- prometheus uload-server scrape job + mana.how/uload probe
- shared-branding APP_BRANDING.uload + APP_ICONS.uload + MANA_APPS
  uload-Entry + UloadLogo
- spiral-db MANA_APP_INDEX.uload (=21)
- shared-types/spaces 5× 'uload' Modul-Einträge in den Space-Listen
- Registries: app-registry/apps.ts (Uload registerApp + DownloadSimple
  icon + Header), categories, help-content, module-registry,
  splitscreen, hooks.server APP_SUBDOMAINS, data/tools/init
- package.json dev:uload:* + deploy:landing:uload Scripts
- i18n: uload in apps/{de,en,es,fr,it}.json

Was BLEIBT:
- cloudflared `uload.mana.how` → :3108, `uload-api.mana.how` → :3107,
  `ulo.ad` → :3107 — Standalone-Routes
- docker-compose mana-auth CORS_ORIGINS uload.mana.how + ulo.ad —
  SSO für Standalone

Dexie v67:
- droppt links + uloadTags + uloadFolders + linkTags

mana-web svelte-check 0/0 (7256 files), snapshot test 10/10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-18 16:14:40 +02:00
parent 2b08e2f3a2
commit 0b44acdde1
85 changed files with 13 additions and 7302 deletions

View file

@ -15,8 +15,6 @@ import { setSecurityHeaders } from '@mana/shared-utils/security-headers';
* - Media mana-media (CAS / thumbnails)
* - LLM mana-llm (server-side LLM proxy)
* - Events mana-events (public RSVP flow)
* - Uload server standalone short-link redirect/click tracking
* - Memoro server standalone voice memo processing
* - Glitchtip DSN client-side error reporting
*
* Per-app HTTP backends (todo-api, calendar-api, contacts-api, chat-api,
@ -33,8 +31,6 @@ const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
const PUBLIC_SYNC_SERVER_URL_CLIENT =
process.env.PUBLIC_SYNC_SERVER_URL_CLIENT || process.env.PUBLIC_SYNC_SERVER_URL || '';
const PUBLIC_ULOAD_SERVER_URL_CLIENT =
process.env.PUBLIC_ULOAD_SERVER_URL_CLIENT || process.env.PUBLIC_ULOAD_SERVER_URL || '';
const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
@ -153,7 +149,6 @@ const APP_SUBDOMAINS = new Set([
'calc',
'inventory',
'times',
'uload',
'questions',
]);
@ -222,7 +217,6 @@ export const handle: Handle = async ({ event, resolve }) => {
window.__PUBLIC_MANA_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_AUTH_URL_CLIENT)};
window.__PUBLIC_AUTH_WEB_URL__ = ${JSON.stringify(PUBLIC_AUTH_WEB_URL_CLIENT)};
window.__PUBLIC_SYNC_SERVER_URL__ = ${JSON.stringify(PUBLIC_SYNC_SERVER_URL_CLIENT)};
window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CLIENT)};
window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIENT)};
@ -257,7 +251,6 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
connectSrc: [
PUBLIC_MANA_AUTH_URL_CLIENT,
PUBLIC_SYNC_SERVER_URL_CLIENT,
PUBLIC_ULOAD_SERVER_URL_CLIENT,
PUBLIC_MANA_MEDIA_URL_CLIENT,
PUBLIC_MANA_LLM_URL_CLIENT,
PUBLIC_MANA_EVENTS_URL_CLIENT,

View file

@ -32,7 +32,6 @@ import {
Binoculars,
ArrowsInCardinal,
Buildings,
DownloadSimple,
Calculator,
Lightning,
PencilRuler,
@ -100,7 +99,7 @@ import {
// dreams · firsts · lasts · habits · recipes
// Places & ev.: places · events
// Creative: picture · music · photos
// Tools: uload · calc · inventory ·
// Tools: calc · inventory ·
// storage · skilltree · questions
// Long-tail: quotes · automations · companion · wetter ·
// goals · website · spaces · augur ·
@ -678,17 +677,6 @@ registerApp({
},
});
registerApp({
id: 'uload',
name: 'uLoad',
color: '#0EA5E9',
icon: DownloadSimple,
views: {
list: { load: () => import('$lib/modules/uload/ListView.svelte') },
detail: { load: () => import('$lib/modules/uload/views/DetailView.svelte') },
},
});
registerApp({
id: 'calc',
name: 'Calc',

View file

@ -315,17 +315,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
],
tips: ['Definiere Kategorien nach Lebensbereichen für eine gute Übersicht'],
},
uload: {
description:
'Quick-Upload — Dateien schnell hochladen und teilbare Links erstellen. Ideal zum schnellen Teilen.',
features: [
'Drag & Drop Upload',
'Teilbare Download-Links generieren',
'Verschiedene Dateitypen unterstützt',
'Gespeichert auf deinem eigenen Server',
],
tips: ['Nutze uload für schnelles Teilen — drag die Datei rein, Link kopieren, fertig'],
},
calc: {
description:
'Taschenrechner mit Berechnungsverlauf. Ergebnisse bleiben gespeichert und sind jederzeit abrufbar.',

View file

@ -1614,6 +1614,18 @@ db.version(66)
});
});
// v67 — uLoad module retirement (2026-05-18).
// uLoad ist nach Code/uload/ als eigenständiger Hono+Bun-Server
// auf uload.mana.how + uload-api.mana.how migriert (eigene Postgres-
// DB im `uload`-Schema, JWKS-Auth). Tabellen werden hier komplett
// gedroppt.
db.version(67).stores({
links: null,
uloadTags: null,
uloadFolders: null,
linkTags: null,
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -218,7 +218,6 @@ describe('module-registry — snapshot', () => {
'entryTags',
],
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
habits: ['habits', 'habitLogs'],
@ -303,8 +302,6 @@ describe('module-registry — snapshot', () => {
timeCountdownTimers: 'countdownTimers',
timeWorldClocks: 'worldClocks',
qCollections: 'collections',
uloadTags: 'tags',
uloadFolders: 'folders',
guideCollections: 'collections',
socialEvents: 'events',
financeCategories: 'categories',

View file

@ -65,7 +65,6 @@ import { photosModuleConfig } from '$lib/modules/photos/module.config';
import { skilltreeModuleConfig } from '$lib/modules/skilltree/module.config';
import { timesModuleConfig } from '$lib/modules/times/module.config';
import { questionsModuleConfig } from '$lib/modules/questions/module.config';
import { uloadModuleConfig } from '$lib/modules/uload/module.config';
import { calcModuleConfig } from '$lib/modules/calc/module.config';
import { guidesModuleConfig } from '$lib/modules/guides/module.config';
import { habitsModuleConfig } from '$lib/modules/habits/module.config';
@ -119,7 +118,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
skilltreeModuleConfig,
timesModuleConfig,
questionsModuleConfig,
uloadModuleConfig,
calcModuleConfig,
guidesModuleConfig,
habitsModuleConfig,

View file

@ -19,7 +19,6 @@
"inventory": "Inventar",
"questions": "Recherche",
"skilltree": "Skills",
"uload": "uLoad",
"calc": "Rechner",
"period": "Periode",
"body": "Körper",

View file

@ -19,7 +19,6 @@
"inventory": "Inventory",
"questions": "Research",
"skilltree": "Skills",
"uload": "uLoad",
"calc": "Calculator",
"period": "Period",
"body": "Body",

View file

@ -19,7 +19,6 @@
"inventory": "Inventario",
"questions": "Investigación",
"skilltree": "Skills",
"uload": "uLoad",
"calc": "Calculadora",
"period": "Ciclo",
"body": "Cuerpo",

View file

@ -19,7 +19,6 @@
"inventory": "Inventaire",
"questions": "Recherche",
"skilltree": "Skills",
"uload": "uLoad",
"calc": "Calculatrice",
"period": "Règles",
"body": "Corps",

View file

@ -19,7 +19,6 @@
"inventory": "Inventario",
"questions": "Ricerca",
"skilltree": "Skills",
"uload": "uLoad",
"calc": "Calcolatrice",
"period": "Ciclo",
"body": "Corpo",

View file

@ -1,286 +0,0 @@
{
"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"
},
"list_view": {
"empty_title": "Keine Links",
"add_placeholder": "URL einfügen...",
"err_invalid_url": "Ungültige URL",
"header_links": "{count} Links",
"header_clicks": "{count} Klicks",
"header_folders": "{count} Ordner",
"add_button": "Neuer Link"
},
"detail_view": {
"not_found": "Link nicht gefunden",
"confirm_delete": "Link wirklich löschen?",
"deleted_toast": "Link gelöscht",
"placeholder_title": "Titel...",
"label_url": "URL",
"label_short_code": "Kurzcode",
"label_short_code_legacy": "Short Code",
"placeholder_short_code": "custom-code",
"label_active": "Aktiv",
"yes": "Ja",
"no": "Nein",
"label_clicks": "Klicks",
"label_expires_at": "Ablaufdatum",
"section_description": "Beschreibung",
"placeholder_description": "Beschreibung hinzufügen...",
"meta_created": "Erstellt: {date}",
"meta_updated": "Bearbeitet: {date}"
},
"page": {
"title": "uLoad - Mana",
"all_links": "Alle Links",
"counts": "{links} Links · {folders} Ordner",
"counts_no_folders": "{links} Links",
"hide_form": "- Ausblenden",
"show_form": "+ Neuer Link",
"err_invalid_url_input": "Bitte eine gültige URL eingeben (mit https://)",
"err_invalid_custom_code": "Custom Code darf nur Buchstaben, Zahlen, - und _ enthalten",
"err_short_code_taken": "Short Code \"{code}\" ist bereits vergeben",
"err_max_clicks": "Max Klicks muss mindestens 1 sein",
"err_expires_past": "Ablaufdatum muss in der Zukunft liegen",
"toast_created": "Link erstellt: {code}",
"toast_updated": "Link aktualisiert",
"confirm_delete": "\"{name}\" wirklich löschen?",
"toast_deleted": "Link gelöscht",
"toast_copied": "Link kopiert!",
"label_title": "Titel (optional)",
"placeholder_title": "Mein Link",
"label_custom_code": "Custom Code (optional)",
"placeholder_custom_code": "mein-link",
"section_advanced": "Erweitert",
"label_expires": "Ablaufdatum",
"label_password": "Passwort",
"placeholder_optional": "Optional",
"label_max_clicks": "Max Klicks",
"placeholder_unlimited": "Unbegrenzt",
"section_utm": "UTM-Parameter",
"label_source": "Source",
"placeholder_source": "newsletter",
"label_medium": "Medium",
"placeholder_medium": "email",
"label_campaign": "Campaign",
"placeholder_campaign": "spring-2026",
"action_create": "Link erstellen",
"placeholder_search": "Links durchsuchen...",
"option_all": "Alle",
"option_active": "Aktiv",
"option_inactive": "Inaktiv",
"option_all_folders": "Alle Ordner",
"empty_title": "Noch keine Links",
"empty_desc": "Erstelle deinen ersten gekürzten Link!",
"badge_utm": "UTM",
"badge_password": "Passwort",
"badge_expires": "Ablauf",
"badge_expires_title": "Läuft ab: {date}",
"action_analytics_title": "Analytics",
"action_copy_title": "Link kopieren",
"action_qr_title": "QR-Code",
"action_edit_title": "Bearbeiten",
"action_activate_title": "Aktivieren",
"action_deactivate_title": "Deaktivieren",
"action_delete_title": "Löschen",
"modal_edit_title": "Link bearbeiten",
"label_url_modal": "URL",
"label_title_modal": "Titel",
"label_short_code_modal": "Short Code",
"short_code_locked": "(nicht änderbar)",
"action_cancel": "Abbrechen",
"action_save": "Speichern",
"modal_qr_title": "QR-Code",
"qr_alt": "QR Code für {code}",
"action_copy_link": "Link kopieren",
"action_download_qr": "QR herunterladen"
},
"links_route": {
"title": "Alle Links - uLoad - Mana",
"heading": "Alle Links",
"action_back_title": "Zurück",
"action_select_done": "Fertig",
"action_select_start": "Auswählen",
"placeholder_search": "Links durchsuchen...",
"option_all": "Alle",
"option_active": "Aktiv",
"option_inactive": "Inaktiv",
"option_all_folders": "Alle Ordner",
"selected_count": "{count} ausgewählt",
"action_bulk_toggle": "Aktivieren/Deaktivieren",
"action_bulk_delete": "Löschen",
"confirm_bulk_delete": "{count} Link(s) löschen?",
"toast_bulk_deleted": "{count} Links gelöscht",
"toast_bulk_updated": "{count} Links aktualisiert",
"empty_title": "Keine Links gefunden",
"empty_filtered": "Versuche andere Filtereinstellungen.",
"empty_root": "Erstelle Links auf der uLoad-Hauptseite.",
"action_copy_title": "Link kopieren",
"action_activate_title": "Aktivieren",
"action_deactivate_title": "Deaktivieren",
"action_delete_title": "Löschen",
"action_analytics_title": "Analytics",
"toast_copied": "Link kopiert!",
"confirm_delete_single": "\"{name}\" wirklich löschen?",
"toast_deleted_single": "Link gelöscht"
},
"analytics_route": {
"title": "Analytics - uLoad - Mana",
"page_title": "Link",
"action_back_title": "Zurück",
"heading": "Analytics",
"not_found": "Link nicht gefunden",
"stat_clicks": "Clicks",
"stat_unique": "Unique",
"stat_status": "Status",
"status_active": "Aktiv",
"status_inactive": "Inaktiv",
"stat_created": "Erstellt",
"section_details": "Link Details",
"label_target_url": "Ziel-URL",
"label_title": "Titel",
"label_utm_params": "UTM-Parameter",
"utm_source": "Source:",
"utm_medium": "Medium:",
"utm_campaign": "Campaign:",
"label_expires_at": "Läuft ab",
"label_max_clicks": "Max Klicks",
"max_clicks_value": "{used} / {max}",
"label_password_protected": "Passwortgeschützt",
"yes": "Ja",
"section_timeline": "Clicks über Zeit",
"days_unit": "{days}T",
"hint_no_server": "Detaillierte Analytics sind verfügbar, wenn der uLoad-Server verbunden ist.",
"hint_local_count": "Lokaler Click-Count: {count}",
"empty_period": "Noch keine Daten für diesen Zeitraum",
"section_devices": "Geräte",
"unknown": "Unbekannt",
"empty_no_data": "Keine Daten",
"section_referrers": "Referrer",
"direct": "Direkt",
"section_countries": "Länder"
},
"settings_route": {
"title": "uLoad-Einstellungen — Mana",
"heading": "uLoad-Einstellungen",
"subtitle": "Datenübersicht · Export · Gefahrenzone",
"section_data": "Daten",
"stat_links": "Links",
"stat_tags": "Tags",
"stat_folders": "Ordner",
"section_export": "Daten exportieren",
"export_hint": "Alle Links, Tags und Ordner als JSON-Datei herunterladen.",
"action_export": "JSON exportieren",
"toast_exported": "Export heruntergeladen",
"section_danger": "Gefahrenzone",
"danger_hint": "Löscht alle lokalen uLoad-Daten (Links, Tags, Ordner). Synchronisierte Daten auf dem Server bleiben erhalten.",
"action_clear": "Alle Daten löschen",
"confirm_clear": "Alle lokalen uLoad-Daten löschen? Dies kann nicht rückgängig gemacht werden.",
"toast_cleared": "Alle uLoad-Daten gelöscht"
},
"tags_route": {
"heading": "Tags",
"hide_form": "Ausblenden",
"show_form": "+ Neuer Tag",
"label_name": "Name",
"placeholder_name": "z.B. Social Media",
"label_color": "Farbe",
"action_create": "Erstellen",
"empty_title": "Noch keine Tags",
"empty_desc": "Erstelle Tags um deine Links zu organisieren.",
"links_count": "{count} Links",
"toast_created": "Tag \"{name}\" erstellt",
"toast_deleted": "Tag \"{name}\" gelöscht",
"toast_updated": "Tag aktualisiert"
}
}

View file

@ -1,286 +0,0 @@
{
"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"
},
"list_view": {
"empty_title": "No links",
"add_placeholder": "Paste URL...",
"err_invalid_url": "Invalid URL",
"header_links": "{count} links",
"header_clicks": "{count} clicks",
"header_folders": "{count} folders",
"add_button": "New link"
},
"detail_view": {
"not_found": "Link not found",
"confirm_delete": "Really delete link?",
"deleted_toast": "Link deleted",
"placeholder_title": "Title...",
"label_url": "URL",
"label_short_code": "Short code",
"label_short_code_legacy": "Short Code",
"placeholder_short_code": "custom-code",
"label_active": "Active",
"yes": "Yes",
"no": "No",
"label_clicks": "Clicks",
"label_expires_at": "Expires at",
"section_description": "Description",
"placeholder_description": "Add description...",
"meta_created": "Created: {date}",
"meta_updated": "Edited: {date}"
},
"page": {
"title": "uLoad - Mana",
"all_links": "All links",
"counts": "{links} links · {folders} folders",
"counts_no_folders": "{links} links",
"hide_form": "- Hide",
"show_form": "+ New link",
"err_invalid_url_input": "Please enter a valid URL (with https://)",
"err_invalid_custom_code": "Custom code may only contain letters, numbers, - and _",
"err_short_code_taken": "Short code \"{code}\" is already taken",
"err_max_clicks": "Max clicks must be at least 1",
"err_expires_past": "Expiry date must be in the future",
"toast_created": "Link created: {code}",
"toast_updated": "Link updated",
"confirm_delete": "Really delete \"{name}\"?",
"toast_deleted": "Link deleted",
"toast_copied": "Link copied!",
"label_title": "Title (optional)",
"placeholder_title": "My link",
"label_custom_code": "Custom code (optional)",
"placeholder_custom_code": "my-link",
"section_advanced": "Advanced",
"label_expires": "Expires at",
"label_password": "Password",
"placeholder_optional": "Optional",
"label_max_clicks": "Max clicks",
"placeholder_unlimited": "Unlimited",
"section_utm": "UTM parameters",
"label_source": "Source",
"placeholder_source": "newsletter",
"label_medium": "Medium",
"placeholder_medium": "email",
"label_campaign": "Campaign",
"placeholder_campaign": "spring-2026",
"action_create": "Create link",
"placeholder_search": "Search links...",
"option_all": "All",
"option_active": "Active",
"option_inactive": "Inactive",
"option_all_folders": "All folders",
"empty_title": "No links yet",
"empty_desc": "Create your first shortened link!",
"badge_utm": "UTM",
"badge_password": "Password",
"badge_expires": "Expires",
"badge_expires_title": "Expires: {date}",
"action_analytics_title": "Analytics",
"action_copy_title": "Copy link",
"action_qr_title": "QR code",
"action_edit_title": "Edit",
"action_activate_title": "Activate",
"action_deactivate_title": "Deactivate",
"action_delete_title": "Delete",
"modal_edit_title": "Edit link",
"label_url_modal": "URL",
"label_title_modal": "Title",
"label_short_code_modal": "Short code",
"short_code_locked": "(not changeable)",
"action_cancel": "Cancel",
"action_save": "Save",
"modal_qr_title": "QR code",
"qr_alt": "QR code for {code}",
"action_copy_link": "Copy link",
"action_download_qr": "Download QR"
},
"links_route": {
"title": "All links - uLoad - Mana",
"heading": "All links",
"action_back_title": "Back",
"action_select_done": "Done",
"action_select_start": "Select",
"placeholder_search": "Search links...",
"option_all": "All",
"option_active": "Active",
"option_inactive": "Inactive",
"option_all_folders": "All folders",
"selected_count": "{count} selected",
"action_bulk_toggle": "Activate/Deactivate",
"action_bulk_delete": "Delete",
"confirm_bulk_delete": "Delete {count} link(s)?",
"toast_bulk_deleted": "{count} links deleted",
"toast_bulk_updated": "{count} links updated",
"empty_title": "No links found",
"empty_filtered": "Try different filter settings.",
"empty_root": "Create links on the uLoad main page.",
"action_copy_title": "Copy link",
"action_activate_title": "Activate",
"action_deactivate_title": "Deactivate",
"action_delete_title": "Delete",
"action_analytics_title": "Analytics",
"toast_copied": "Link copied!",
"confirm_delete_single": "Really delete \"{name}\"?",
"toast_deleted_single": "Link deleted"
},
"analytics_route": {
"title": "Analytics - uLoad - Mana",
"page_title": "Link",
"action_back_title": "Back",
"heading": "Analytics",
"not_found": "Link not found",
"stat_clicks": "Clicks",
"stat_unique": "Unique",
"stat_status": "Status",
"status_active": "Active",
"status_inactive": "Inactive",
"stat_created": "Created",
"section_details": "Link details",
"label_target_url": "Target URL",
"label_title": "Title",
"label_utm_params": "UTM parameters",
"utm_source": "Source:",
"utm_medium": "Medium:",
"utm_campaign": "Campaign:",
"label_expires_at": "Expires at",
"label_max_clicks": "Max clicks",
"max_clicks_value": "{used} / {max}",
"label_password_protected": "Password protected",
"yes": "Yes",
"section_timeline": "Clicks over time",
"days_unit": "{days}d",
"hint_no_server": "Detailed analytics are available when the uLoad server is connected.",
"hint_local_count": "Local click count: {count}",
"empty_period": "No data for this period yet",
"section_devices": "Devices",
"unknown": "Unknown",
"empty_no_data": "No data",
"section_referrers": "Referrers",
"direct": "Direct",
"section_countries": "Countries"
},
"settings_route": {
"title": "uLoad settings — Mana",
"heading": "uLoad settings",
"subtitle": "Data overview · Export · Danger zone",
"section_data": "Data",
"stat_links": "Links",
"stat_tags": "Tags",
"stat_folders": "Folders",
"section_export": "Export data",
"export_hint": "Download all links, tags and folders as a JSON file.",
"action_export": "Export JSON",
"toast_exported": "Export downloaded",
"section_danger": "Danger zone",
"danger_hint": "Clears all local uLoad data (links, tags, folders). Data synced to the server stays.",
"action_clear": "Clear all data",
"confirm_clear": "Clear all local uLoad data? This cannot be undone.",
"toast_cleared": "All uLoad data cleared"
},
"tags_route": {
"heading": "Tags",
"hide_form": "Hide",
"show_form": "+ New tag",
"label_name": "Name",
"placeholder_name": "e.g. Social Media",
"label_color": "Color",
"action_create": "Create",
"empty_title": "No tags yet",
"empty_desc": "Create tags to organize your links.",
"links_count": "{count} links",
"toast_created": "Tag \"{name}\" created",
"toast_deleted": "Tag \"{name}\" deleted",
"toast_updated": "Tag updated"
}
}

View file

@ -1,286 +0,0 @@
{
"nav": {
"links": "Enlaces",
"tags": "Etiquetas",
"analytics": "Analiticas",
"settings": "Ajustes"
},
"links": {
"title": "Enlaces",
"newLink": "Nuevo enlace",
"hide": "Ocultar",
"url": "URL",
"urlPlaceholder": "https://example.com/url-larga",
"titleLabel": "Titulo (opcional)",
"titlePlaceholder": "Mi enlace",
"customCode": "Codigo personalizado (opcional)",
"customCodePlaceholder": "mi-enlace",
"utmParams": "Parametros UTM",
"create": "Crear enlace",
"search": "Buscar enlaces...",
"all": "Todos",
"active": "Activos",
"inactive": "Inactivos",
"allFolders": "Todas las carpetas",
"noLinks": "Sin enlaces aun",
"noLinksDesc": "Crea tu primer enlace acortado!",
"copied": "Enlace copiado!",
"created": "Enlace creado",
"updated": "Enlace actualizado",
"deleted": "Enlace eliminado",
"edit": "Editar",
"editTitle": "Editar enlace",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"deleteConfirm": "eliminar?",
"activate": "Activar",
"deactivate": "Desactivar",
"copyLink": "Copiar enlace",
"qrCode": "Codigo QR",
"qrDownload": "Descargar QR",
"clicks": "clics"
},
"tags": {
"title": "Etiquetas",
"newTag": "Nueva etiqueta",
"name": "Nombre",
"namePlaceholder": "ej. Redes sociales",
"color": "Color",
"create": "Crear",
"noTags": "Sin etiquetas",
"noTagsDesc": "Crea etiquetas para organizar tus enlaces.",
"created": "Etiqueta creada",
"updated": "Etiqueta actualizada",
"deleted": "Etiqueta eliminada",
"linksCount": "Enlaces"
},
"analytics": {
"title": "Analiticas",
"clicks": "Clics",
"unique": "Unicos",
"status": "Estado",
"created": "Creado",
"clicksOverTime": "Clics en el tiempo",
"devices": "Dispositivos",
"referrers": "Referentes",
"countries": "Paises",
"noData": "Sin datos",
"noDataPeriod": "Sin datos para este periodo",
"authRequired": "Analiticas solo para usuarios registrados",
"localClicks": "Clics locales",
"unknown": "Desconocido",
"direct": "Directo"
},
"settings": {
"title": "Ajustes",
"account": "Cuenta",
"email": "Email",
"name": "Nombre",
"data": "Datos",
"clearData": "Borrar datos locales",
"clearConfirm": "Borrar todos los datos locales?",
"cleared": "Datos locales borrados",
"logout": "Cerrar sesion",
"guestHint": "Estas en modo invitado.",
"loginToSync": "Inicia sesion para sincronizar."
},
"common": {
"back": "Atras",
"login": "Iniciar sesion",
"source": "Fuente",
"medium": "Medio",
"campaign": "Campana"
},
"list_view": {
"empty_title": "Sin enlaces",
"add_placeholder": "Pega URL...",
"err_invalid_url": "URL no válida",
"header_links": "{count} enlaces",
"header_clicks": "{count} clics",
"header_folders": "{count} carpetas",
"add_button": "Nuevo enlace"
},
"detail_view": {
"not_found": "Enlace no encontrado",
"confirm_delete": "¿Eliminar realmente el enlace?",
"deleted_toast": "Enlace eliminado",
"placeholder_title": "Título...",
"label_url": "URL",
"label_short_code": "Código corto",
"label_short_code_legacy": "Short Code",
"placeholder_short_code": "codigo-personal",
"label_active": "Activo",
"yes": "Sí",
"no": "No",
"label_clicks": "Clics",
"label_expires_at": "Caduca",
"section_description": "Descripción",
"placeholder_description": "Añadir descripción...",
"meta_created": "Creado: {date}",
"meta_updated": "Editado: {date}"
},
"page": {
"title": "uLoad - Mana",
"all_links": "Todos los enlaces",
"counts": "{links} enlaces · {folders} carpetas",
"counts_no_folders": "{links} enlaces",
"hide_form": "- Ocultar",
"show_form": "+ Nuevo enlace",
"err_invalid_url_input": "Introduce una URL válida (con https://)",
"err_invalid_custom_code": "El código personal solo puede tener letras, números, - y _",
"err_short_code_taken": "El código \"{code}\" ya está en uso",
"err_max_clicks": "Max clics debe ser al menos 1",
"err_expires_past": "La fecha de caducidad debe estar en el futuro",
"toast_created": "Enlace creado: {code}",
"toast_updated": "Enlace actualizado",
"confirm_delete": "¿Eliminar realmente \"{name}\"?",
"toast_deleted": "Enlace eliminado",
"toast_copied": "Enlace copiado!",
"label_title": "Título (opcional)",
"placeholder_title": "Mi enlace",
"label_custom_code": "Código personal (opcional)",
"placeholder_custom_code": "mi-enlace",
"section_advanced": "Avanzado",
"label_expires": "Fecha de caducidad",
"label_password": "Contraseña",
"placeholder_optional": "Opcional",
"label_max_clicks": "Max clics",
"placeholder_unlimited": "Ilimitado",
"section_utm": "Parámetros UTM",
"label_source": "Source",
"placeholder_source": "newsletter",
"label_medium": "Medium",
"placeholder_medium": "email",
"label_campaign": "Campaign",
"placeholder_campaign": "spring-2026",
"action_create": "Crear enlace",
"placeholder_search": "Buscar enlaces...",
"option_all": "Todos",
"option_active": "Activos",
"option_inactive": "Inactivos",
"option_all_folders": "Todas las carpetas",
"empty_title": "Sin enlaces",
"empty_desc": "¡Crea tu primer enlace acortado!",
"badge_utm": "UTM",
"badge_password": "Contraseña",
"badge_expires": "Caducidad",
"badge_expires_title": "Caduca: {date}",
"action_analytics_title": "Analíticas",
"action_copy_title": "Copiar enlace",
"action_qr_title": "Código QR",
"action_edit_title": "Editar",
"action_activate_title": "Activar",
"action_deactivate_title": "Desactivar",
"action_delete_title": "Eliminar",
"modal_edit_title": "Editar enlace",
"label_url_modal": "URL",
"label_title_modal": "Título",
"label_short_code_modal": "Código corto",
"short_code_locked": "(no modificable)",
"action_cancel": "Cancelar",
"action_save": "Guardar",
"modal_qr_title": "Código QR",
"qr_alt": "Código QR para {code}",
"action_copy_link": "Copiar enlace",
"action_download_qr": "Descargar QR"
},
"links_route": {
"title": "Todos los enlaces - uLoad - Mana",
"heading": "Todos los enlaces",
"action_back_title": "Atrás",
"action_select_done": "Listo",
"action_select_start": "Seleccionar",
"placeholder_search": "Buscar enlaces...",
"option_all": "Todos",
"option_active": "Activos",
"option_inactive": "Inactivos",
"option_all_folders": "Todas las carpetas",
"selected_count": "{count} seleccionados",
"action_bulk_toggle": "Activar/Desactivar",
"action_bulk_delete": "Eliminar",
"confirm_bulk_delete": "¿Eliminar {count} enlace(s)?",
"toast_bulk_deleted": "{count} enlaces eliminados",
"toast_bulk_updated": "{count} enlaces actualizados",
"empty_title": "No se encontraron enlaces",
"empty_filtered": "Prueba otros filtros.",
"empty_root": "Crea enlaces en la página principal de uLoad.",
"action_copy_title": "Copiar enlace",
"action_activate_title": "Activar",
"action_deactivate_title": "Desactivar",
"action_delete_title": "Eliminar",
"action_analytics_title": "Analíticas",
"toast_copied": "Enlace copiado!",
"confirm_delete_single": "¿Eliminar realmente \"{name}\"?",
"toast_deleted_single": "Enlace eliminado"
},
"analytics_route": {
"title": "Analíticas - uLoad - Mana",
"page_title": "Enlace",
"action_back_title": "Atrás",
"heading": "Analíticas",
"not_found": "Enlace no encontrado",
"stat_clicks": "Clics",
"stat_unique": "Únicos",
"stat_status": "Estado",
"status_active": "Activo",
"status_inactive": "Inactivo",
"stat_created": "Creado",
"section_details": "Detalles del enlace",
"label_target_url": "URL destino",
"label_title": "Título",
"label_utm_params": "Parámetros UTM",
"utm_source": "Source:",
"utm_medium": "Medium:",
"utm_campaign": "Campaign:",
"label_expires_at": "Caduca",
"label_max_clicks": "Max clics",
"max_clicks_value": "{used} / {max}",
"label_password_protected": "Con contraseña",
"yes": "Sí",
"section_timeline": "Clics en el tiempo",
"days_unit": "{days}d",
"hint_no_server": "Las analíticas detalladas están disponibles cuando el servidor uLoad está conectado.",
"hint_local_count": "Clics locales: {count}",
"empty_period": "Aún no hay datos para este periodo",
"section_devices": "Dispositivos",
"unknown": "Desconocido",
"empty_no_data": "Sin datos",
"section_referrers": "Referentes",
"direct": "Directo",
"section_countries": "Países"
},
"settings_route": {
"title": "Ajustes uLoad — Mana",
"heading": "Ajustes uLoad",
"subtitle": "Resumen de datos · Exportar · Zona peligrosa",
"section_data": "Datos",
"stat_links": "Enlaces",
"stat_tags": "Etiquetas",
"stat_folders": "Carpetas",
"section_export": "Exportar datos",
"export_hint": "Descarga todos los enlaces, etiquetas y carpetas como JSON.",
"action_export": "Exportar JSON",
"toast_exported": "Exportación descargada",
"section_danger": "Zona peligrosa",
"danger_hint": "Borra todos los datos locales de uLoad (enlaces, etiquetas, carpetas). Los datos sincronizados con el servidor se conservan.",
"action_clear": "Borrar todos los datos",
"confirm_clear": "¿Borrar todos los datos locales de uLoad? Esto no se puede deshacer.",
"toast_cleared": "Todos los datos de uLoad borrados"
},
"tags_route": {
"heading": "Etiquetas",
"hide_form": "Ocultar",
"show_form": "+ Nueva etiqueta",
"label_name": "Nombre",
"placeholder_name": "ej. Redes sociales",
"label_color": "Color",
"action_create": "Crear",
"empty_title": "Sin etiquetas",
"empty_desc": "Crea etiquetas para organizar tus enlaces.",
"links_count": "{count} enlaces",
"toast_created": "Etiqueta \"{name}\" creada",
"toast_deleted": "Etiqueta \"{name}\" eliminada",
"toast_updated": "Etiqueta actualizada"
}
}

View file

@ -1,286 +0,0 @@
{
"nav": {
"links": "Liens",
"tags": "Tags",
"analytics": "Analytiques",
"settings": "Parametres"
},
"links": {
"title": "Liens",
"newLink": "Nouveau lien",
"hide": "Masquer",
"url": "URL",
"urlPlaceholder": "https://example.com/url-longue",
"titleLabel": "Titre (optionnel)",
"titlePlaceholder": "Mon lien",
"customCode": "Code personnalise (optionnel)",
"customCodePlaceholder": "mon-lien",
"utmParams": "Parametres UTM",
"create": "Creer le lien",
"search": "Rechercher des liens...",
"all": "Tous",
"active": "Actifs",
"inactive": "Inactifs",
"allFolders": "Tous les dossiers",
"noLinks": "Aucun lien",
"noLinksDesc": "Creez votre premier lien raccourci !",
"copied": "Lien copie !",
"created": "Lien cree",
"updated": "Lien mis a jour",
"deleted": "Lien supprime",
"edit": "Modifier",
"editTitle": "Modifier le lien",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"deleteConfirm": "supprimer ?",
"activate": "Activer",
"deactivate": "Desactiver",
"copyLink": "Copier le lien",
"qrCode": "Code QR",
"qrDownload": "Telecharger le QR",
"clicks": "clics"
},
"tags": {
"title": "Tags",
"newTag": "Nouveau tag",
"name": "Nom",
"namePlaceholder": "ex. Reseaux sociaux",
"color": "Couleur",
"create": "Creer",
"noTags": "Aucun tag",
"noTagsDesc": "Creez des tags pour organiser vos liens.",
"created": "Tag cree",
"updated": "Tag mis a jour",
"deleted": "Tag supprime",
"linksCount": "Liens"
},
"analytics": {
"title": "Analytiques",
"clicks": "Clics",
"unique": "Uniques",
"status": "Statut",
"created": "Cree",
"clicksOverTime": "Clics dans le temps",
"devices": "Appareils",
"referrers": "Referents",
"countries": "Pays",
"noData": "Aucune donnee",
"noDataPeriod": "Aucune donnee pour cette periode",
"authRequired": "Analytiques pour utilisateurs connectes",
"localClicks": "Clics locaux",
"unknown": "Inconnu",
"direct": "Direct"
},
"settings": {
"title": "Parametres",
"account": "Compte",
"email": "Email",
"name": "Nom",
"data": "Donnees",
"clearData": "Effacer les donnees locales",
"clearConfirm": "Effacer toutes les donnees ?",
"cleared": "Donnees effacees",
"logout": "Deconnexion",
"guestHint": "Vous etes en mode invite.",
"loginToSync": "Connectez-vous pour synchroniser."
},
"common": {
"back": "Retour",
"login": "Se connecter",
"source": "Source",
"medium": "Medium",
"campaign": "Campagne"
},
"list_view": {
"empty_title": "Aucun lien",
"add_placeholder": "Coller URL...",
"err_invalid_url": "URL invalide",
"header_links": "{count} liens",
"header_clicks": "{count} clics",
"header_folders": "{count} dossiers",
"add_button": "Nouveau lien"
},
"detail_view": {
"not_found": "Lien introuvable",
"confirm_delete": "Vraiment supprimer ce lien ?",
"deleted_toast": "Lien supprimé",
"placeholder_title": "Titre...",
"label_url": "URL",
"label_short_code": "Code court",
"label_short_code_legacy": "Short Code",
"placeholder_short_code": "code-personnalise",
"label_active": "Actif",
"yes": "Oui",
"no": "Non",
"label_clicks": "Clics",
"label_expires_at": "Expire le",
"section_description": "Description",
"placeholder_description": "Ajouter une description...",
"meta_created": "Créé : {date}",
"meta_updated": "Modifié : {date}"
},
"page": {
"title": "uLoad - Mana",
"all_links": "Tous les liens",
"counts": "{links} liens · {folders} dossiers",
"counts_no_folders": "{links} liens",
"hide_form": "- Masquer",
"show_form": "+ Nouveau lien",
"err_invalid_url_input": "Veuillez saisir une URL valide (avec https://)",
"err_invalid_custom_code": "Le code personnalisé ne peut contenir que des lettres, chiffres, - et _",
"err_short_code_taken": "Le code \"{code}\" est déjà utilisé",
"err_max_clicks": "Max clics doit être au moins 1",
"err_expires_past": "La date d'expiration doit être dans le futur",
"toast_created": "Lien créé : {code}",
"toast_updated": "Lien mis à jour",
"confirm_delete": "Vraiment supprimer \"{name}\" ?",
"toast_deleted": "Lien supprimé",
"toast_copied": "Lien copié !",
"label_title": "Titre (optionnel)",
"placeholder_title": "Mon lien",
"label_custom_code": "Code personnalisé (optionnel)",
"placeholder_custom_code": "mon-lien",
"section_advanced": "Avancé",
"label_expires": "Date d'expiration",
"label_password": "Mot de passe",
"placeholder_optional": "Optionnel",
"label_max_clicks": "Max clics",
"placeholder_unlimited": "Illimité",
"section_utm": "Paramètres UTM",
"label_source": "Source",
"placeholder_source": "newsletter",
"label_medium": "Medium",
"placeholder_medium": "email",
"label_campaign": "Campaign",
"placeholder_campaign": "spring-2026",
"action_create": "Créer le lien",
"placeholder_search": "Rechercher des liens...",
"option_all": "Tous",
"option_active": "Actifs",
"option_inactive": "Inactifs",
"option_all_folders": "Tous les dossiers",
"empty_title": "Aucun lien",
"empty_desc": "Créez votre premier lien raccourci !",
"badge_utm": "UTM",
"badge_password": "Mot de passe",
"badge_expires": "Expiration",
"badge_expires_title": "Expire : {date}",
"action_analytics_title": "Analytiques",
"action_copy_title": "Copier le lien",
"action_qr_title": "Code QR",
"action_edit_title": "Modifier",
"action_activate_title": "Activer",
"action_deactivate_title": "Désactiver",
"action_delete_title": "Supprimer",
"modal_edit_title": "Modifier le lien",
"label_url_modal": "URL",
"label_title_modal": "Titre",
"label_short_code_modal": "Code court",
"short_code_locked": "(non modifiable)",
"action_cancel": "Annuler",
"action_save": "Enregistrer",
"modal_qr_title": "Code QR",
"qr_alt": "Code QR pour {code}",
"action_copy_link": "Copier le lien",
"action_download_qr": "Télécharger le QR"
},
"links_route": {
"title": "Tous les liens - uLoad - Mana",
"heading": "Tous les liens",
"action_back_title": "Retour",
"action_select_done": "Terminé",
"action_select_start": "Sélectionner",
"placeholder_search": "Rechercher des liens...",
"option_all": "Tous",
"option_active": "Actifs",
"option_inactive": "Inactifs",
"option_all_folders": "Tous les dossiers",
"selected_count": "{count} sélectionnés",
"action_bulk_toggle": "Activer/Désactiver",
"action_bulk_delete": "Supprimer",
"confirm_bulk_delete": "Supprimer {count} lien(s) ?",
"toast_bulk_deleted": "{count} liens supprimés",
"toast_bulk_updated": "{count} liens mis à jour",
"empty_title": "Aucun lien trouvé",
"empty_filtered": "Essaie d'autres filtres.",
"empty_root": "Crée des liens sur la page principale uLoad.",
"action_copy_title": "Copier le lien",
"action_activate_title": "Activer",
"action_deactivate_title": "Désactiver",
"action_delete_title": "Supprimer",
"action_analytics_title": "Analytiques",
"toast_copied": "Lien copié !",
"confirm_delete_single": "Vraiment supprimer \"{name}\" ?",
"toast_deleted_single": "Lien supprimé"
},
"analytics_route": {
"title": "Analytiques - uLoad - Mana",
"page_title": "Lien",
"action_back_title": "Retour",
"heading": "Analytiques",
"not_found": "Lien introuvable",
"stat_clicks": "Clics",
"stat_unique": "Uniques",
"stat_status": "Statut",
"status_active": "Actif",
"status_inactive": "Inactif",
"stat_created": "Créé",
"section_details": "Détails du lien",
"label_target_url": "URL cible",
"label_title": "Titre",
"label_utm_params": "Paramètres UTM",
"utm_source": "Source :",
"utm_medium": "Medium :",
"utm_campaign": "Campaign :",
"label_expires_at": "Expire le",
"label_max_clicks": "Max clics",
"max_clicks_value": "{used} / {max}",
"label_password_protected": "Protégé par mot de passe",
"yes": "Oui",
"section_timeline": "Clics dans le temps",
"days_unit": "{days}j",
"hint_no_server": "Les analytiques détaillées sont disponibles quand le serveur uLoad est connecté.",
"hint_local_count": "Clics locaux : {count}",
"empty_period": "Aucune donnée pour cette période",
"section_devices": "Appareils",
"unknown": "Inconnu",
"empty_no_data": "Aucune donnée",
"section_referrers": "Référents",
"direct": "Direct",
"section_countries": "Pays"
},
"settings_route": {
"title": "Paramètres uLoad — Mana",
"heading": "Paramètres uLoad",
"subtitle": "Aperçu des données · Export · Zone dangereuse",
"section_data": "Données",
"stat_links": "Liens",
"stat_tags": "Tags",
"stat_folders": "Dossiers",
"section_export": "Exporter les données",
"export_hint": "Téléchargez tous les liens, tags et dossiers en JSON.",
"action_export": "Exporter JSON",
"toast_exported": "Export téléchargé",
"section_danger": "Zone dangereuse",
"danger_hint": "Efface toutes les données locales uLoad (liens, tags, dossiers). Les données synchronisées sur le serveur restent.",
"action_clear": "Effacer toutes les données",
"confirm_clear": "Effacer toutes les données locales uLoad ? Cela ne peut pas être annulé.",
"toast_cleared": "Toutes les données uLoad effacées"
},
"tags_route": {
"heading": "Tags",
"hide_form": "Masquer",
"show_form": "+ Nouveau tag",
"label_name": "Nom",
"placeholder_name": "ex. Réseaux sociaux",
"label_color": "Couleur",
"action_create": "Créer",
"empty_title": "Aucun tag",
"empty_desc": "Crée des tags pour organiser tes liens.",
"links_count": "{count} liens",
"toast_created": "Tag \"{name}\" créé",
"toast_deleted": "Tag \"{name}\" supprimé",
"toast_updated": "Tag mis à jour"
}
}

View file

@ -1,286 +0,0 @@
{
"nav": {
"links": "Link",
"tags": "Tag",
"analytics": "Analisi",
"settings": "Impostazioni"
},
"links": {
"title": "Link",
"newLink": "Nuovo link",
"hide": "Nascondi",
"url": "URL",
"urlPlaceholder": "https://example.com/url-lungo",
"titleLabel": "Titolo (opzionale)",
"titlePlaceholder": "Il mio link",
"customCode": "Codice personalizzato (opzionale)",
"customCodePlaceholder": "mio-link",
"utmParams": "Parametri UTM",
"create": "Crea link",
"search": "Cerca link...",
"all": "Tutti",
"active": "Attivi",
"inactive": "Inattivi",
"allFolders": "Tutte le cartelle",
"noLinks": "Nessun link",
"noLinksDesc": "Crea il tuo primo link accorciato!",
"copied": "Link copiato!",
"created": "Link creato",
"updated": "Link aggiornato",
"deleted": "Link eliminato",
"edit": "Modifica",
"editTitle": "Modifica link",
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"deleteConfirm": "eliminare?",
"activate": "Attiva",
"deactivate": "Disattiva",
"copyLink": "Copia link",
"qrCode": "Codice QR",
"qrDownload": "Scarica QR",
"clicks": "clic"
},
"tags": {
"title": "Tag",
"newTag": "Nuovo tag",
"name": "Nome",
"namePlaceholder": "es. Social media",
"color": "Colore",
"create": "Crea",
"noTags": "Nessun tag",
"noTagsDesc": "Crea tag per organizzare i tuoi link.",
"created": "Tag creato",
"updated": "Tag aggiornato",
"deleted": "Tag eliminato",
"linksCount": "Link"
},
"analytics": {
"title": "Analisi",
"clicks": "Clic",
"unique": "Unici",
"status": "Stato",
"created": "Creato",
"clicksOverTime": "Clic nel tempo",
"devices": "Dispositivi",
"referrers": "Referenti",
"countries": "Paesi",
"noData": "Nessun dato",
"noDataPeriod": "Nessun dato per questo periodo",
"authRequired": "Analisi solo per utenti registrati",
"localClicks": "Clic locali",
"unknown": "Sconosciuto",
"direct": "Diretto"
},
"settings": {
"title": "Impostazioni",
"account": "Account",
"email": "Email",
"name": "Nome",
"data": "Dati",
"clearData": "Cancella dati locali",
"clearConfirm": "Cancellare tutti i dati?",
"cleared": "Dati cancellati",
"logout": "Esci",
"guestHint": "Sei in modalita ospite.",
"loginToSync": "Accedi per sincronizzare."
},
"common": {
"back": "Indietro",
"login": "Accedi",
"source": "Sorgente",
"medium": "Mezzo",
"campaign": "Campagna"
},
"list_view": {
"empty_title": "Nessun link",
"add_placeholder": "Incolla URL...",
"err_invalid_url": "URL non valido",
"header_links": "{count} link",
"header_clicks": "{count} clic",
"header_folders": "{count} cartelle",
"add_button": "Nuovo link"
},
"detail_view": {
"not_found": "Link non trovato",
"confirm_delete": "Eliminare davvero il link?",
"deleted_toast": "Link eliminato",
"placeholder_title": "Titolo...",
"label_url": "URL",
"label_short_code": "Codice breve",
"label_short_code_legacy": "Short Code",
"placeholder_short_code": "codice-personale",
"label_active": "Attivo",
"yes": "Sì",
"no": "No",
"label_clicks": "Clic",
"label_expires_at": "Scadenza",
"section_description": "Descrizione",
"placeholder_description": "Aggiungi descrizione...",
"meta_created": "Creato: {date}",
"meta_updated": "Modificato: {date}"
},
"page": {
"title": "uLoad - Mana",
"all_links": "Tutti i link",
"counts": "{links} link · {folders} cartelle",
"counts_no_folders": "{links} link",
"hide_form": "- Nascondi",
"show_form": "+ Nuovo link",
"err_invalid_url_input": "Inserisci un URL valido (con https://)",
"err_invalid_custom_code": "Il codice può contenere solo lettere, numeri, - e _",
"err_short_code_taken": "Il codice \"{code}\" è già in uso",
"err_max_clicks": "Max clic deve essere almeno 1",
"err_expires_past": "La data di scadenza deve essere nel futuro",
"toast_created": "Link creato: {code}",
"toast_updated": "Link aggiornato",
"confirm_delete": "Eliminare davvero \"{name}\"?",
"toast_deleted": "Link eliminato",
"toast_copied": "Link copiato!",
"label_title": "Titolo (opzionale)",
"placeholder_title": "Il mio link",
"label_custom_code": "Codice personale (opzionale)",
"placeholder_custom_code": "mio-link",
"section_advanced": "Avanzato",
"label_expires": "Data di scadenza",
"label_password": "Password",
"placeholder_optional": "Opzionale",
"label_max_clicks": "Max clic",
"placeholder_unlimited": "Illimitato",
"section_utm": "Parametri UTM",
"label_source": "Source",
"placeholder_source": "newsletter",
"label_medium": "Medium",
"placeholder_medium": "email",
"label_campaign": "Campaign",
"placeholder_campaign": "spring-2026",
"action_create": "Crea link",
"placeholder_search": "Cerca link...",
"option_all": "Tutti",
"option_active": "Attivi",
"option_inactive": "Inattivi",
"option_all_folders": "Tutte le cartelle",
"empty_title": "Nessun link",
"empty_desc": "Crea il tuo primo link accorciato!",
"badge_utm": "UTM",
"badge_password": "Password",
"badge_expires": "Scadenza",
"badge_expires_title": "Scade: {date}",
"action_analytics_title": "Analisi",
"action_copy_title": "Copia link",
"action_qr_title": "Codice QR",
"action_edit_title": "Modifica",
"action_activate_title": "Attiva",
"action_deactivate_title": "Disattiva",
"action_delete_title": "Elimina",
"modal_edit_title": "Modifica link",
"label_url_modal": "URL",
"label_title_modal": "Titolo",
"label_short_code_modal": "Codice breve",
"short_code_locked": "(non modificabile)",
"action_cancel": "Annulla",
"action_save": "Salva",
"modal_qr_title": "Codice QR",
"qr_alt": "Codice QR per {code}",
"action_copy_link": "Copia link",
"action_download_qr": "Scarica QR"
},
"links_route": {
"title": "Tutti i link - uLoad - Mana",
"heading": "Tutti i link",
"action_back_title": "Indietro",
"action_select_done": "Fatto",
"action_select_start": "Seleziona",
"placeholder_search": "Cerca link...",
"option_all": "Tutti",
"option_active": "Attivi",
"option_inactive": "Inattivi",
"option_all_folders": "Tutte le cartelle",
"selected_count": "{count} selezionati",
"action_bulk_toggle": "Attiva/Disattiva",
"action_bulk_delete": "Elimina",
"confirm_bulk_delete": "Eliminare {count} link?",
"toast_bulk_deleted": "{count} link eliminati",
"toast_bulk_updated": "{count} link aggiornati",
"empty_title": "Nessun link trovato",
"empty_filtered": "Prova altri filtri.",
"empty_root": "Crea link sulla pagina principale di uLoad.",
"action_copy_title": "Copia link",
"action_activate_title": "Attiva",
"action_deactivate_title": "Disattiva",
"action_delete_title": "Elimina",
"action_analytics_title": "Analisi",
"toast_copied": "Link copiato!",
"confirm_delete_single": "Eliminare davvero \"{name}\"?",
"toast_deleted_single": "Link eliminato"
},
"analytics_route": {
"title": "Analisi - uLoad - Mana",
"page_title": "Link",
"action_back_title": "Indietro",
"heading": "Analisi",
"not_found": "Link non trovato",
"stat_clicks": "Clic",
"stat_unique": "Unici",
"stat_status": "Stato",
"status_active": "Attivo",
"status_inactive": "Inattivo",
"stat_created": "Creato",
"section_details": "Dettagli link",
"label_target_url": "URL di destinazione",
"label_title": "Titolo",
"label_utm_params": "Parametri UTM",
"utm_source": "Source:",
"utm_medium": "Medium:",
"utm_campaign": "Campaign:",
"label_expires_at": "Scade",
"label_max_clicks": "Max clic",
"max_clicks_value": "{used} / {max}",
"label_password_protected": "Protetto da password",
"yes": "Sì",
"section_timeline": "Clic nel tempo",
"days_unit": "{days}g",
"hint_no_server": "Le analisi dettagliate sono disponibili quando il server uLoad è connesso.",
"hint_local_count": "Clic locali: {count}",
"empty_period": "Nessun dato per questo periodo",
"section_devices": "Dispositivi",
"unknown": "Sconosciuto",
"empty_no_data": "Nessun dato",
"section_referrers": "Referenti",
"direct": "Diretto",
"section_countries": "Paesi"
},
"settings_route": {
"title": "Impostazioni uLoad — Mana",
"heading": "Impostazioni uLoad",
"subtitle": "Riepilogo dati · Esporta · Zona pericolosa",
"section_data": "Dati",
"stat_links": "Link",
"stat_tags": "Tag",
"stat_folders": "Cartelle",
"section_export": "Esporta dati",
"export_hint": "Scarica tutti i link, tag e cartelle come JSON.",
"action_export": "Esporta JSON",
"toast_exported": "Esportazione scaricata",
"section_danger": "Zona pericolosa",
"danger_hint": "Cancella tutti i dati locali uLoad (link, tag, cartelle). I dati sincronizzati con il server restano.",
"action_clear": "Cancella tutti i dati",
"confirm_clear": "Cancellare tutti i dati locali uLoad? Non può essere annullato.",
"toast_cleared": "Tutti i dati uLoad cancellati"
},
"tags_route": {
"heading": "Tag",
"hide_form": "Nascondi",
"show_form": "+ Nuovo tag",
"label_name": "Nome",
"placeholder_name": "es. Social media",
"label_color": "Colore",
"action_create": "Crea",
"empty_title": "Nessun tag",
"empty_desc": "Crea tag per organizzare i tuoi link.",
"links_count": "{count} link",
"toast_created": "Tag \"{name}\" creato",
"toast_deleted": "Tag \"{name}\" eliminato",
"toast_updated": "Tag aggiornato"
}
}

View file

@ -1,242 +0,0 @@
<!--
uLoad — Workbench ListView
Short links list with click counts and quick link creation.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { encryptRecord } from '$lib/data/crypto';
import { BaseListView } from '@mana/shared-ui';
import { Plus, Link as LinkIcon } from '@mana/shared-icons';
import type { LocalLink, LocalFolder } from './types';
import type { ViewProps } from '$lib/app-registry';
import { linkTable } from './collections';
import { generateShortCode } from './queries';
let { navigate }: ViewProps = $props();
const linksQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalLink>('links').toArray();
const visible = all.filter((l) => !l.deletedAt && l.isActive);
return decryptRecords('links', visible);
}, [] as LocalLink[]);
const foldersQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalFolder>('uloadFolders').toArray();
return all.filter((f) => !f.deletedAt);
}, [] as LocalFolder[]);
const links = $derived(linksQuery.value);
const folders = $derived(foldersQuery.value);
const totalClicks = $derived(links.reduce((sum, l) => sum + l.clickCount, 0));
const sorted = $derived(
[...links].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')).slice(0, 20)
);
function hostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
// ── Quick-add link ──────────────────────────────────────
let showAdd = $state(false);
let newUrl = $state('');
let error = $state('');
async function addLink() {
const url = newUrl.trim();
if (!url) return;
// Auto-prepend https:// if missing
const fullUrl = /^https?:\/\//.test(url) ? url : `https://${url}`;
try {
const parsed = new URL(fullUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
error = $_('uload.list_view.err_invalid_url');
return;
}
} catch {
error = $_('uload.list_view.err_invalid_url');
return;
}
const shortCode = generateShortCode();
const newRow: LocalLink = {
id: crypto.randomUUID(),
shortCode,
customCode: null,
originalUrl: fullUrl,
title: null,
description: null,
isActive: true,
password: null,
maxClicks: null,
expiresAt: null,
clickCount: 0,
qrCodeUrl: null,
utmSource: null,
utmMedium: null,
utmCampaign: null,
folderId: null,
order: links.length,
};
await encryptRecord('links', newRow);
await linkTable.add(newRow);
newUrl = '';
error = '';
}
</script>
<BaseListView items={sorted} getKey={(l) => l.id} emptyTitle={$_('uload.list_view.empty_title')}>
{#snippet header()}
<span>{$_('uload.list_view.header_links', { values: { count: links.length } })}</span>
<span>{$_('uload.list_view.header_clicks', { values: { count: totalClicks } })}</span>
<span>{$_('uload.list_view.header_folders', { values: { count: folders.length } })}</span>
{/snippet}
{#snippet listHeader()}
{#if showAdd}
<form
onsubmit={(e) => {
e.preventDefault();
addLink();
}}
class="quick-add"
>
<LinkIcon size={14} class="icon" />
<!-- svelte-ignore a11y_autofocus -->
<input
class="add-input"
bind:value={newUrl}
placeholder={$_('uload.list_view.add_placeholder')}
autofocus
/>
<button type="submit" class="submit-btn" disabled={!newUrl.trim()}>
<Plus size={14} />
</button>
</form>
{#if error}
<p class="error-msg">{error}</p>
{/if}
{:else}
<button class="add-toggle" onclick={() => (showAdd = true)}>
<Plus size={14} />
<span>{$_('uload.list_view.add_button')}</span>
</button>
{/if}
{/snippet}
{#snippet item(link)}
<button
onclick={() =>
navigate('detail', {
linkId: link.id,
_siblingIds: sorted.map((l) => l.id),
_siblingKey: 'linkId',
})}
class="mb-1 w-full min-h-[44px] text-left rounded-md px-3 py-2 transition-colors hover:bg-muted/50 cursor-pointer"
>
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-foreground">
{link.title || link.shortCode}
</p>
<span class="shrink-0 text-xs text-muted-foreground">{link.clickCount}</span>
</div>
<p class="truncate text-xs text-muted-foreground/70">{hostname(link.originalUrl)}</p>
{#if link.customCode}
<p class="text-xs text-primary/70">/{link.customCode}</p>
{/if}
</button>
{/snippet}
</BaseListView>
<style>
.quick-add {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem;
margin-bottom: 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.quick-add :global(.icon) {
flex-shrink: 0;
color: hsl(var(--color-muted-foreground));
}
.add-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
padding: 0.125rem 0.25rem;
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 0.25rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.submit-btn:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.submit-btn:disabled {
opacity: 0.3;
cursor: default;
}
.add-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
margin-bottom: 0.625rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.add-toggle:hover {
border-color: hsl(var(--color-border-strong));
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface-hover));
}
.error-msg {
font-size: 0.6875rem;
color: hsl(var(--color-error));
margin: -0.25rem 0 0.5rem 0.25rem;
}
</style>

View file

@ -1,113 +0,0 @@
/**
* uLoad module collection accessors and guest seed data.
*
* Uses table names in the unified DB: links, uloadTags, uloadFolders, linkTags.
*/
import { db } from '$lib/data/database';
import type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const linkTable = db.table<LocalLink>('links');
export const uloadTagTable = db.table<LocalTag>('uloadTags');
export const uloadFolderTable = db.table<LocalFolder>('uloadFolders');
export const linkTagTable = db.table<LocalLinkTag>('linkTags');
// ─── Guest Seed ────────────────────────────────────────────
export const ULOAD_GUEST_SEED = {
uloadFolders: [
{
id: 'folder-personal',
name: 'Persoenlich',
color: '#3b82f6',
order: 0,
},
{
id: 'folder-work',
name: 'Arbeit',
color: '#10b981',
order: 1,
},
] satisfies LocalFolder[],
links: [
{
id: 'link-welcome',
shortCode: 'welcome',
originalUrl: 'https://ulo.ad',
title: 'Willkommen bei uLoad!',
description: 'Dein erster gekuerzter Link.',
isActive: true,
clickCount: 42,
folderId: 'folder-personal',
order: 0,
},
{
id: 'link-github',
shortCode: 'gh-demo',
originalUrl: 'https://github.com',
title: 'GitHub',
description: 'Beispiel-Link mit Tags',
isActive: true,
clickCount: 15,
folderId: 'folder-work',
order: 0,
},
{
id: 'link-docs',
shortCode: 'docs',
originalUrl: 'https://docs.example.com/getting-started',
title: 'Dokumentation',
description: 'Link mit UTM-Tracking',
isActive: true,
clickCount: 8,
utmSource: 'newsletter',
utmMedium: 'email',
utmCampaign: 'onboarding',
folderId: 'folder-work',
order: 1,
},
{
id: 'link-expired',
shortCode: 'old-promo',
originalUrl: 'https://example.com/promo',
title: 'Abgelaufene Promotion',
description: 'Dieser Link ist deaktiviert.',
isActive: false,
clickCount: 234,
folderId: 'folder-personal',
order: 1,
},
] satisfies LocalLink[],
uloadTags: [
{
id: 'tag-social',
name: 'Social Media',
slug: 'social-media',
color: '#8b5cf6',
icon: null,
visibility: 'space',
usageCount: 0,
},
{
id: 'tag-docs',
name: 'Dokumentation',
slug: 'dokumentation',
color: '#3b82f6',
icon: null,
visibility: 'space',
usageCount: 0,
},
{
id: 'tag-marketing',
name: 'Marketing',
slug: 'marketing',
color: '#10b981',
icon: null,
visibility: 'space',
usageCount: 0,
},
] satisfies LocalTag[],
linkTags: [] as LocalLinkTag[],
};

View file

@ -1,33 +0,0 @@
/**
* uLoad module barrel exports.
*/
export {
linkTable,
uloadTagTable,
uloadFolderTable,
linkTagTable,
ULOAD_GUEST_SEED,
} from './collections';
export {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
useLinkById,
toLink,
toTag,
toFolder,
toLinkTag,
getFilteredLinks,
getSortedLinks,
getLinkById,
getTagById,
getFolderById,
getTagUsageCount,
getLinkTags,
generateShortCode,
slugify,
} from './queries';
export type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
export type { Link, Tag, Folder, LinkTag, StatusFilter, LinkFilterCriteria } from './queries';

View file

@ -1,11 +0,0 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const uloadModuleConfig: ModuleConfig = {
appId: 'uload',
tables: [
{ name: 'links' },
{ name: 'uloadTags', syncName: 'tags' },
{ name: 'uloadFolders', syncName: 'folders' },
{ name: 'linkTags' },
],
};

View file

@ -1,273 +0,0 @@
/**
* Reactive Queries & Pure Helpers for uLoad module.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs).
*/
import { liveQuery } from 'dexie';
import { deriveUpdatedAt } from '$lib/data/sync';
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
import { db } from '$lib/data/database';
import { scopedForModule } from '$lib/data/scope';
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
import type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
// ─── Shared View Types ────────────────────────────────────
export interface Link {
id: string;
shortCode: string;
customCode?: string;
originalUrl: string;
title?: string;
description?: string;
isActive: boolean;
password?: string;
maxClicks?: number;
expiresAt?: string;
clickCount: number;
qrCodeUrl?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
folderId?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
slug: string;
color?: string;
icon?: string;
visibility: import('@mana/shared-privacy').VisibilityLevel;
usageCount: number;
createdAt: string;
updatedAt: string;
}
export interface Folder {
id: string;
name: string;
color?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface LinkTag {
id: string;
linkId: string;
tagId: string;
}
export type StatusFilter = 'all' | 'active' | 'inactive';
export interface LinkFilterCriteria {
search?: string;
status?: StatusFilter;
folderId?: string | null;
}
// ─── Type Converters ───────────────────────────────────────
export function toLink(local: LocalLink): Link {
return {
id: local.id,
shortCode: local.shortCode,
customCode: local.customCode ?? undefined,
originalUrl: local.originalUrl,
title: local.title ?? undefined,
description: local.description ?? undefined,
isActive: local.isActive,
password: local.password ?? undefined,
maxClicks: local.maxClicks ?? undefined,
expiresAt: local.expiresAt ?? undefined,
clickCount: local.clickCount,
qrCodeUrl: local.qrCodeUrl ?? undefined,
utmSource: local.utmSource ?? undefined,
utmMedium: local.utmMedium ?? undefined,
utmCampaign: local.utmCampaign ?? undefined,
folderId: local.folderId ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toTag(local: LocalTag): Tag {
return {
id: local.id,
name: local.name,
slug: local.slug,
color: local.color ?? undefined,
icon: local.icon ?? undefined,
visibility: local.visibility ?? 'space',
usageCount: local.usageCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toFolder(local: LocalFolder): Folder {
return {
id: local.id,
name: local.name,
color: local.color ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: deriveUpdatedAt(local),
};
}
export function toLinkTag(local: LocalLinkTag): LinkTag {
return {
id: local.id,
linkId: local.linkId,
tagId: local.tagId,
};
}
// ─── Raw Observable Queries ───────────────────────────────
export function allLinks$() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalLink, string>('uload', 'links').toArray();
const visible = locals.filter((l) => !l.deletedAt);
const decrypted = await decryptRecords('links', visible);
return decrypted.map(toLink);
});
}
export function allTags$() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalTag, string>('uload', 'uloadTags').toArray();
return locals.filter((t) => !t.deletedAt).map(toTag);
});
}
export function allFolders$() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalFolder, string>('uload', 'uloadFolders').toArray();
return locals.filter((f) => !f.deletedAt).map(toFolder);
});
}
export function allLinkTags$() {
return liveQuery(async () => {
const locals = await scopedForModule<LocalLinkTag, string>('uload', 'linkTags').toArray();
return locals.filter((lt) => !lt.deletedAt).map(toLinkTag);
});
}
// ─── Svelte 5 Reactive Hooks ──────────────────────────────
export function useAllLinks() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalLink, string>('uload', 'links').toArray();
const visible = locals.filter((l) => !l.deletedAt);
const decrypted = await decryptRecords('links', visible);
return decrypted.map(toLink);
}, [] as Link[]);
}
export function useAllTags() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalTag, string>('uload', 'uloadTags').toArray();
return locals.filter((t) => !t.deletedAt).map(toTag);
}, [] as Tag[]);
}
export function useAllFolders() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalFolder, string>('uload', 'uloadFolders').sortBy(
'order'
);
return locals.filter((f) => !f.deletedAt).map(toFolder);
}, [] as Folder[]);
}
export function useAllLinkTags() {
return useScopedLiveQuery(async () => {
const locals = await scopedForModule<LocalLinkTag, string>('uload', 'linkTags').toArray();
return locals.filter((lt) => !lt.deletedAt).map(toLinkTag);
}, [] as LinkTag[]);
}
export function useLinkById(id: string) {
return useScopedLiveQuery(
async () => {
if (!id) return null;
const local = await db.table<LocalLink>('links').get(id);
if (!local || local.deletedAt) return null;
const decrypted = await decryptRecord('links', { ...local });
return toLink(decrypted);
},
null as Link | null
);
}
// ─── Pure Filter / Sort Helpers ───────────────────────────
export function getFilteredLinks(links: Link[], filters: LinkFilterCriteria): Link[] {
let result = links;
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(l) =>
l.title?.toLowerCase().includes(q) ||
l.originalUrl.toLowerCase().includes(q) ||
l.shortCode.toLowerCase().includes(q)
);
}
if (filters.status === 'active') result = result.filter((l) => l.isActive);
if (filters.status === 'inactive') result = result.filter((l) => !l.isActive);
if (filters.folderId) result = result.filter((l) => l.folderId === filters.folderId);
return result;
}
export function getSortedLinks(links: Link[]): Link[] {
return [...links].sort((a, b) => a.order - b.order);
}
export function getLinkById(links: Link[], id: string): Link | undefined {
return links.find((l) => l.id === id);
}
export function getTagById(tags: Tag[], id: string): Tag | undefined {
return tags.find((t) => t.id === id);
}
export function getFolderById(folders: Folder[], id: string): Folder | undefined {
return folders.find((f) => f.id === id);
}
export function getTagUsageCount(linkTags: LinkTag[], tagId: string): number {
return linkTags.filter((lt) => lt.tagId === tagId).length;
}
export function getLinkTags(linkTags: LinkTag[], tags: Tag[], linkId: string): Tag[] {
const tagIds = linkTags.filter((lt) => lt.linkId === linkId).map((lt) => lt.tagId);
return tags.filter((t) => tagIds.includes(t.id));
}
export function generateShortCode(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

View file

@ -1,46 +0,0 @@
/**
* uLoad module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface LocalLink extends BaseRecord {
shortCode: string;
customCode?: string | null;
originalUrl: string;
title?: string | null;
description?: string | null;
isActive: boolean;
password?: string | null;
maxClicks?: number | null;
expiresAt?: string | null;
clickCount: number;
qrCodeUrl?: string | null;
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
folderId?: string | null;
order: number;
source?: string | null;
}
export interface LocalTag extends BaseRecord {
name: string;
slug: string;
color?: string | null;
icon?: string | null;
visibility?: VisibilityLevel;
usageCount: number;
}
export interface LocalFolder extends BaseRecord {
name: string;
color?: string | null;
order: number;
}
export interface LocalLinkTag extends BaseRecord {
linkId: string;
tagId: string;
}

View file

@ -1,178 +0,0 @@
<!--
uLoad — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { formatDate } from '$lib/i18n/format';
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import type { ViewProps } from '$lib/app-registry';
import type { LocalLink } from '../types';
let { params, goBack }: ViewProps = $props();
let linkId = $derived(params.linkId as string);
let editTitle = $state('');
let editOriginalUrl = $state('');
let editCustomCode = $state('');
let editDescription = $state('');
let editIsActive = $state(true);
let editExpiresAt = $state('');
const detail = useDetailEntity<LocalLink>({
id: () => linkId,
table: 'links',
decrypt: true,
onLoad: (val) => {
editTitle = val.title ?? '';
editOriginalUrl = val.originalUrl;
editCustomCode = val.customCode ?? '';
editDescription = val.description ?? '';
editIsActive = val.isActive;
editExpiresAt = val.expiresAt?.split('T')[0] ?? '';
},
});
async function saveField() {
detail.blur();
const diff: Record<string, unknown> = {
title: editTitle.trim() || undefined,
originalUrl: editOriginalUrl.trim() || detail.entity?.originalUrl || '',
customCode: editCustomCode.trim() || undefined,
description: editDescription.trim() || undefined,
isActive: editIsActive,
expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : null,
};
await encryptRecord('links', diff);
await db.table('links').update(linkId, diff);
}
async function handleActiveToggle() {
await db.table('links').update(linkId, {
isActive: editIsActive,
});
}
async function deleteLink() {
await db.table('links').update(linkId, {
deletedAt: new Date().toISOString(),
});
}
</script>
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel={$_('uload.detail_view.not_found')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('uload.detail_view.confirm_delete')}
onConfirmDelete={() =>
detail.deleteWithUndo({
label: $_('uload.detail_view.deleted_toast'),
delete: deleteLink,
goBack,
})}
>
{#snippet body(link)}
<input
class="title-input"
bind:value={editTitle}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('uload.detail_view.placeholder_title')}
/>
<div class="properties">
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_url')}</span>
<input
class="prop-input"
bind:value={editOriginalUrl}
onfocus={detail.focus}
onblur={saveField}
placeholder="https://..."
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_short_code')}</span>
<input
class="prop-input"
bind:value={editCustomCode}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('uload.detail_view.placeholder_short_code')}
/>
</div>
{#if link.shortCode}
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_short_code_legacy')}</span>
<span class="prop-value">{link.shortCode}</span>
</div>
{/if}
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_active')}</span>
<button
class="toggle-btn"
class:active={editIsActive}
onclick={() => {
editIsActive = !editIsActive;
handleActiveToggle();
}}
>
{editIsActive ? $_('uload.detail_view.yes') : $_('uload.detail_view.no')}
</button>
</div>
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_clicks')}</span>
<span class="prop-value">{link.clickCount}</span>
</div>
<div class="prop-row">
<span class="prop-label">{$_('uload.detail_view.label_expires_at')}</span>
<input
type="date"
class="prop-input"
bind:value={editExpiresAt}
onfocus={detail.focus}
onblur={saveField}
/>
</div>
</div>
<div class="section">
<span class="section-label">{$_('uload.detail_view.section_description')}</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('uload.detail_view.placeholder_description')}
rows={3}
></textarea>
</div>
<div class="meta">
<span
>{$_('uload.detail_view.meta_created', {
values: { date: formatDate(new Date(link.createdAt ?? '')) },
})}</span
>
{#if link.updatedAt}
<span
>{$_('uload.detail_view.meta_updated', {
values: { date: formatDate(new Date(link.updatedAt)) },
})}</span
>
{/if}
</div>
{/snippet}
</DetailViewShell>

View file

@ -22,7 +22,6 @@ const SPLIT_APP_ID_LIST = [
'skilltree',
'times',
'questions',
'uload',
'calc',
'places',
'automations',

View file

@ -1,804 +0,0 @@
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { _ } from 'svelte-i18n';
import {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
getFilteredLinks,
getSortedLinks,
getLinkTags,
generateShortCode,
type Link,
type StatusFilter,
} from '$lib/modules/uload/queries';
import { linkTable, uloadFolderTable } from '$lib/modules/uload/collections';
import { encryptRecord } from '$lib/data/crypto';
import type { LocalLink } from '$lib/modules/uload/types';
import {
CaretRight,
ChartBar,
Copy,
QrCode,
PencilSimple,
Lightning,
Trash,
X,
Link as LinkIcon,
FolderSimple,
MagnifyingGlass,
} from '@mana/shared-icons';
import { toast } from '$lib/stores/toast.svelte';
import { RoutePage } from '$lib/components/shell';
const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
// Reactive live queries
const allLinks = useAllLinks();
const allTags = useAllTags();
const allFolders = useAllFolders();
const allLinkTags = useAllLinkTags();
// Filter state
let searchQuery = $state('');
let selectedStatus = $state<StatusFilter>('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('');
let showAdvanced = $state(false);
let newExpiresAt = $state('');
let newPassword = $state('');
let newMaxClicks = $state('');
// Edit modal state
let editingLink = $state<Link | null>(null);
let editUrl = $state('');
let editTitle = $state('');
let editUtmSource = $state('');
let editUtmMedium = $state('');
let editUtmCampaign = $state('');
let editExpiresAt = $state('');
let editPassword = $state('');
let editMaxClicks = $state('');
// QR modal state
let qrLink = $state<Link | null>(null);
// Derived
const links = $derived(allLinks.value);
const folders = $derived(allFolders.value);
const tags = $derived(allTags.value);
const linkTags = $derived(allLinkTags.value);
const filteredLinks = $derived(
getSortedLinks(
getFilteredLinks(links, {
search: searchQuery,
status: selectedStatus,
folderId: selectedFolderId ?? undefined,
})
)
);
const ULOAD_DOMAIN = import.meta.env.PUBLIC_ULOAD_DOMAIN || 'ulo.ad';
function getShortUrl(code: string): string {
return `https://${ULOAD_DOMAIN}/${code}`;
}
function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function isValidCustomCode(code: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(code);
}
async function isShortCodeUnique(code: string): Promise<boolean> {
const existing = await linkTable.where('shortCode').equals(code).first();
return !existing;
}
async function createLink() {
if (!newUrl) return;
if (!isValidUrl(newUrl)) {
toast.error($_('uload.page.err_invalid_url_input'));
return;
}
const shortCode = newCustomCode || generateShortCode();
if (newCustomCode && !isValidCustomCode(newCustomCode)) {
toast.error($_('uload.page.err_invalid_custom_code'));
return;
}
if (!(await isShortCodeUnique(shortCode))) {
toast.error($_('uload.page.err_short_code_taken', { values: { code: shortCode } }));
return;
}
const maxClicks = newMaxClicks ? parseInt(newMaxClicks) : null;
if (maxClicks !== null && maxClicks < 1) {
toast.error($_('uload.page.err_max_clicks'));
return;
}
if (newExpiresAt && new Date(newExpiresAt) <= new Date()) {
toast.error($_('uload.page.err_expires_past'));
return;
}
const newRow: LocalLink = {
id: crypto.randomUUID(),
shortCode,
customCode: newCustomCode || null,
originalUrl: newUrl,
title: newTitle || null,
description: null,
isActive: true,
password: newPassword || null,
maxClicks,
expiresAt: newExpiresAt || null,
clickCount: 0,
qrCodeUrl: null,
utmSource: newUtmSource || null,
utmMedium: newUtmMedium || null,
utmCampaign: newUtmCampaign || null,
folderId: selectedFolderId,
order: links.length,
};
await encryptRecord('links', newRow);
await linkTable.add(newRow);
toast.success($_('uload.page.toast_created', { values: { code: shortCode } }));
newUrl = '';
newTitle = '';
newCustomCode = '';
newUtmSource = '';
newUtmMedium = '';
newUtmCampaign = '';
newExpiresAt = '';
newPassword = '';
newMaxClicks = '';
showUtm = false;
showAdvanced = false;
}
function openEdit(link: Link) {
editingLink = link;
editUrl = link.originalUrl;
editTitle = link.title ?? '';
editUtmSource = link.utmSource ?? '';
editUtmMedium = link.utmMedium ?? '';
editUtmCampaign = link.utmCampaign ?? '';
editExpiresAt = link.expiresAt ?? '';
editPassword = link.password ?? '';
editMaxClicks = link.maxClicks?.toString() ?? '';
}
async function saveEdit() {
if (!editingLink || !editUrl) return;
if (!isValidUrl(editUrl)) {
toast.error($_('uload.page.err_invalid_url_input'));
return;
}
const maxClicks = editMaxClicks ? parseInt(editMaxClicks) : null;
if (maxClicks !== null && maxClicks < 1) {
toast.error($_('uload.page.err_max_clicks'));
return;
}
if (editExpiresAt && new Date(editExpiresAt) <= new Date()) {
toast.error($_('uload.page.err_expires_past'));
return;
}
const diff: Record<string, unknown> = {
originalUrl: editUrl,
title: editTitle || null,
utmSource: editUtmSource || null,
utmMedium: editUtmMedium || null,
utmCampaign: editUtmCampaign || null,
expiresAt: editExpiresAt || null,
password: editPassword || null,
maxClicks,
};
await encryptRecord('links', diff);
await linkTable.update(editingLink.id, diff);
toast.success($_('uload.page.toast_updated'));
editingLink = null;
}
async function toggleActive(link: Link) {
await linkTable.update(link.id, { isActive: !link.isActive });
}
async function deleteLink(link: Link) {
const name = link.title || link.shortCode;
if (!confirm($_('uload.page.confirm_delete', { values: { name } }))) return;
await linkTable.delete(link.id);
toast.success($_('uload.page.toast_deleted'));
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success($_('uload.page.toast_copied'));
}
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-border-strong bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-border dark:bg-muted';
const inputSmClass =
'w-full rounded-lg border border-border-strong bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-border dark:bg-muted';
</script>
<svelte:head>
<title>{$_('uload.page.title')}</title>
</svelte:head>
<RoutePage appId="uload">
<div class="min-h-screen">
<div class="mx-auto max-w-7xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">uLoad</h1>
<p class="mt-1 text-sm opacity-60">
{folders.length > 0
? $_('uload.page.counts', {
values: { links: filteredLinks.length, folders: folders.length },
})
: $_('uload.page.counts_no_folders', {
values: { links: filteredLinks.length },
})}
</p>
</div>
<div class="flex items-center gap-2">
<a
href="/uload/links"
class="rounded-lg border border-border-strong px-3 py-2 text-sm font-medium hover:bg-muted dark:border-border dark:hover:bg-muted"
>
{$_('uload.page.all_links')}
</a>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white shadow-lg transition-[transform,colors,box-shadow] hover:scale-105 hover:bg-indigo-700"
>
{showCreateForm ? $_('uload.page.hide_form') : $_('uload.page.show_form')}
</button>
</div>
</div>
<!-- Create Form -->
{#if showCreateForm}
<div
class="mb-6 rounded-xl border border-border-strong bg-white p-6 shadow-sm dark:border-border dark:bg-card"
>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label for="url" class="mb-1 block text-sm font-medium"
>{$_('uload.page.label_url_modal')}</label
>
<input
id="url"
type="url"
bind:value={newUrl}
placeholder="https://example.com/long-url-here"
class={inputClass}
onkeydown={(e) => e.key === 'Enter' && createLink()}
/>
</div>
<div>
<label for="title" class="mb-1 block text-sm font-medium"
>{$_('uload.page.label_title')}</label
>
<input
id="title"
type="text"
bind:value={newTitle}
placeholder={$_('uload.page.placeholder_title')}
class={inputClass}
/>
</div>
<div>
<label for="code" class="mb-1 block text-sm font-medium"
>{$_('uload.page.label_custom_code')}</label
>
<input
id="code"
type="text"
bind:value={newCustomCode}
placeholder={$_('uload.page.placeholder_custom_code')}
class={inputClass}
/>
</div>
</div>
<!-- Advanced Options -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="mt-2 flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700"
>
<span class="transition-transform {showAdvanced ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
{$_('uload.page.section_advanced')}
</button>
{#if showAdvanced}
<div class="mt-3 grid gap-3 md:grid-cols-3">
<div>
<label for="expires" class="mb-1 block text-xs font-medium opacity-70"
>{$_('uload.page.label_expires')}</label
>
<input
id="expires"
type="datetime-local"
bind:value={newExpiresAt}
class={inputSmClass}
/>
</div>
<div>
<label for="password" class="mb-1 block text-xs font-medium opacity-70"
>{$_('uload.page.label_password')}</label
>
<input
id="password"
type="text"
bind:value={newPassword}
placeholder={$_('uload.page.placeholder_optional')}
class={inputSmClass}
/>
</div>
<div>
<label for="maxclicks" class="mb-1 block text-xs font-medium opacity-70"
>{$_('uload.page.label_max_clicks')}</label
>
<input
id="maxclicks"
type="number"
bind:value={newMaxClicks}
placeholder={$_('uload.page.placeholder_unlimited')}
min="1"
class={inputSmClass}
/>
</div>
</div>
{/if}
<!-- UTM Parameters -->
<button
onclick={() => (showUtm = !showUtm)}
class="mt-3 flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700"
>
<span class="transition-transform {showUtm ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
{$_('uload.page.section_utm')}
</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"
>{$_('uload.page.label_source')}</label
>
<input
id="utm-source"
type="text"
bind:value={newUtmSource}
placeholder={$_('uload.page.placeholder_source')}
class={inputSmClass}
/>
</div>
<div>
<label for="utm-medium" class="mb-1 block text-xs font-medium opacity-70"
>{$_('uload.page.label_medium')}</label
>
<input
id="utm-medium"
type="text"
bind:value={newUtmMedium}
placeholder={$_('uload.page.placeholder_medium')}
class={inputSmClass}
/>
</div>
<div>
<label for="utm-campaign" class="mb-1 block text-xs font-medium opacity-70"
>{$_('uload.page.label_campaign')}</label
>
<input
id="utm-campaign"
type="text"
bind:value={newUtmCampaign}
placeholder={$_('uload.page.placeholder_campaign')}
class={inputSmClass}
/>
</div>
</div>
{/if}
<div class="mt-4 flex justify-end">
<button
onclick={createLink}
disabled={!newUrl}
class="rounded-lg bg-indigo-600 px-6 py-2.5 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{$_('uload.page.action_create')}
</button>
</div>
</div>
{/if}
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative">
<MagnifyingGlass
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40"
/>
<input
type="text"
bind:value={searchQuery}
placeholder={$_('uload.page.placeholder_search')}
class="w-60 rounded-lg border border-border-strong bg-white py-2 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-border dark:bg-muted"
/>
</div>
<select bind:value={selectedStatus} class={inputSmClass} style="max-width: 140px">
<option value="all">{$_('uload.page.option_all')}</option>
<option value="active">{$_('uload.page.option_active')}</option>
<option value="inactive">{$_('uload.page.option_inactive')}</option>
</select>
{#if folders.length > 0}
<select bind:value={selectedFolderId} class={inputSmClass} style="max-width: 160px">
<option value={null}>{$_('uload.page.option_all_folders')}</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
</select>
{/if}
</div>
<!-- Links List -->
{#if allLinks.loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-20 animate-pulse rounded-xl bg-muted dark:bg-card"></div>
{/each}
</div>
{:else if filteredLinks.length === 0}
<div
class="rounded-xl border-2 border-dashed border-border-strong p-12 text-center dark:border-border"
>
<LinkIcon size={48} class="mx-auto mb-4 opacity-20" />
<p class="text-lg font-medium opacity-60">{$_('uload.page.empty_title')}</p>
<p class="mt-1 text-sm opacity-40">{$_('uload.page.empty_desc')}</p>
<button
onclick={() => (showCreateForm = true)}
class="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
{$_('uload.page.show_form')}
</button>
</div>
{:else}
<div class="space-y-3">
{#each filteredLinks as link (link.id)}
<div
class="group rounded-xl border border-border-strong bg-white p-4 shadow-sm transition-colors hover:shadow-md dark:border-border dark:bg-card"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-muted'}"
></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"
>{$_('uload.page.badge_utm')}</span
>
{/if}
{#if link.password}
<span
class="shrink-0 rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 dark:bg-red-900 dark:text-red-300"
>{$_('uload.page.badge_password')}</span
>
{/if}
{#if link.expiresAt}
<span
class="shrink-0 rounded bg-orange-100 px-1.5 py-0.5 text-xs text-orange-700 dark:bg-orange-900 dark:text-orange-300"
title={$_('uload.page.badge_expires_title', {
values: { date: formatDate(new Date(link.expiresAt)) },
})}>{$_('uload.page.badge_expires')}</span
>
{/if}
</div>
<p class="mt-1 truncate text-sm opacity-60">{link.originalUrl}</p>
{#if getLinkTags(linkTags, tags, link.id).length > 0}
<div class="mt-1 flex gap-1">
{#each getLinkTags(linkTags, tags, link.id) as tag}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
style="background: {tag.color ?? '#6b7280'}20; color: {tag.color ??
'#6b7280'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<div class="ml-4 flex items-center gap-1">
<a
href="/uload/analytics/{link.id}"
class="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm font-medium opacity-60 transition-colors hover:bg-muted hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.page.action_analytics_title')}
>
<ChartBar size={16} />
{link.clickCount}
</a>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.page.action_copy_title')}
>
<Copy size={16} />
</button>
<button
onclick={() => (qrLink = link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.page.action_qr_title')}
>
<QrCode size={16} />
</button>
<button
onclick={() => openEdit(link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.page.action_edit_title')}
>
<PencilSimple size={16} />
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={link.isActive
? $_('uload.page.action_deactivate_title')
: $_('uload.page.action_activate_title')}
>
<Lightning
size={16}
class={link.isActive ? 'text-green-500' : 'text-muted-foreground'}
/>
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title={$_('uload.page.action_delete_title')}
>
<Trash size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</RoutePage>
<!-- Edit Modal -->
{#if editingLink}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (editingLink = null)}
onkeydown={(e) => e.key === 'Escape' && (editingLink = null)}
tabindex="-1"
role="presentation"
>
<div
class="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl dark:bg-card"
onclick={(e) => e.stopPropagation()}
role="none"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">{$_('uload.page.modal_edit_title')}</h3>
<button
onclick={() => (editingLink = null)}
class="rounded-lg p-1 hover:bg-muted dark:hover:bg-muted"
>
<X size={20} />
</button>
</div>
<div class="space-y-4">
<div>
<label for="edit-url" class="mb-1 block text-sm font-medium"
>{$_('uload.page.label_url_modal')}</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"
>{$_('uload.page.label_title_modal')}</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"
>{$_('uload.page.label_short_code_modal')}</label
>
<div class="flex items-center gap-2">
<span class="text-sm opacity-50">/{editingLink.shortCode}</span>
<span class="text-xs opacity-30">{$_('uload.page.short_code_locked')}</span>
</div>
</div>
<div class="border-t border-border-strong pt-4 dark:border-border">
<p class="mb-2 text-sm font-medium opacity-70">{$_('uload.page.section_utm')}</p>
<div class="grid gap-3 md:grid-cols-3">
<input
type="text"
bind:value={editUtmSource}
placeholder={$_('uload.page.label_source')}
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmMedium}
placeholder={$_('uload.page.label_medium')}
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmCampaign}
placeholder={$_('uload.page.label_campaign')}
class={inputSmClass}
/>
</div>
</div>
<div class="border-t border-border-strong pt-4 dark:border-border">
<p class="mb-2 text-sm font-medium opacity-70">{$_('uload.page.section_advanced')}</p>
<div class="grid gap-3 md:grid-cols-3">
<div>
<label for="uload-expires-at" class="mb-1 block text-xs opacity-50"
>{$_('uload.page.label_expires')}</label
>
<input
id="uload-expires-at"
type="datetime-local"
bind:value={editExpiresAt}
class={inputSmClass}
/>
</div>
<div>
<label for="uload-password" class="mb-1 block text-xs opacity-50"
>{$_('uload.page.label_password')}</label
>
<input
id="uload-password"
type="text"
bind:value={editPassword}
placeholder={$_('uload.page.placeholder_optional')}
class={inputSmClass}
/>
</div>
<div>
<label for="uload-max-clicks" class="mb-1 block text-xs opacity-50"
>{$_('uload.page.label_max_clicks')}</label
>
<input
id="uload-max-clicks"
type="number"
bind:value={editMaxClicks}
placeholder={$_('uload.page.placeholder_unlimited')}
min="1"
class={inputSmClass}
/>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={() => (editingLink = null)}
class="rounded-lg border border-border-strong px-4 py-2 text-sm font-medium hover:bg-muted dark:border-border dark:hover:bg-muted"
>
{$_('uload.page.action_cancel')}
</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"
>
{$_('uload.page.action_save')}
</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)}
onkeydown={(e) => e.key === 'Escape' && (qrLink = null)}
tabindex="-1"
role="presentation"
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-card"
onclick={(e) => e.stopPropagation()}
role="none"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">{$_('uload.page.modal_qr_title')}</h3>
<button
onclick={() => (qrLink = null)}
class="rounded-lg p-1 hover:bg-muted dark:hover:bg-muted"
>
<X size={20} />
</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={$_('uload.page.qr_alt', { values: { code: 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-border-strong px-4 py-2 text-sm font-medium hover:bg-muted dark:border-border dark:hover:bg-muted"
>
{$_('uload.page.action_copy_link')}
</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"
>
{$_('uload.page.action_download_qr')}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -1,393 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { formatDate } from '$lib/i18n/format';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { CaretLeft } from '@mana/shared-icons';
import { useLinkById } from '$lib/modules/uload/queries';
import { authStore } from '$lib/stores/auth.svelte';
import { RoutePage } from '$lib/components/shell';
const ULOAD_SERVER = import.meta.env.PUBLIC_ULOAD_SERVER_URL || 'http://localhost:3070';
let linkId = $derived($page.params.id ?? '');
const linkQuery = $derived(useLinkById(linkId));
const link = $derived(linkQuery.value);
let stats = $state<{ totalClicks: number; uniqueVisitors: number } | null>(null);
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 serverAvailable = $state(false);
let days = $state(30);
async function fetchAnalytics() {
if (!authStore.isAuthenticated) {
loading = false;
return;
}
try {
const token = await authStore.getValidToken();
const headers = { Authorization: `Bearer ${token}` };
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=${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();
serverAvailable = true;
}
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 {
// Server not available — show local data only
serverAvailable = false;
}
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>
<svelte:head>
<title>{$_('uload.analytics_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload" title={$_('uload.analytics_route.page_title')}>
<div class="mx-auto max-w-4xl p-4">
<!-- Header -->
<div class="mb-6 flex items-center gap-4">
<a
href="/uload"
class="rounded-lg p-2 transition-colors hover:bg-muted/5"
title={$_('uload.analytics_route.action_back_title')}
>
<CaretLeft size={20} class="text-muted-foreground" />
</a>
<div>
<h1 class="text-2xl font-bold text-white">{$_('uload.analytics_route.heading')}</h1>
{#if link}
<p class="mt-1 text-sm text-muted-foreground">
<span class="font-mono text-indigo-400">/{link.shortCode}</span>
&rarr; <span class="truncate">{link.originalUrl}</span>
</p>
{/if}
</div>
</div>
{#if loading}
<div class="space-y-4">
{#each Array(4) as _}
<div class="h-32 animate-pulse rounded-xl bg-muted/5"></div>
{/each}
</div>
{:else if !link}
<div class="rounded-xl border border-border/10 p-12 text-center">
<p class="text-muted-foreground">{$_('uload.analytics_route.not_found')}</p>
</div>
{:else}
<!-- Stats Overview -->
<div class="mb-6 grid gap-4 sm:grid-cols-4">
<div class="rounded-xl border border-border/10 bg-muted/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{$_('uload.analytics_route.stat_clicks')}
</p>
<p class="mt-1 text-3xl font-bold text-white">
{stats?.totalClicks ?? link.clickCount}
</p>
</div>
<div class="rounded-xl border border-border/10 bg-muted/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{$_('uload.analytics_route.stat_unique')}
</p>
<p class="mt-1 text-3xl font-bold text-white">
{stats?.uniqueVisitors ?? '-'}
</p>
</div>
<div class="rounded-xl border border-border/10 bg-muted/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{$_('uload.analytics_route.stat_status')}
</p>
<p class="mt-1 text-3xl font-bold">
{#if link.isActive}
<span class="text-green-400">{$_('uload.analytics_route.status_active')}</span>
{:else}
<span class="text-muted-foreground/70"
>{$_('uload.analytics_route.status_inactive')}</span
>
{/if}
</p>
</div>
<div class="rounded-xl border border-border/10 bg-muted/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{$_('uload.analytics_route.stat_created')}
</p>
<p class="mt-1 text-lg font-bold text-white">
{formatDate(new Date(link.createdAt))}
</p>
</div>
</div>
<!-- Link Details -->
<div class="mb-6 rounded-xl border border-border/10 bg-muted/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">
{$_('uload.analytics_route.section_details')}
</h2>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{$_('uload.analytics_route.label_target_url')}</span
>
<a
href={link.originalUrl}
target="_blank"
rel="noopener noreferrer"
class="max-w-md truncate text-indigo-400 hover:underline"
>
{link.originalUrl}
</a>
</div>
{#if link.title}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{$_('uload.analytics_route.label_title')}</span>
<span class="text-white">{link.title}</span>
</div>
{/if}
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<div class="border-t border-border/10 pt-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{$_('uload.analytics_route.label_utm_params')}
</p>
<div class="grid gap-2 sm:grid-cols-3">
{#if link.utmSource}
<div class="text-sm text-foreground">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.utm_source')}</span
>
{link.utmSource}
</div>
{/if}
{#if link.utmMedium}
<div class="text-sm text-foreground">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.utm_medium')}</span
>
{link.utmMedium}
</div>
{/if}
{#if link.utmCampaign}
<div class="text-sm text-foreground">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.utm_campaign')}</span
>
{link.utmCampaign}
</div>
{/if}
</div>
</div>
{/if}
{#if link.expiresAt}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.label_expires_at')}</span
>
<span class="text-white">{formatDate(new Date(link.expiresAt))}</span>
</div>
{/if}
{#if link.maxClicks}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.label_max_clicks')}</span
>
<span class="text-white"
>{$_('uload.analytics_route.max_clicks_value', {
values: { used: link.clickCount, max: link.maxClicks },
})}</span
>
</div>
{/if}
{#if link.password}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground"
>{$_('uload.analytics_route.label_password_protected')}</span
>
<span class="text-white">{$_('uload.analytics_route.yes')}</span>
</div>
{/if}
</div>
</div>
<!-- Timeline -->
<div class="mb-6 rounded-xl border border-border/10 bg-muted/5 p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">
{$_('uload.analytics_route.section_timeline')}
</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-muted/10 text-muted-foreground hover:bg-muted/15'}"
>
{$_('uload.analytics_route.days_unit', { values: { days: d } })}
</button>
{/each}
</div>
</div>
{#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-muted/90 px-2 py-1 text-xs text-foreground group-hover:block"
>
{day.count}
</div>
</div>
{/each}
</div>
<div class="mt-1 flex justify-between text-xs text-muted-foreground/70">
<span>{timeline[0]?.date}</span>
<span>{timeline[timeline.length - 1]?.date}</span>
</div>
{:else if !serverAvailable}
<div class="py-8 text-center">
<p class="text-sm text-muted-foreground">
{$_('uload.analytics_route.hint_no_server')}
</p>
<p class="mt-1 text-xs text-muted-foreground/70">
{$_('uload.analytics_route.hint_local_count', {
values: { count: link.clickCount },
})}
</p>
</div>
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">
{$_('uload.analytics_route.empty_period')}
</p>
{/if}
</div>
<!-- Breakdown Grid -->
{#if serverAvailable}
<div class="grid gap-6 md:grid-cols-3">
<!-- Devices -->
<div class="rounded-xl border border-border/10 bg-muted/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">
{$_('uload.analytics_route.section_devices')}
</h2>
{#if devices.length > 0}
<div class="space-y-3">
{#each devices as d}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-foreground"
>{d.deviceType || $_('uload.analytics_route.unknown')}</span
>
<span class="font-medium text-white">
{Math.round((d.count / totalDevices) * 100)}%
</span>
</div>
<div class="h-2 rounded-full bg-muted/10">
<div
class="h-2 rounded-full bg-indigo-500"
style="width: {(d.count / totalDevices) * 100}%"
></div>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">
{$_('uload.analytics_route.empty_no_data')}
</p>
{/if}
</div>
<!-- Referrers -->
<div class="rounded-xl border border-border/10 bg-muted/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">
{$_('uload.analytics_route.section_referrers')}
</h2>
{#if referrers.length > 0}
<div class="space-y-2">
{#each referrers.slice(0, 8) as r}
<div class="flex items-center justify-between text-sm">
<span class="max-w-[140px] truncate text-foreground">
{r.referer || $_('uload.analytics_route.direct')}
</span>
<span class="font-medium tabular-nums text-white">{r.count}</span>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">
{$_('uload.analytics_route.empty_no_data')}
</p>
{/if}
</div>
<!-- Countries -->
<div class="rounded-xl border border-border/10 bg-muted/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">
{$_('uload.analytics_route.section_countries')}
</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 class="text-foreground"
>{c.country || $_('uload.analytics_route.unknown')}</span
>
<span class="font-medium text-white">
{Math.round((c.count / totalCountries) * 100)}%
</span>
</div>
<div class="h-2 rounded-full bg-muted/10">
<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 text-muted-foreground">
{$_('uload.analytics_route.empty_no_data')}
</p>
{/if}
</div>
</div>
{/if}
{/if}
</div>
</RoutePage>

View file

@ -1,353 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
getFilteredLinks,
getSortedLinks,
getLinkTags,
generateShortCode,
type Link,
type StatusFilter,
} from '$lib/modules/uload/queries';
import { linkTable } from '$lib/modules/uload/collections';
import type { LocalLink } from '$lib/modules/uload/types';
import {
ArrowLeft,
ChartBar,
Copy,
QrCode,
PencilSimple,
Lightning,
Trash,
MagnifyingGlass,
Link as LinkIcon,
} from '@mana/shared-icons';
import { toast } from '$lib/stores/toast.svelte';
import { RoutePage } from '$lib/components/shell';
const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
// Reactive live queries
const allLinks = useAllLinks();
const allTags = useAllTags();
const allFolders = useAllFolders();
const allLinkTags = useAllLinkTags();
// Filter state
let searchQuery = $state('');
let selectedStatus = $state<StatusFilter>('all');
let selectedFolderId = $state<string | null>(null);
// Bulk selection
let selectMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
// Derived
const links = $derived(allLinks.value);
const folders = $derived(allFolders.value);
const tags = $derived(allTags.value);
const linkTags = $derived(allLinkTags.value);
const filteredLinks = $derived(
getSortedLinks(
getFilteredLinks(links, {
search: searchQuery,
status: selectedStatus,
folderId: selectedFolderId ?? undefined,
})
)
);
const ULOAD_DOMAIN = import.meta.env.PUBLIC_ULOAD_DOMAIN || 'ulo.ad';
function getShortUrl(code: string): string {
return `https://${ULOAD_DOMAIN}/${code}`;
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success($_('uload.links_route.toast_copied'));
}
async function toggleActive(link: Link) {
await linkTable.update(link.id, { isActive: !link.isActive });
}
async function deleteLink(link: Link) {
const name = link.title || link.shortCode;
if (!confirm($_('uload.links_route.confirm_delete_single', { values: { name } }))) return;
await linkTable.delete(link.id);
toast.success($_('uload.links_route.toast_deleted_single'));
}
// Bulk actions
function toggleSelect(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
selectedIds = selectedIds;
}
function toggleSelectAll() {
if (selectedIds.size === filteredLinks.length) {
selectedIds.clear();
} else {
selectedIds = new Set(filteredLinks.map((l) => l.id));
}
selectedIds = selectedIds;
}
async function bulkDelete() {
const count = selectedIds.size;
if (!confirm($_('uload.links_route.confirm_bulk_delete', { values: { count } }))) return;
for (const id of selectedIds) {
await linkTable.delete(id);
}
toast.success($_('uload.links_route.toast_bulk_deleted', { values: { count } }));
selectedIds.clear();
selectedIds = selectedIds;
selectMode = false;
}
async function bulkToggleActive() {
const count = selectedIds.size;
for (const id of selectedIds) {
const link = filteredLinks.find((l) => l.id === id);
if (link) await linkTable.update(id, { isActive: !link.isActive });
}
toast.success($_('uload.links_route.toast_bulk_updated', { values: { count } }));
selectedIds.clear();
selectedIds = selectedIds;
selectMode = false;
}
const inputSmClass =
'w-full rounded-lg border border-border-strong bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-border dark:bg-muted';
</script>
<svelte:head>
<title>{$_('uload.links_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload">
<div class="min-h-screen">
<div class="mx-auto max-w-7xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/uload"
class="rounded-lg p-2 hover:bg-muted dark:hover:bg-muted"
title={$_('uload.links_route.action_back_title')}
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold">
{$_('uload.links_route.heading')}
{#if filteredLinks.length > 0}
<span class="ml-1 text-xl opacity-50">({filteredLinks.length})</span>
{/if}
</h1>
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => {
selectMode = !selectMode;
if (!selectMode) {
selectedIds.clear();
selectedIds = selectedIds;
}
}}
class="rounded-lg border border-border-strong px-3 py-2 text-sm font-medium transition-colors {selectMode
? 'bg-indigo-600 text-white'
: 'hover:bg-muted dark:border-border dark:hover:bg-muted'}"
>
{selectMode
? $_('uload.links_route.action_select_done')
: $_('uload.links_route.action_select_start')}
</button>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative">
<MagnifyingGlass
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40"
/>
<input
type="text"
bind:value={searchQuery}
placeholder={$_('uload.links_route.placeholder_search')}
class="w-60 rounded-lg border border-border-strong bg-white py-2 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-border dark:bg-muted"
/>
</div>
<select bind:value={selectedStatus} class={inputSmClass} style="max-width: 140px">
<option value="all">{$_('uload.links_route.option_all')}</option>
<option value="active">{$_('uload.links_route.option_active')}</option>
<option value="inactive">{$_('uload.links_route.option_inactive')}</option>
</select>
{#if folders.length > 0}
<select bind:value={selectedFolderId} class={inputSmClass} style="max-width: 160px">
<option value={null}>{$_('uload.links_route.option_all_folders')}</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
</select>
{/if}
</div>
<!-- Bulk Actions Bar -->
{#if selectMode && selectedIds.size > 0}
<div
class="mb-4 flex items-center gap-3 rounded-lg border border-indigo-200 bg-indigo-50 p-3 dark:border-indigo-800 dark:bg-indigo-900/20"
>
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selectedIds.size === filteredLinks.length}
onchange={toggleSelectAll}
class="h-4 w-4 rounded"
/>
<span class="text-sm font-medium"
>{$_('uload.links_route.selected_count', {
values: { count: selectedIds.size },
})}</span
>
</label>
<div class="h-4 w-px bg-indigo-300 dark:bg-indigo-700"></div>
<button
onclick={bulkToggleActive}
class="rounded px-3 py-1 text-sm font-medium hover:bg-indigo-100 dark:hover:bg-indigo-800"
>{$_('uload.links_route.action_bulk_toggle')}</button
>
<button
onclick={bulkDelete}
class="rounded px-3 py-1 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>{$_('uload.links_route.action_bulk_delete')}</button
>
</div>
{/if}
<!-- Links List -->
{#if allLinks.loading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-20 animate-pulse rounded-xl bg-muted dark:bg-card"></div>
{/each}
</div>
{:else if filteredLinks.length === 0}
<div
class="rounded-xl border-2 border-dashed border-border-strong p-12 text-center dark:border-border"
>
<LinkIcon size={48} class="mx-auto mb-4 opacity-20" />
<p class="text-lg font-medium opacity-60">{$_('uload.links_route.empty_title')}</p>
{#if searchQuery || selectedStatus !== 'all' || selectedFolderId}
<p class="mt-1 text-sm opacity-40">{$_('uload.links_route.empty_filtered')}</p>
{:else}
<p class="mt-1 text-sm opacity-40">{$_('uload.links_route.empty_root')}</p>
{/if}
</div>
{:else}
<div class="space-y-3">
{#each filteredLinks as link (link.id)}
<div
class="group rounded-xl border border-border-strong bg-white p-4 shadow-sm transition-colors hover:shadow-md dark:border-border dark:bg-card"
>
<div class="flex items-center justify-between">
{#if selectMode}
<input
type="checkbox"
checked={selectedIds.has(link.id)}
onchange={() => toggleSelect(link.id)}
class="mr-3 h-4 w-4 shrink-0 rounded"
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-muted'}"
></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>
{#if getLinkTags(linkTags, tags, link.id).length > 0}
<div class="mt-1 flex gap-1">
{#each getLinkTags(linkTags, tags, link.id) as tag}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
style="background: {tag.color ?? '#6b7280'}20; color: {tag.color ??
'#6b7280'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<div class="ml-4 flex items-center gap-1">
<a
href="/uload/analytics/{link.id}"
class="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm font-medium opacity-60 transition-colors hover:bg-muted hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.links_route.action_analytics_title')}
>
<ChartBar size={16} />
{link.clickCount}
</a>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={$_('uload.links_route.action_copy_title')}
>
<Copy size={16} />
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-muted group-hover:opacity-100 dark:hover:bg-muted"
title={link.isActive
? $_('uload.links_route.action_deactivate_title')
: $_('uload.links_route.action_activate_title')}
>
<Lightning
size={16}
class={link.isActive ? 'text-green-500' : 'text-muted-foreground'}
/>
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-colors hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title={$_('uload.links_route.action_delete_title')}
>
<Trash size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</RoutePage>

View file

@ -1,206 +0,0 @@
<!--
/uload/settings — uLoad data overview, JSON export, clear-local-data.
Reached via the ⚙ button in the uLoad module; not a workbench card.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { Trash, DownloadSimple } from '@mana/shared-icons';
import { linkTable, uloadTagTable, uloadFolderTable, linkTagTable } from '$lib/modules/uload';
import { decryptRecords } from '$lib/data/crypto';
import { useAllLinks, useAllTags, useAllFolders } from '$lib/modules/uload';
import { toast } from 'svelte-sonner';
import { RoutePage } from '$lib/components/shell';
const links = useAllLinks();
const tags = useAllTags();
const folders = useAllFolders();
async function clearAllData() {
if (!confirm($_('uload.settings_route.confirm_clear'))) return;
await linkTable.clear();
await uloadTagTable.clear();
await uloadFolderTable.clear();
await linkTagTable.clear();
toast.success($_('uload.settings_route.toast_cleared'));
}
async function exportData() {
const rawLinks = await linkTable.toArray();
const allLinks = await decryptRecords('links', rawLinks);
const allTags = await uloadTagTable.toArray();
const allFolders = await uloadFolderTable.toArray();
const allLinkTags = await linkTagTable.toArray();
const data = {
exportedAt: new Date().toISOString(),
links: allLinks,
tags: allTags,
folders: allFolders,
linkTags: allLinkTags,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `uload-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success($_('uload.settings_route.toast_exported'));
}
</script>
<svelte:head>
<title>{$_('uload.settings_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload">
<div class="pane">
<header class="bar">
<div class="title">
<strong>{$_('uload.settings_route.heading')}</strong>
<span class="sub">{$_('uload.settings_route.subtitle')}</span>
</div>
</header>
<section class="panel">
<h2>{$_('uload.settings_route.section_data')}</h2>
<div class="stats">
<div class="stat">
<p class="stat-value">{links.value?.length ?? 0}</p>
<p class="stat-label">{$_('uload.settings_route.stat_links')}</p>
</div>
<div class="stat">
<p class="stat-value">{tags.value?.length ?? 0}</p>
<p class="stat-label">{$_('uload.settings_route.stat_tags')}</p>
</div>
<div class="stat">
<p class="stat-value">{folders.value?.length ?? 0}</p>
<p class="stat-label">{$_('uload.settings_route.stat_folders')}</p>
</div>
</div>
</section>
<section class="panel">
<h2>{$_('uload.settings_route.section_export')}</h2>
<p class="hint">{$_('uload.settings_route.export_hint')}</p>
<button type="button" class="btn" onclick={exportData}>
<DownloadSimple size={16} />
{$_('uload.settings_route.action_export')}
</button>
</section>
<section class="panel danger">
<h2>{$_('uload.settings_route.section_danger')}</h2>
<p class="hint">
{$_('uload.settings_route.danger_hint')}
</p>
<button type="button" class="btn danger" onclick={clearAllData}>
<Trash size={16} />
{$_('uload.settings_route.action_clear')}
</button>
</section>
</div>
</RoutePage>
<style>
.pane {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--color-foreground));
}
.bar .title strong {
font-size: 1rem;
font-weight: 600;
}
.bar .sub {
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.panel {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
padding: 1.125rem;
}
.panel.danger {
border-color: hsl(0 70% 55% / 0.3);
background: hsl(0 70% 55% / 0.04);
}
.panel h2 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.panel.danger h2 {
color: hsl(0 70% 55%);
}
.hint {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0 0.875rem;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
text-align: center;
gap: 1rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 8px;
background: hsl(var(--color-background, var(--color-card)));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.btn:hover {
background: hsl(var(--color-muted) / 0.5);
}
.btn.danger {
background: hsl(0 70% 55%);
color: white;
border-color: hsl(0 70% 45%);
}
.btn.danger:hover {
background: hsl(0 70% 50%);
}
</style>

View file

@ -1,182 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { uloadTagTable, useAllTags, useAllLinkTags, slugify } from '$lib/modules/uload';
import type { LocalTag } from '$lib/modules/uload';
import { toast } from 'svelte-sonner';
import { PencilSimple, Trash, ArrowLeft } from '@mana/shared-icons';
import { RoutePage } from '$lib/components/shell';
const tags = useAllTags();
const linkTags = useAllLinkTags();
let showCreateForm = $state(false);
let newName = $state('');
let newColor = $state('#6366f1');
let editingTag = $state<{ id: string; name: string; color?: string } | null>(null);
function getUsageCount(tagId: string): number {
return (tags.value ? linkTags.value : []).filter((lt) => lt.tagId === tagId).length;
}
async function createTag() {
if (!newName.trim()) return;
const created = newName.trim();
await uloadTagTable.add({
id: crypto.randomUUID(),
name: created,
slug: slugify(newName),
color: newColor,
icon: null,
isPublic: false,
usageCount: 0,
} as LocalTag);
toast.success($_('uload.tags_route.toast_created', { values: { name: created } }));
newName = '';
newColor = '#6366f1';
showCreateForm = false;
}
async function deleteTag(tag: { id: string; name: string }) {
await uloadTagTable.delete(tag.id);
toast.success($_('uload.tags_route.toast_deleted', { values: { name: tag.name } }));
}
async function updateTag() {
if (!editingTag) return;
await uloadTagTable.update(editingTag.id, {
name: editingTag.name,
slug: slugify(editingTag.name),
color: editingTag.color,
});
toast.success($_('uload.tags_route.toast_updated'));
editingTag = null;
}
</script>
<RoutePage appId="uload" backHref="/uload">
<div class="mx-auto max-w-4xl p-4">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/uload" class="rounded-lg p-2 transition-colors hover:bg-muted/5">
<ArrowLeft size={20} class="text-muted-foreground" />
</a>
<h1 class="text-2xl font-bold text-white">{$_('uload.tags_route.heading')}</h1>
</div>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
{showCreateForm ? $_('uload.tags_route.hide_form') : $_('uload.tags_route.show_form')}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-xl border border-border/10 bg-muted/5 p-5">
<div class="flex items-end gap-4">
<div class="flex-1">
<label for="tag-name" class="mb-1 block text-sm font-medium text-muted-foreground"
>{$_('uload.tags_route.label_name')}</label
>
<input
id="tag-name"
type="text"
bind:value={newName}
placeholder={$_('uload.tags_route.placeholder_name')}
class="w-full rounded-lg border border-border/10 bg-muted/5 px-4 py-2 text-white placeholder-white/30 focus:border-indigo-500 focus:outline-none"
onkeydown={(e) => e.key === 'Enter' && createTag()}
/>
</div>
<div>
<label for="tag-color" class="mb-1 block text-sm font-medium text-muted-foreground"
>{$_('uload.tags_route.label_color')}</label
>
<input
id="tag-color"
type="color"
bind:value={newColor}
class="h-10 w-16 cursor-pointer rounded-lg border border-border/10"
/>
</div>
<button
onclick={createTag}
disabled={!newName.trim()}
class="rounded-lg bg-indigo-600 px-6 py-2 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
{$_('uload.tags_route.action_create')}
</button>
</div>
</div>
{/if}
{#if !tags.value || tags.value.length === 0}
<div class="rounded-xl border-2 border-dashed border-border/10 p-12 text-center">
<p class="text-lg font-medium text-muted-foreground">
{$_('uload.tags_route.empty_title')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
{$_('uload.tags_route.empty_desc')}
</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each tags.value as tag (tag.id)}
<div
class="group rounded-xl border border-border/10 bg-muted/5 p-4 transition-colors hover:bg-muted/8"
>
{#if editingTag?.id === tag.id}
<div class="space-y-3">
<input
type="text"
bind:value={editingTag.name}
class="w-full rounded border border-border/10 bg-muted/5 px-3 py-1.5 text-sm text-white"
/>
<div class="flex items-center gap-2">
<input type="color" bind:value={editingTag.color} class="h-8 w-12 rounded" />
<button
onclick={updateTag}
class="rounded bg-indigo-600 px-3 py-1 text-sm text-white"
>{$_('common.save')}</button
>
<button
onclick={() => (editingTag = null)}
class="rounded border border-border/10 px-3 py-1 text-sm text-muted-foreground"
>{$_('common.cancel')}</button
>
</div>
</div>
{:else}
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
class="inline-block h-4 w-4 rounded-full"
style="background-color: {tag.color}"
></span>
<span class="font-medium text-white">{tag.name}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground"
>{$_('uload.tags_route.links_count', {
values: { count: getUsageCount(tag.id) },
})}</span
>
<button
onclick={() => (editingTag = { id: tag.id, name: tag.name, color: tag.color })}
class="rounded p-1 text-muted-foreground opacity-0 transition-colors hover:bg-muted/10 hover:text-white group-hover:opacity-100"
>
<PencilSimple size={16} />
</button>
<button
onclick={() => deleteTag(tag)}
class="rounded p-1 text-muted-foreground opacity-0 transition-colors hover:bg-red-900/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</RoutePage>

View file

@ -15,7 +15,6 @@ interface ServiceStatus {
const SERVICES = [
{ name: 'Auth', url: process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001' },
{ name: 'Sync', url: process.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3010' },
{ name: 'Uload Server', url: process.env.PUBLIC_ULOAD_SERVER_URL || 'http://localhost:3070' },
{ name: 'Media', url: process.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3011' },
{ name: 'LLM', url: process.env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025' },
{ name: 'Geocoding', url: process.env.PUBLIC_MANA_GEOCODING_URL || 'http://localhost:3018' },

View file

@ -1,128 +0,0 @@
# uLoad — URL Shortener & Link Management
**Live:** https://ulo.ad
## Architecture
uLoad uses a **local-first** architecture with a lightweight Hono/Bun server for redirects and analytics.
```
Browser → IndexedDB (Links, Tags, Folders)
↕ sync
mana-sync → PostgreSQL
Browser → /r/:code → Hono Server → PostgreSQL (redirect + click tracking)
```
## Project Structure
```
apps/uload/
├── apps/
│ ├── web/ # SvelteKit web app (local-first)
│ ├── server/ # Hono/Bun redirect & analytics server
│ └── landing/ # Astro marketing page
├── packages/
│ └── uload-database/ # Shared Drizzle schema
└── package.json
```
## Tech Stack
| Layer | Technology |
|-------|-----------|
| **Web** | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
| **Server** | Hono + Bun |
| **Data** | Local-first (Dexie.js + mana-sync) |
| **Database** | PostgreSQL via Drizzle ORM |
| **Auth** | mana-auth (Better Auth + EdDSA JWT) |
| **Landing** | Astro 5 |
| **PWA** | @vite-pwa/sveltekit |
| **i18n** | svelte-i18n (DE/EN) |
## Commands
```bash
# Development
pnpm dev:uload:web # SvelteKit dev server
pnpm dev:uload:server # Hono/Bun server (port 3070)
pnpm dev:uload:landing # Landing page
pnpm dev:uload:local # Web + Sync + Server (no auth)
pnpm dev:uload:full # Everything incl. auth
# Build & Deploy
pnpm --filter @uload/web build
pnpm --filter @uload/landing build
pnpm deploy:landing:uload # Deploy landing to Cloudflare Pages
# Type Check
pnpm --filter @uload/web check
pnpm --filter @uload/server type-check
pnpm --filter @mana/uload-database type-check
```
## Ports
| Service | Dev Port | Prod Port |
|---------|----------|-----------|
| Web | 5173 | 5029 |
| Server | 3070 | 3041 |
| Landing | 4321 | Cloudflare Pages |
## Hono Server Routes
| Route | Auth | Description |
|-------|------|-------------|
| `GET /health` | No | Health check |
| `GET /r/:code` | No | Redirect + click tracking |
| `GET /public/u/:username` | No | Public user profile + links |
| `GET /api/v1/analytics/:linkId` | JWT | Click stats |
| `GET /api/v1/analytics/:linkId/timeline` | JWT | Clicks over time |
| `GET /api/v1/analytics/:linkId/devices` | JWT | Device breakdown |
| `GET /api/v1/analytics/:linkId/referrers` | JWT | Top referrers |
| `GET /api/v1/analytics/:linkId/countries` | JWT | Country breakdown |
| `POST /api/v1/stripe/checkout` | JWT | Stripe session (stub) |
| `POST /api/v1/stripe/webhook` | No | Stripe webhook (stub) |
| `POST /api/v1/email/send-invitation` | JWT | Team invite (stub) |
## Local-First Collections
| Collection | Fields |
|-----------|--------|
| `links` | shortCode, originalUrl, title, isActive, clickCount, utmSource/Medium/Campaign, folderId |
| `tags` | name, slug, color, icon, isPublic, usageCount |
| `folders` | name, color, order |
| `linkTags` | linkId, tagId |
## Web App Pages
| Route | Description |
|-------|-------------|
| `/my/links` | Link management (CRUD, QR, UTM, bulk) |
| `/my/tags` | Tag management |
| `/my/analytics/[id]` | Per-link analytics dashboard |
| `/settings` | Account & data settings |
| `/pricing` | Subscription plans (static) |
| `/u/[username]` | Public user profile |
| `/login` | Login (shared-auth-ui) |
| `/register` | Register (shared-auth-ui) |
## Docker
```bash
# Build
./scripts/mac-mini/build-app.sh uload-web
./scripts/mac-mini/build-app.sh uload-server
# Services in docker-compose.macmini.yml:
# - uload-server (port 3041, Bun)
# - uload-web (port 5029, Node)
```
## Key Patterns
- **Svelte 5 Runes**: Use `$state`, `$derived`, `$effect` — never `$:`
- **Local-first**: All CRUD via `linkCollection.insert/update/delete` (IndexedDB)
- **Analytics**: Fetched from Hono server, not local (server-only click data)
- **Auth**: `authStore` from `@mana/shared-auth-ui`, `AuthGate` with guest mode
- **Sync**: Starts on login via `uloadStore.startSync()`, stops on logout

View file

@ -1,151 +0,0 @@
# uLoad - URL Shortener & Link Management
A modern URL shortener and link management platform built with SvelteKit and PocketBase.
## 🚀 Production
**Live:** https://ulo.ad
**Admin:** https://ulo.ad/_/
## 🛠 Tech Stack
- **Frontend:** SvelteKit 2.0 + Svelte 5
- **Backend:** PocketBase (embedded)
- **Styling:** Tailwind CSS 4.0
- **Deployment:** Docker + Coolify on Hetzner VPS
- **Database:** SQLite (via PocketBase)
## 📦 Features
- URL shortening with custom codes
- QR code generation
- Click analytics
- User profiles (e.g., ulo.ad/p/username)
- Link management dashboard
- Real-time statistics
## 🏃 Development
```bash
# Install dependencies
npm install --legacy-peer-deps
# Start development server
npm run dev
# Start with PocketBase backend
npm run dev:all
# Run tests
npm run test
# Type checking
npm run check
```
## 🐳 Docker Deployment
```bash
# Build and run locally
docker-compose up --build
# Access at:
# Frontend: http://localhost:3000
# PocketBase: http://localhost:8090
```
## 📝 Documentation
- [Deployment Guide](./DEPLOYMENT.md) - Complete Docker Compose deployment instructions
- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration
## 🔧 Environment Variables
```bash
NODE_ENV=production
PORT=3000
ORIGIN=https://ulo.ad
PUBLIC_POCKETBASE_URL=https://ulo.ad/api
POCKETBASE_ADMIN_EMAIL=admin@example.com
POCKETBASE_ADMIN_PASSWORD=secure_password
```
See `.env.example` for all configuration options.
## 📂 Project Structure
```
uload/
├── src/ # SvelteKit application
│ ├── routes/ # Pages and API routes
│ ├── lib/ # Components and utilities
│ └── app.html # HTML template
├── backend/ # PocketBase configuration
│ ├── pb_schema.json # Database schema
│ └── init-pocketbase.sh # Setup script
├── build/ # Production build output
├── static/ # Static assets
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Local development
├── supervisord.conf # Process management
└── CLAUDE.md # AI assistant context
```
## 🚢 Deployment
The application is deployed on Hetzner VPS using Coolify with automatic deployments on push to main branch.
```bash
# Commit and push to deploy
git add .
git commit -m "Update"
git push origin main
# Coolify automatically deploys
```
### Manual Deployment Steps:
1. Set DNS A record to `91.99.221.179`
2. Add domain in Coolify
3. Update environment variables
4. Enable SSL certificate
5. Deploy application
## 📊 Monitoring
- **Health Check:** https://ulo.ad/health
- **Admin Panel:** https://ulo.ad/_/
- **Server:** Hetzner CX21 (2 vCPU, 4GB RAM)
- **Uptime:** 99.9% SLA
## 🔐 Security
- HTTPS enforced
- Environment-based configuration
- Secure admin authentication
- Rate limiting on API endpoints
- Regular security updates
## 🤝 Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 🐛 Troubleshooting
Common issues and solutions are documented in [DEPLOYMENT_LESSONS_LEARNED.md](./DEPLOYMENT_LESSONS_LEARNED.md)
For support, check:
- Application logs in Coolify
- Health endpoint status
- PocketBase admin panel
## 📄 License
Private - Memoro AI © 2024

View file

@ -1,16 +0,0 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://ulo.ad',
integrations: [tailwind(), mdx(), sitemap()],
i18n: {
defaultLocale: 'de',
locales: ['de', 'en'],
routing: {
prefixDefaultLocale: false,
},
},
});

View file

@ -1,25 +0,0 @@
{
"name": "@uload/landing",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.0.8",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^6.0.2",
"@mana/shared-landing-ui": "workspace:*",
"astro": "^5.1.1",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View file

@ -1,114 +0,0 @@
---
const currentYear = new Date().getFullYear();
const footerLinks = {
produkt: [
{ href: '/features', label: 'Features' },
{ href: '/#pricing', label: 'Preise' },
{ href: '/blog', label: 'Blog' },
],
unternehmen: [{ href: '/about', label: 'Über uns' }],
rechtliches: [
{ href: '/datenschutz', label: 'Datenschutz' },
{ href: '/impressum', label: 'Impressum' },
{ href: '/agb', label: 'AGB' },
{ href: '/sicherheit', label: 'Sicherheit' },
],
};
const appUrl = 'https://app.ulo.ad';
---
<footer class="bg-gray-900 text-gray-300">
<div class="container-custom py-12 md:py-16">
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-2 md:col-span-1">
<a href="/" class="flex items-center gap-2 mb-4">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-lg">u</span>
</div>
<span class="text-xl font-bold text-white">uLoad</span>
</a>
<p class="text-sm text-gray-400 mb-4">
Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und
analysieren Sie Klicks.
</p>
</div>
<!-- Produkt -->
<div>
<h3 class="text-white font-semibold mb-4">Produkt</h3>
<ul class="space-y-2">
{
footerLinks.produkt.map((link) => (
<li>
<a
href={link.href}
class="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Unternehmen -->
<div>
<h3 class="text-white font-semibold mb-4">Unternehmen</h3>
<ul class="space-y-2">
{
footerLinks.unternehmen.map((link) => (
<li>
<a
href={link.href}
class="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Rechtliches -->
<div>
<h3 class="text-white font-semibold mb-4">Rechtliches</h3>
<ul class="space-y-2">
{
footerLinks.rechtliches.map((link) => (
<li>
<a
href={link.href}
class="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom -->
<div
class="border-t border-gray-800 mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4"
>
<p class="text-sm text-gray-400">
© {currentYear} uLoad. Alle Rechte vorbehalten.
</p>
<div class="flex items-center gap-4">
<a
href={`${appUrl}/login`}
class="text-sm text-gray-400 hover:text-white transition-colors"
>
App öffnen
</a>
</div>
</div>
</div>
</footer>

View file

@ -1,195 +0,0 @@
---
const appUrl = 'https://app.ulo.ad';
---
<section
class="relative overflow-hidden bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
>
<!-- Background decoration -->
<div class="absolute inset-0 -z-10">
<div
class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-primary-500/10 blur-3xl"
>
</div>
<div
class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl"
>
</div>
</div>
<div class="mx-auto max-w-7xl">
<div class="text-center">
<!-- Trust badges -->
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-gray-500">
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
></path>
</svg>
DSGVO-konform
</span>
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
Blitzschnell
</span>
<span class="flex items-center gap-1">
<svg
class="h-4 w-4 text-purple-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
100% Sicher
</span>
</div>
<!-- Main headline -->
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
More than links.
<span class="bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
Your digital identity.
</span>
</h1>
<p class="mx-auto mb-8 max-w-2xl text-lg text-gray-600 sm:text-xl">
Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
beeindruckende Profilkarten und manage alles im Team.
</p>
<!-- CTA Buttons -->
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
<a
href={`${appUrl}/register`}
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700 hover:shadow-xl"
>
Kostenlos starten →
</a>
<a
href="#features"
class="rounded-lg border-2 border-gray-200 bg-white px-8 py-3 font-semibold text-gray-900 transition hover:border-primary-500 hover:shadow-lg"
>
Features entdecken
</a>
</div>
<!-- Shortener teaser -->
<div class="mx-auto max-w-2xl">
<div
class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white/80 p-4 backdrop-blur sm:flex-row sm:p-2"
>
<input
type="url"
placeholder="Deine lange URL hier einfügen..."
disabled
class="flex-1 rounded-lg border-0 bg-transparent px-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none sm:py-2"
/>
<a
href={`${appUrl}/register`}
class="rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition hover:bg-primary-700 sm:py-2 text-center"
>
Kürzen →
</a>
</div>
<p class="mt-2 text-sm text-gray-500">
Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
</p>
</div>
</div>
<!-- Visual preview -->
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
<!-- Link shortening preview -->
<div
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
<svg
class="h-6 w-6 text-primary-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
></path>
</svg>
</div>
<h3 class="mb-2 font-semibold text-gray-900">Smart Links</h3>
<p class="text-sm text-gray-600">Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz</p>
<a
href="/features"
class="mt-4 inline-block text-xs text-primary-600 group-hover:underline"
>
Mehr erfahren →
</a>
</div>
<!-- Profile cards preview -->
<div
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
></path>
</svg>
</div>
<h3 class="mb-2 font-semibold text-gray-900">Profile Cards</h3>
<p class="text-sm text-gray-600">Beeindruckende Profilseiten mit Drag & Drop Builder</p>
<a href="/features" class="mt-4 inline-block text-xs text-purple-600 group-hover:underline">
Templates ansehen →
</a>
</div>
<!-- Team collaboration preview -->
<div
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</div>
<h3 class="mb-2 font-semibold text-gray-900">Team Workspace</h3>
<p class="text-sm text-gray-600">Gemeinsam Links verwalten mit granularen Berechtigungen</p>
<a href="/features" class="mt-4 inline-block text-xs text-green-600 group-hover:underline">
Für Teams →
</a>
</div>
</div>
</div>
</section>

View file

@ -1,86 +0,0 @@
---
const navLinks = [
{ href: '/features', label: 'Features' },
{ href: '/blog', label: 'Blog' },
{ href: '/about', label: 'Über uns' },
];
const appUrl = 'https://app.ulo.ad';
---
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<nav class="container-custom">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-lg">u</span>
</div>
<span class="text-xl font-bold text-gray-900">uLoad</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
{link.label}
</a>
))
}
</div>
<!-- CTA Buttons -->
<div class="hidden md:flex items-center gap-4">
<a href={`${appUrl}/login`} class="text-gray-600 hover:text-gray-900 font-medium">
Anmelden
</a>
<a href={`${appUrl}/register`} class="btn-primary"> Kostenlos starten </a>
</div>
<!-- Mobile Menu Button -->
<button
id="mobile-menu-btn"
class="md:hidden p-2 text-gray-600 hover:text-gray-900"
aria-label="Menü öffnen"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden md:hidden pb-4">
<div class="flex flex-col gap-4">
{
navLinks.map((link) => (
<a href={link.href} class="text-gray-600 hover:text-gray-900 font-medium py-2">
{link.label}
</a>
))
}
<div class="flex flex-col gap-2 pt-4 border-t border-gray-100">
<a href={`${appUrl}/login`} class="btn-secondary text-center"> Anmelden </a>
<a href={`${appUrl}/register`} class="btn-primary text-center"> Kostenlos starten </a>
</div>
</div>
</div>
</nav>
</header>
<script>
const menuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
menuBtn?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
</script>

View file

@ -1,92 +0,0 @@
---
title: Der ultimative Link-Tracking Guide für 2024
description: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
pubDate: 2024-01-20
author: Till Schneider
tags: [tracking, analytics, dsgvo, marketing]
---
Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern.
## Was ist Link-Tracking?
Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
- Woher kommen Ihre Besucher?
- Welche Kampagnen funktionieren?
- Wie hoch ist Ihre Conversion-Rate?
- Welche Inhalte performen am besten?
## Die wichtigsten Metriken
### 1. Click-Through-Rate (CTR)
Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
### 2. Conversion Rate
Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen.
### 3. Bounce Rate
Wie viele Nutzer verlassen Ihre Seite sofort wieder?
### 4. Geographic Distribution
Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
## UTM-Parameter richtig einsetzen
UTM-Parameter sind der Standard für Campaign-Tracking:
```
https://ulo.ad/angebot
?utm_source=newsletter
&utm_medium=email
&utm_campaign=winter-sale
```
### Die 5 UTM-Parameter
1. **utm_source**: Woher kommt der Traffic?
2. **utm_medium**: Welches Medium?
3. **utm_campaign**: Welche Kampagne?
4. **utm_content**: Welcher spezifische Link?
5. **utm_term**: Welches Keyword?
## DSGVO-konformes Tracking
### Was ist erlaubt?
✅ **Anonymisierte Daten**
- Gerätetyp
- Browser
- Ungefährer Standort
- Referrer
### Was braucht Zustimmung?
❌ **Personenbezogene Daten**
- Vollständige IP-Adressen
- Device Fingerprinting
- Cross-Site Tracking
## Best Practices für Link-Tracking
### 1. Konsistente Namenskonvention
Entwickeln Sie ein einheitliches Schema für Ihre Kampagnen.
### 2. Dokumentation führen
Erstellen Sie eine Tracking-Tabelle für alle Kampagnen.
### 3. Regelmäßige Bereinigung
Löschen Sie alte, inaktive Links regelmäßig.
## Fazit
Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern.

View file

@ -1,76 +0,0 @@
---
title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
description: 42% weniger Klicks bei langen URLs diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter.
pubDate: 2024-01-15
author: Till Schneider
tags: [urls, psychology, conversion, marketing]
---
**42% weniger Klicks bei langen URLs** diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können.
## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen
Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance.
### Die Spam-Alarm-Reaktion unseres Gehirns
Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen.
Vergleichen Sie diese beiden URLs:
**Lange URL (schlecht):**
```
https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024
```
**Kurze URL (gut):**
```
https://ulo.ad/summer-sale
```
### Mobile Nutzer: Die vergessene Mehrheit
In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen.
## Die Wissenschaft dahinter: Cognitive Load Theory
Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
## Die vier Säulen des Link-Vertrauens
1. **Erkennbare Domain (60% Wichtigkeit)** - Menschen wollen wissen, wo sie landen werden
2. **Keine kryptischen Zeichen (25% Wichtigkeit)** - Zufällige Zahlen-Buchstaben-Kombinationen schrecken ab
3. **Optimale Länge (10% Wichtigkeit)** - Die magische Grenze liegt bei etwa 50 Zeichen
4. **HTTPS-Verschlüsselung (5% Wichtigkeit)** - Ein Hygienefaktor
## Praktische Optimierungsstrategien
### 1. Sprechende URLs verwenden
**Schlecht:** `ulo.ad/p47829`
**Gut:** `ulo.ad/sommer-sale`
### 2. Die 50-Zeichen-Regel
Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
- Kurz genug für Twitter/X
- Lesbar auf Mobilgeräten
- Merkbar für Nutzer
### 3. A/B-Testing ist Ihr Freund
Testen Sie verschiedene URL-Varianten und messen Sie die Performance.
## Fazit: Die Macht der Kürze
Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen.
### Die wichtigsten Takeaways
1. **42% weniger Klicks** bei URLs über 100 Zeichen
2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit
3. **50 Zeichen** ist die magische Grenze
4. **Sprechende URLs** performen 39% besser

View file

@ -1,17 +0,0 @@
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string().optional(),
image: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = {
blog: blogCollection,
};

View file

@ -1,2 +0,0 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -1,59 +0,0 @@
---
import '../styles/global.css';
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
interface Props {
title: string;
description?: string;
ogImage?: string;
}
const {
title,
description = 'uLoad - Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.',
ogImage = '/og-image.png',
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={canonicalURL} />
<title>{title} | uLoad</title>
<meta name="description" content={description} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(ogImage, Astro.site)} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="min-h-screen flex flex-col">
<Navigation />
<main class="flex-grow">
<slot />
</main>
<Footer />
</body>
</html>

View file

@ -1,28 +0,0 @@
---
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
description?: string;
lastUpdated?: string;
}
const { title, description, lastUpdated } = Astro.props;
---
<BaseLayout title={title} description={description}>
<article class="px-4 py-16 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl">
<header class="mb-12">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 mb-4">
{title}
</h1>
{lastUpdated && <p class="text-gray-500">Zuletzt aktualisiert: {lastUpdated}</p>}
</header>
<div class="prose prose-lg prose-gray max-w-none">
<slot />
</div>
</div>
</article>
</BaseLayout>

View file

@ -1,130 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
const stats = [
{ value: '10K+', label: 'Aktive Nutzer' },
{ value: '500K+', label: 'Erstellte Links' },
{ value: '2M+', label: 'Klicks verfolgt' },
{ value: '99.9%', label: 'Uptime' },
];
const values = [
{
icon: '🎯',
title: 'Einfachheit',
description:
'Wir glauben, dass professionelle Tools nicht kompliziert sein müssen. uLoad ist intuitiv und sofort einsatzbereit.',
},
{
icon: '🔒',
title: 'Datenschutz',
description:
'Ihre Daten gehören Ihnen. Wir sind DSGVO-konform und speichern nur was wirklich notwendig ist.',
},
{
icon: '⚡',
title: 'Performance',
description:
'Schnelle Links bedeuten bessere Nutzererfahrung. Unsere Infrastruktur ist auf Geschwindigkeit optimiert.',
},
{
icon: '💪',
title: 'Zuverlässigkeit',
description:
'Mit 99.9% Uptime können Sie sich auf uLoad verlassen - für jede Kampagne, jedes Projekt.',
},
];
---
<BaseLayout
title="Über uns"
description="Erfahren Sie mehr über uLoad - den intelligenten URL-Shortener für Profis."
>
<!-- Hero -->
<section
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
>
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Links die verbinden
</h1>
<p class="mx-auto max-w-2xl text-lg text-gray-600">
uLoad wurde entwickelt um Link-Management einfach, sicher und effektiv zu machen. Für
Einzelpersonen, Teams und Unternehmen.
</p>
</div>
</div>
</section>
<!-- Stats -->
<section class="bg-primary-600 px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl">
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
{
stats.map((stat) => (
<div class="text-center">
<div class="text-4xl font-bold text-white">{stat.value}</div>
<div class="mt-1 text-primary-100">{stat.label}</div>
</div>
))
}
</div>
</div>
</section>
<!-- Story -->
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-3xl">
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Unsere Geschichte</h2>
<div class="prose prose-lg mx-auto text-gray-600">
<p>
uLoad entstand aus einer einfachen Frustration: Bestehende URL-Shortener waren entweder zu
kompliziert, zu teuer oder boten nicht die Features die moderne Teams brauchen.
</p>
<p>
Wir wollten einen Service schaffen, der sowohl für Einsteiger als auch für Power-User
funktioniert. Ein Tool das mit Ihren Anforderungen wächst - von der ersten verkürzten URL
bis zum Enterprise-Einsatz.
</p>
<p>
Heute nutzen tausende Nutzer uLoad täglich für ihre Marketing-Kampagnen,
Social-Media-Posts und geschäftliche Kommunikation. Und wir arbeiten jeden Tag daran,
uLoad noch besser zu machen.
</p>
</div>
</div>
</section>
<!-- Values -->
<section class="bg-gray-50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">Unsere Werte</h2>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{
values.map((value) => (
<div class="rounded-xl bg-white p-6 shadow-sm">
<div class="mb-4 text-4xl">{value.icon}</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900">{value.title}</h3>
<p class="text-sm text-gray-600">{value.description}</p>
</div>
))
}
</div>
</div>
</section>
<!-- CTA -->
<section class="px-4 py-16 sm:px-6 lg:px-8">
<div class="mx-auto max-w-4xl text-center">
<h2 class="mb-4 text-3xl font-bold text-gray-900">Werden Sie Teil der uLoad Community</h2>
<p class="mb-8 text-lg text-gray-600">Schließen Sie sich tausenden zufriedenen Nutzern an.</p>
<a
href="https://app.ulo.ad/register"
class="inline-block rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
>
Jetzt kostenlos starten →
</a>
</div>
</section>
</BaseLayout>

View file

@ -1,76 +0,0 @@
---
import LegalLayout from '../layouts/LegalLayout.astro';
---
<LegalLayout title="Allgemeine Geschäftsbedingungen" lastUpdated="Januar 2024">
<h2>§ 1 Geltungsbereich</h2>
<p>
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Verträge zwischen uLoad und dem
Nutzer über die Nutzung der auf der Website ulo.ad angebotenen Dienste.
</p>
<h2>§ 2 Leistungsbeschreibung</h2>
<p>
uLoad bietet einen URL-Verkürzungsdienst sowie ergänzende Dienste wie Analytics,
QR-Code-Generierung und Team-Workspaces an. Der genaue Leistungsumfang ergibt sich aus der
jeweiligen Produktbeschreibung zum Zeitpunkt der Bestellung.
</p>
<h2>§ 3 Registrierung und Nutzerkonto</h2>
<p>
Für die Nutzung bestimmter Funktionen ist eine Registrierung erforderlich. Der Nutzer
verpflichtet sich, wahrheitsgemäße Angaben zu machen und diese aktuell zu halten. Der Nutzer ist
für die Geheimhaltung seiner Zugangsdaten verantwortlich.
</p>
<h2>§ 4 Nutzungsregeln</h2>
<p>
Der Nutzer verpflichtet sich, den Dienst nicht für rechtswidrige Zwecke zu nutzen. Insbesondere
ist es untersagt:
</p>
<ul>
<li>Links zu illegalen Inhalten zu erstellen</li>
<li>Spam oder Phishing-Links zu verbreiten</li>
<li>Die Dienste für automatisierte Massenanfragen zu missbrauchen</li>
<li>Andere Nutzer zu belästigen oder zu täuschen</li>
</ul>
<h2>§ 5 Preise und Zahlung</h2>
<p>
Die Nutzung der Basisfunktionen ist kostenlos. Für erweiterte Funktionen können kostenpflichtige
Abonnements abgeschlossen werden. Alle Preise verstehen sich inklusive der gesetzlichen
Mehrwertsteuer.
</p>
<h2>§ 6 Kündigung</h2>
<p>
Kostenlose Konten können jederzeit gelöscht werden. Kostenpflichtige Abonnements können zum Ende
der jeweiligen Abrechnungsperiode gekündigt werden.
</p>
<h2>§ 7 Haftung</h2>
<p>
uLoad haftet nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen.
Die Haftung für leichte Fahrlässigkeit ist ausgeschlossen, soweit nicht wesentliche
Vertragspflichten verletzt wurden.
</p>
<h2>§ 8 Datenschutz</h2>
<p>
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung und den
geltenden Datenschutzgesetzen.
</p>
<h2>§ 9 Änderungen der AGB</h2>
<p>
uLoad behält sich vor, diese AGB jederzeit zu ändern. Änderungen werden dem Nutzer rechtzeitig
mitgeteilt. Mit der weiteren Nutzung des Dienstes nach Inkrafttreten der Änderungen erklärt sich
der Nutzer mit diesen einverstanden.
</p>
<h2>§ 10 Schlussbestimmungen</h2>
<p>
Es gilt das Recht der Bundesrepublik Deutschland. Sollten einzelne Bestimmungen dieser AGB
unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.
</p>
</LegalLayout>

View file

@ -1,95 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
type Props = { post: CollectionEntry<'blog'> };
const { post } = Astro.props;
const { Content } = await post.render();
function formatDate(date: Date): string {
return new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article class="px-4 py-16 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl">
<!-- Header -->
<header class="mb-12">
<a
href="/blog"
class="inline-flex items-center gap-2 text-sm text-primary-600 hover:underline mb-6"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"></path>
</svg>
Zurück zum Blog
</a>
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl mb-4">
{post.data.title}
</h1>
<div class="flex items-center gap-4 text-gray-500">
<time datetime={post.data.pubDate.toISOString()}>
{formatDate(post.data.pubDate)}
</time>
{
post.data.author && (
<>
<span>•</span>
<span>{post.data.author}</span>
</>
)
}
</div>
{
post.data.tags && (
<div class="mt-4 flex flex-wrap gap-2">
{post.data.tags.map((tag) => (
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
{tag}
</span>
))}
</div>
)
}
</header>
<!-- Content -->
<div
class="prose prose-lg prose-gray max-w-none prose-headings:font-bold prose-a:text-primary-600 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
>
<Content />
</div>
<!-- Footer -->
<footer class="mt-16 pt-8 border-t border-gray-200">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<a href="/blog" class="text-primary-600 hover:underline"> ← Alle Artikel </a>
<a
href="https://app.ulo.ad/register"
class="inline-block rounded-lg bg-primary-600 px-6 py-2 font-medium text-white transition hover:bg-primary-700"
>
Jetzt uLoad testen
</a>
</div>
</footer>
</div>
</article>
</BaseLayout>

View file

@ -1,69 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
function formatDate(date: Date): string {
return new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
---
<BaseLayout
title="Blog"
description="Tipps, Tricks und Best Practices rund um Link-Management, URL-Verkürzung und digitales Marketing."
>
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center mb-16">
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">Blog</h1>
<p class="mx-auto max-w-2xl text-lg text-gray-600">
Tipps, Tricks und Best Practices rund um Link-Management und digitales Marketing.
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{
posts.map((post) => (
<article class="group rounded-xl border border-gray-200 bg-white overflow-hidden transition hover:shadow-xl">
<a href={`/blog/${post.slug}`} class="block">
<div class="p-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
<time datetime={post.data.pubDate.toISOString()}>
{formatDate(post.data.pubDate)}
</time>
{post.data.author && (
<>
<span>•</span>
<span>{post.data.author}</span>
</>
)}
</div>
<h2 class="text-xl font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
{post.data.title}
</h2>
<p class="text-gray-600 line-clamp-3">{post.data.description}</p>
{post.data.tags && (
<div class="mt-4 flex flex-wrap gap-2">
{post.data.tags.slice(0, 3).map((tag) => (
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600">
{tag}
</span>
))}
</div>
)}
</div>
</a>
</article>
))
}
</div>
</div>
</section>
</BaseLayout>

View file

@ -1,91 +0,0 @@
---
import LegalLayout from '../layouts/LegalLayout.astro';
---
<LegalLayout title="Datenschutzerklärung" lastUpdated="Januar 2024">
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit
denen Sie persönlich identifiziert werden können.
</p>
<h3>Datenerfassung auf dieser Website</h3>
<p>
<strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong><br />
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten
können Sie dem Impressum dieser Website entnehmen.
</p>
<h3>Wie erfassen wir Ihre Daten?</h3>
<p>
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich
z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
</p>
<p>
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das
sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des
Seitenaufrufs).
</p>
<h2>2. Hosting</h2>
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
<p>
Die Server befinden sich in Deutschland und unterliegen den strengen deutschen
Datenschutzgesetzen.
</p>
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln
Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
Datenschutzvorschriften sowie dieser Datenschutzerklärung.
</p>
<h3>Hinweis zur verantwortlichen Stelle</h3>
<p>
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist im Impressum
genannt.
</p>
<h2>4. Datenerfassung auf dieser Website</h2>
<h3>Cookies</h3>
<p>
Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Datenpakete und
richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer
einer Sitzung (Session-Cookies) oder dauerhaft (permanente Cookies) auf Ihrem Endgerät
gespeichert.
</p>
<h3>Server-Log-Dateien</h3>
<p>
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
</p>
<ul>
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse (anonymisiert)</li>
</ul>
<h2>5. Ihre Rechte</h2>
<p>
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer
gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die
Berichtigung oder Löschung dieser Daten zu verlangen.
</p>
<h2>6. Kontakt</h2>
<p>
Bei Fragen zum Datenschutz können Sie sich jederzeit an uns wenden. Die Kontaktdaten finden Sie
im Impressum.
</p>
</LegalLayout>

View file

@ -1,169 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
const appUrl = 'https://app.ulo.ad';
const featureCategories = [
{
title: 'Link Management',
features: [
{
icon: '🔗',
title: 'URL-Verkürzung',
description:
'Verwandeln Sie lange URLs in kurze, merkbare Links. Perfekt für Social Media, E-Mails und gedruckte Materialien.',
},
{
icon: '✏️',
title: 'Custom Short Codes',
description:
'Erstellen Sie personalisierte Kurz-URLs wie ulo.ad/mein-link für bessere Wiedererkennung.',
},
{
icon: '📅',
title: 'Ablaufdatum',
description:
'Setzen Sie automatische Ablaufdaten für zeitlich begrenzte Aktionen und Kampagnen.',
},
{
icon: '🔒',
title: 'Passwortschutz',
description: 'Schützen Sie sensible Links mit Passwörtern für zusätzliche Sicherheit.',
},
],
},
{
title: 'Analytics & Tracking',
features: [
{
icon: '📊',
title: 'Klick-Tracking',
description: 'Verfolgen Sie jeden Klick in Echtzeit mit detaillierten Statistiken.',
},
{
icon: '🌍',
title: 'Geografische Daten',
description: 'Sehen Sie woher Ihre Besucher kommen mit Länder- und Städte-Aufschlüsselung.',
},
{
icon: '📱',
title: 'Geräte-Analyse',
description:
'Erfahren Sie welche Geräte, Browser und Betriebssysteme Ihre Nutzer verwenden.',
},
{
icon: '📈',
title: 'Referrer-Tracking',
description:
'Identifizieren Sie die Quellen Ihres Traffics für bessere Marketing-Entscheidungen.',
},
],
},
{
title: 'QR-Codes',
features: [
{
icon: '🎨',
title: 'Anpassbare Designs',
description: 'Erstellen Sie QR-Codes in Ihren Markenfarben für konsistentes Branding.',
},
{
icon: '📐',
title: 'Multiple Formate',
description: 'Download in PNG, SVG oder PDF für verschiedene Anwendungsfälle.',
},
{
icon: '⬇️',
title: 'Hochauflösend',
description: 'Druckqualität bis zu 4000x4000 Pixel für großformatige Medien.',
},
],
},
{
title: 'Team & Kollaboration',
features: [
{
icon: '👥',
title: 'Team Workspaces',
description: 'Erstellen Sie gemeinsame Arbeitsbereiche für Ihr Team oder Ihre Kunden.',
},
{
icon: '🔐',
title: 'Rollenbasierte Rechte',
description: 'Definieren Sie wer Links erstellen, bearbeiten oder nur ansehen darf.',
},
{
icon: '🏷️',
title: 'Tag-System',
description: 'Organisieren Sie Links mit Tags für bessere Übersicht in großen Teams.',
},
],
},
];
---
<BaseLayout
title="Features"
description="Entdecken Sie alle Features von uLoad - URL-Verkürzung, Analytics, QR-Codes und Team-Kollaboration."
>
<!-- Hero -->
<section
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
>
<div class="mx-auto max-w-7xl text-center">
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Features die den Unterschied machen
</h1>
<p class="mx-auto max-w-2xl text-lg text-gray-600">
Von einfacher URL-Verkürzung bis hin zu detaillierten Analytics uLoad bietet alles was
Profis brauchen.
</p>
<div class="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
<a
href={`${appUrl}/register`}
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
>
Kostenlos starten
</a>
</div>
</div>
</section>
<!-- Feature Categories -->
{
featureCategories.map((category, idx) => (
<section
class:list={['px-4 py-16 sm:px-6 lg:px-8', idx % 2 === 1 ? 'bg-gray-50' : 'bg-white']}
>
<div class="mx-auto max-w-7xl">
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">{category.title}</h2>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{category.features.map((feature) => (
<div class="rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-lg">
<div class="mb-4 text-4xl">{feature.icon}</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900">{feature.title}</h3>
<p class="text-sm text-gray-600">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
))
}
<!-- CTA -->
<section class="bg-primary-600 px-4 py-16 sm:px-6 lg:px-8">
<div class="mx-auto max-w-4xl text-center">
<h2 class="mb-4 text-3xl font-bold text-white">Bereit loszulegen?</h2>
<p class="mb-8 text-lg text-primary-100">
Starten Sie kostenlos und entdecken Sie alle Features selbst.
</p>
<a
href={`${appUrl}/register`}
class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition hover:bg-gray-100"
>
Jetzt kostenlos starten →
</a>
</div>
</section>
</BaseLayout>

View file

@ -1,63 +0,0 @@
---
import LegalLayout from '../layouts/LegalLayout.astro';
---
<LegalLayout title="Impressum">
<h2>Angaben gemäß § 5 TMG</h2>
<p>
<strong>uLoad</strong><br />
[Ihr Name / Firmenname]<br />
[Straße und Hausnummer]<br />
[PLZ Ort]<br />
Deutschland
</p>
<h2>Kontakt</h2>
<p>E-Mail: kontakt@ulo.ad</p>
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
<p>
[Ihr Name]<br />
[Adresse wie oben]
</p>
<h2>EU-Streitschlichtung</h2>
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener"
>https://ec.europa.eu/consumers/odr/</a
>
</p>
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
<h2>Haftung für Inhalte</h2>
<p>
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch
nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
<h2>Haftung für Links</h2>
<p>
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss
haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die
Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
verantwortlich.
</p>
<h2>Urheberrecht</h2>
<p>
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
jeweiligen Autors bzw. Erstellers.
</p>
</LegalLayout>

View file

@ -1,235 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HeroSection from '../components/HeroSection.astro';
// Shared components
import FeatureSection from '@mana/shared-landing-ui/sections/FeatureSection.astro';
import StepsSection from '@mana/shared-landing-ui/sections/StepsSection.astro';
import FAQSection from '@mana/shared-landing-ui/sections/FAQSection.astro';
import CTASection from '@mana/shared-landing-ui/sections/CTASection.astro';
import PricingSection from '@mana/shared-landing-ui/sections/PricingSection.astro';
const appUrl = 'https://app.ulo.ad';
// Feature data
const features = [
{
icon: '🔗',
title: 'Smart Links',
description:
'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.',
},
{
icon: '📊',
title: 'Detaillierte Analytics',
description:
'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.',
},
{
icon: '🎨',
title: 'QR-Code Generator',
description:
'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.',
},
{
icon: '💳',
title: 'Profile Cards',
description:
'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.',
},
{
icon: '👥',
title: 'Team Workspaces',
description:
'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.',
},
{
icon: '🔌',
title: 'API & Integrationen',
description:
'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.',
},
];
// Steps data
const steps = [
{
number: '1',
title: 'Link einfügen',
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
image: '/screenshots/paste.png',
},
{
number: '2',
title: 'Anpassen',
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
image: '/screenshots/customize.png',
},
{
number: '3',
title: 'Teilen & Tracken',
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
image: '/screenshots/share.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '10 Links pro Monat', included: true },
{ text: 'Basis Analytics', included: true },
{ text: 'QR-Code Generator', included: true },
{ text: 'Link Anpassung', included: true },
{ text: 'Unbegrenzte Links', included: false },
{ text: 'Team Features', included: false },
],
cta: {
text: 'Kostenlos starten',
href: `${appUrl}/register`,
},
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Für Freelancer & Creators',
features: [
{ text: 'Unbegrenzte Links', included: true },
{ text: 'Erweiterte Analytics', included: true },
{ text: 'Custom QR Codes', included: true },
{ text: 'API Zugang', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Passwortschutz', included: true },
],
cta: {
text: 'Pro wählen',
href: `${appUrl}/register?plan=pro`,
},
},
{
name: 'Pro Jährlich',
price: '3,33',
period: '/Monat',
description: 'Spare 20€ pro Jahr',
features: [
{ text: 'Alle Pro Features', included: true },
{ text: 'Unbegrenzte Links', included: true },
{ text: 'Erweiterte Analytics', included: true },
{ text: 'Custom QR Codes', included: true },
{ text: 'API Zugang', included: true },
{ text: 'Priority Support', included: true },
],
cta: {
text: 'Jährlich sparen',
href: `${appUrl}/register?plan=pro-yearly`,
},
highlighted: true,
badge: 'Spare 20€',
},
{
name: 'Lifetime',
price: '129,99',
period: 'einmalig',
description: 'Einmal zahlen, für immer nutzen',
features: [
{ text: 'Alle Pro Features', included: true },
{ text: 'Lebenslanger Zugang', included: true },
{ text: 'Alle zukünftigen Features', included: true },
{ text: 'Early Access', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Keine Abo-Gebühren', included: true },
],
cta: {
text: 'Lifetime sichern',
href: `${appUrl}/register?plan=lifetime`,
},
badge: 'Einmalig',
},
];
// FAQ data
const faqs = [
{
question: 'Wie lange bleiben meine Links aktiv?',
answer:
'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.',
},
{
question: 'Kann ich meine eigene Domain verwenden?',
answer:
'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).',
},
{
question: 'Wie funktionieren die Analytics?',
answer:
'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.',
},
{
question: 'Was sind Profile Cards?',
answer:
'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.',
},
{
question: 'Gibt es eine API?',
answer:
'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.',
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer:
'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.',
},
];
---
<BaseLayout title="Intelligenter URL-Shortener">
<HeroSection />
<FeatureSection
id="features"
title="Alles was du für professionelles Link-Management brauchst"
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
features={features}
columns={3}
variant="cards"
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten zum perfekten Link"
subtitle="So einfach funktioniert uLoad"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-gray-50"
/>
<PricingSection
id="pricing"
title="Transparente Preise, keine versteckten Kosten"
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
plans={pricingPlans}
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über uLoad wissen musst"
faqs={faqs}
class="bg-gray-50"
/>
<CTASection
id="cta"
title="Bereit für smarte Links?"
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
variant="default"
/>
</BaseLayout>

View file

@ -1,202 +0,0 @@
---
import LegalLayout from '../layouts/LegalLayout.astro';
---
<LegalLayout title="Sicherheit" lastUpdated="November 2024">
<div class="rounded-lg bg-green-50 p-4 text-green-800 mb-8">
<p class="font-semibold">Ihre Sicherheit ist unsere Priorität</p>
<p class="mt-1">
Bei uload setzen wir modernste Sicherheitsstandards ein, um Ihre Daten und Links zu schützen.
</p>
</div>
<h2>Verschlüsselung</h2>
<h3>SSL/TLS-Verschlüsselung</h3>
<p>
Alle Datenübertragungen zwischen Ihrem Browser und unseren Servern sind durch moderne
SSL/TLS-Verschlüsselung geschützt. Wir verwenden ausschließlich TLS 1.3 und TLS 1.2 mit starken
Cipher-Suites.
</p>
<h3>Verschlüsselte Speicherung</h3>
<p>
Sensible Daten wie Passwörter werden mit branchenführenden Verschlüsselungsalgorithmen (bcrypt
mit Salt) gespeichert. Selbst im unwahrscheinlichen Fall eines Datenlecks bleiben Ihre
Passwörter geschützt.
</p>
<h3>Ende-zu-Ende Verschlüsselung für Premium-Nutzer</h3>
<p>
Premium-Nutzer können optionale Ende-zu-Ende-Verschlüsselung für besonders sensible Links
aktivieren. Diese Links können nur mit dem richtigen Schlüssel entschlüsselt werden.
</p>
<h2>Authentifizierung & Zugriffskontrolle</h2>
<h3>Sichere Authentifizierung</h3>
<ul>
<li>Starke Passwort-Anforderungen (mindestens 8 Zeichen, Groß-/Kleinbuchstaben, Zahlen)</li>
<li>Zwei-Faktor-Authentifizierung (2FA) verfügbar</li>
<li>Automatische Sitzungsbeendigung nach Inaktivität</li>
<li>Schutz vor Brute-Force-Angriffen durch Rate-Limiting</li>
</ul>
<h3>Passwortgeschützte Links</h3>
<p>
Erstellen Sie passwortgeschützte Links für zusätzliche Sicherheit. Nur Personen mit dem
korrekten Passwort können auf die Ziel-URL zugreifen.
</p>
<h3>IP-Whitelisting für Enterprise</h3>
<p>
Enterprise-Kunden können IP-Whitelisting aktivieren, um den Zugriff auf ihre Links nur von
bestimmten IP-Adressen oder IP-Bereichen zu erlauben.
</p>
<h2>Infrastruktur-Sicherheit</h2>
<h3>Hosting & Server</h3>
<ul>
<li>Hosting in ISO 27001 zertifizierten Rechenzentren</li>
<li>Redundante Server-Architektur für maximale Verfügbarkeit</li>
<li>Regelmäßige Sicherheitsupdates und Patches</li>
<li>24/7 Überwachung der Systemintegrität</li>
</ul>
<h3>DDoS-Schutz</h3>
<p>
Unser Service ist durch einen fortschrittlichen DDoS-Schutz abgesichert, der Angriffe
automatisch erkennt und abwehrt, um die Verfügbarkeit unseres Dienstes zu gewährleisten.
</p>
<h3>Web Application Firewall (WAF)</h3>
<p>
Eine Web Application Firewall schützt vor gängigen Web-Angriffen wie SQL-Injection,
Cross-Site-Scripting (XSS) und anderen OWASP Top 10 Bedrohungen.
</p>
<h2>Überwachung & Schutz</h2>
<h3>Malware & Phishing-Schutz</h3>
<p>
Alle erstellten Links werden automatisch gegen bekannte Malware- und Phishing-Datenbanken
geprüft. Verdächtige Links werden blockiert und zur manuellen Überprüfung markiert.
</p>
<h3>Echtzeit-Überwachung</h3>
<ul>
<li>Kontinuierliche Überwachung auf verdächtige Aktivitäten</li>
<li>Automatische Erkennung von Missbrauchsmustern</li>
<li>Sofortige Benachrichtigung bei Sicherheitsvorfällen</li>
<li>Detaillierte Audit-Logs für Enterprise-Kunden</li>
</ul>
<h3>Link-Validierung</h3>
<p>
Regelmäßige Überprüfung aller Ziel-URLs auf Verfügbarkeit und Sicherheit. Gefährliche oder
kompromittierte Websites werden automatisch blockiert.
</p>
<h2>Datenschutz & Compliance</h2>
<h3>DSGVO-Konformität</h3>
<p>
Vollständige Einhaltung der Datenschutz-Grundverordnung (DSGVO). Sie haben jederzeit die volle
Kontrolle über Ihre Daten mit Rechten auf Auskunft, Berichtigung und Löschung.
</p>
<h3>Datensparsamkeit</h3>
<p>
Wir sammeln nur die minimal notwendigen Daten für den Betrieb unseres Services. Keine unnötige
Datensammlung oder -weitergabe an Dritte.
</p>
<h3>Regelmäßige Audits</h3>
<p>
Unabhängige Sicherheitsaudits und Penetrationstests werden regelmäßig durchgeführt, um höchste
Sicherheitsstandards zu gewährleisten.
</p>
<h2>Backup & Wiederherstellung</h2>
<h3>Automatische Backups</h3>
<ul>
<li>Tägliche automatische Backups aller Daten</li>
<li>Geografisch verteilte Backup-Speicherung</li>
<li>Verschlüsselte Backup-Archive</li>
<li>Regelmäßige Wiederherstellungstests</li>
</ul>
<h3>Disaster Recovery</h3>
<p>
Umfassender Disaster-Recovery-Plan mit RPO (Recovery Point Objective) von maximal 24 Stunden und
RTO (Recovery Time Objective) von maximal 4 Stunden.
</p>
<h2>Ihre Verantwortung</h2>
<h3>Best Practices für Nutzer</h3>
<ul>
<li>Verwenden Sie starke, einzigartige Passwörter</li>
<li>Aktivieren Sie die Zwei-Faktor-Authentifizierung</li>
<li>Teilen Sie Ihre Zugangsdaten niemals mit anderen</li>
<li>Melden Sie verdächtige Aktivitäten sofort</li>
<li>Halten Sie Ihre Kontaktinformationen aktuell</li>
<li>Überprüfen Sie regelmäßig Ihre Account-Aktivitäten</li>
</ul>
<h2>Sicherheitsvorfälle melden</h2>
<h3>Verantwortungsvolle Offenlegung</h3>
<p>
Wir schätzen die Arbeit von Sicherheitsforschern. Wenn Sie eine Sicherheitslücke entdecken,
melden Sie diese bitte verantwortungsvoll an:
</p>
<p class="font-mono bg-gray-100 p-3 rounded-lg mt-2">security@uload.de</p>
<p class="mt-2">
Bitte geben Sie uns angemessene Zeit zur Behebung, bevor Sie die Schwachstelle öffentlich
machen.
</p>
<h3>Bug Bounty Programm</h3>
<p>
Für kritische Sicherheitslücken bieten wir Belohnungen im Rahmen unseres Bug Bounty Programms.
</p>
<h2>Zertifizierungen & Standards</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 not-prose mt-4">
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900 mb-2">ISO 27001</h3>
<p class="text-sm text-gray-600">Informationssicherheits-Management-System zertifiziert</p>
</div>
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900 mb-2">SSL Labs A+</h3>
<p class="text-sm text-gray-600">Höchste Bewertung für SSL/TLS-Konfiguration</p>
</div>
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900 mb-2">OWASP Compliance</h3>
<p class="text-sm text-gray-600">Einhaltung der OWASP-Sicherheitsrichtlinien</p>
</div>
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900 mb-2">PCI DSS Ready</h3>
<p class="text-sm text-gray-600">Bereit für Payment Card Industry Standards</p>
</div>
</div>
<h2 class="mt-8">Kontakt</h2>
<p>Bei Fragen zur Sicherheit unseres Services kontaktieren Sie uns:</p>
<ul>
<li><strong>E-Mail:</strong> security@uload.de</li>
<li><strong>PGP-Schlüssel:</strong> Verfügbar auf Anfrage</li>
</ul>
<div class="rounded-lg bg-blue-50 p-4 text-blue-800 mt-8 not-prose">
<p class="font-semibold">Tipp:</p>
<p>
Aktivieren Sie die Zwei-Faktor-Authentifizierung in Ihren Account-Einstellungen für maximale
Sicherheit!
</p>
</div>
</LegalLayout>

View file

@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* uLoad Theme CSS Variables - Professional Blue (Light Theme) */
:root {
/* Primary colors - uLoad Blue */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-glow: rgba(59, 130, 246, 0.2);
/* Text colors (Light theme) */
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-muted: #6b7280;
/* Background colors (Light theme) */
--color-background-page: #ffffff;
--color-background-card: #f9fafb;
--color-background-card-hover: #f3f4f6;
/* Border colors */
--color-border: #e5e7eb;
--color-border-hover: #d1d5db;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Selection */
::selection {
background-color: var(--color-primary);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors duration-200 shadow-lg hover:shadow-xl;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border-2 border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all duration-200;
}
.container-custom {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
.section {
@apply py-16 md:py-24;
}
}

View file

@ -1,48 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
// uLoad Professional Blue Theme (Light)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
DEFAULT: '#3b82f6',
hover: '#2563eb',
glow: 'rgba(59, 130, 246, 0.2)',
},
background: {
page: '#ffffff',
card: '#f9fafb',
'card-hover': '#f3f4f6',
},
text: {
primary: '#111827',
secondary: '#4b5563',
muted: '#6b7280',
},
border: {
DEFAULT: '#e5e7eb',
hover: '#d1d5db',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};

View file

@ -1,11 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -1,16 +0,0 @@
FROM oven/bun:1 AS production
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
COPY src ./src
COPY tsconfig.json ./
EXPOSE 3041
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3041/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["bun", "run", "src/index.ts"]

View file

@ -1,22 +0,0 @@
{
"name": "@uload/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",
"drizzle-orm": "^0.44.7",
"hono": "^4.7.0",
"stripe": "^18.4.0",
"postgres": "^3.4.7"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.0.0"
}
}

View file

@ -1,30 +0,0 @@
export interface Config {
port: number;
databaseUrl: string;
cors: { origins: string[] };
stripeSecretKey: string;
stripeWebhookSecret: string;
baseUrl: string;
}
export function loadConfig(): Config {
const requiredEnv = (key: string, fallback?: string): string => {
const value = process.env[key] || fallback;
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
};
return {
port: parseInt(process.env.PORT || '3070', 10),
databaseUrl: requiredEnv(
'DATABASE_URL',
'postgresql://mana:devpassword@localhost:5432/mana_sync'
),
cors: {
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
},
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
baseUrl: process.env.BASE_URL || 'http://localhost:3070',
};
}

View file

@ -1,14 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
let db: ReturnType<typeof drizzle> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client);
}
return db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -1,51 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { authMiddleware, errorHandler, notFoundHandler } from '@mana/shared-hono';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { RedirectService } from './services/redirect';
import { AnalyticsService } from './services/analytics';
import { healthRoutes } from './routes/health';
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);
const redirectService = new RedirectService(db);
const analyticsService = new AnalyticsService(db);
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
// Health (no auth)
app.route('/health', healthRoutes);
// Public routes (no auth)
app.route('/r', createRedirectRoutes(redirectService));
app.route('/public', createPublicRoutes(db));
// Stripe webhook (no auth — signature verified internally)
app.post('/api/v1/stripe/webhook', async (c) => {
const routes = createStripeRoutes(config);
return routes.fetch(c.req.raw);
});
// Authenticated API routes
app.use('/api/v1/*', authMiddleware());
app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService));
app.route('/api/v1/stripe', createStripeRoutes(config));
app.route('/api/v1/email', createEmailRoutes());
console.log(`uload-server starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -1,33 +0,0 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
import type { AnalyticsService } from '../services/analytics';
export function createAnalyticsRoutes(analyticsService: AnalyticsService) {
return new Hono<{ Variables: AuthVariables }>()
.get('/:linkId', async (c) => {
const linkId = c.req.param('linkId');
const stats = await analyticsService.getClickStats(linkId);
return c.json(stats);
})
.get('/:linkId/timeline', async (c) => {
const linkId = c.req.param('linkId');
const days = parseInt(c.req.query('days') || '30', 10);
const timeline = await analyticsService.getClicksOverTime(linkId, days);
return c.json(timeline);
})
.get('/:linkId/referrers', async (c) => {
const linkId = c.req.param('linkId');
const referrers = await analyticsService.getTopReferrers(linkId);
return c.json(referrers);
})
.get('/:linkId/devices', async (c) => {
const linkId = c.req.param('linkId');
const devices = await analyticsService.getDeviceBreakdown(linkId);
return c.json(devices);
})
.get('/:linkId/countries', async (c) => {
const linkId = c.req.param('linkId');
const countries = await analyticsService.getCountryBreakdown(linkId);
return c.json(countries);
});
}

View file

@ -1,9 +0,0 @@
import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
export function createEmailRoutes() {
return new Hono<{ Variables: AuthVariables }>().post('/send-invitation', async (c) => {
// TODO: Implement with Resend
return c.json({ error: 'Email not configured yet' }, 501);
});
}

View file

@ -1,10 +0,0 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({
status: 'ok',
service: 'uload-server',
runtime: 'bun',
timestamp: new Date().toISOString(),
})
);

View file

@ -1,44 +0,0 @@
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
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');
// Query links for a user from sync_changes
// Note: In mana-sync, user_id is the auth user ID, not username.
// For public profiles, we'd need a user lookup. For now, treat username as user_id.
const result = await db.execute(sql`
SELECT DISTINCT ON (record_id)
record_id as id,
data->>'shortCode' as "shortCode",
data->>'title' as title,
data->>'description' as description,
COALESCE((data->>'clickCount')::int, 0) as "clickCount",
created_at as "createdAt"
FROM sync_changes
WHERE app_id = 'uload'
AND table_name = 'links'
AND user_id = ${username}
AND op != 'delete'
AND COALESCE((data->>'isActive')::boolean, true) = true
ORDER BY record_id, created_at DESC
LIMIT 50
`);
const links = result as unknown as {
id: string;
shortCode: string;
title: string | null;
description: string | null;
clickCount: number;
createdAt: string;
}[];
return c.json({
user: { username, name: null, bio: null },
links,
});
});
}

View file

@ -1,74 +0,0 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { RedirectService } from '../services/redirect';
export function createRedirectRoutes(redirectService: RedirectService) {
return new Hono().get('/:code', async (c) => {
const code = c.req.param('code');
const link = await redirectService.resolve(code);
if (!link) {
throw new HTTPException(404, { message: 'Link not found or expired' });
}
// Track click asynchronously (don't block redirect)
const userAgent = c.req.header('User-Agent') || '';
const referer = c.req.header('Referer') || '';
// Simple UA parsing
const browser = parseBrowser(userAgent);
const deviceType = parseDeviceType(userAgent);
const os = parseOS(userAgent);
// Hash IP for privacy
const ip = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown';
const ipHash = await hashIP(ip);
redirectService
.trackClick(link.id, {
ipHash,
userAgent,
referer,
browser,
deviceType,
os,
})
.catch((err) => console.error('Click tracking error:', err));
return c.redirect(link.originalUrl, 302);
});
}
function parseDeviceType(ua: string): string {
if (/mobile|android|iphone|ipad/i.test(ua)) return 'mobile';
if (/tablet|ipad/i.test(ua)) return 'tablet';
return 'desktop';
}
function parseBrowser(ua: string): string {
if (/firefox/i.test(ua)) return 'Firefox';
if (/edg/i.test(ua)) return 'Edge';
if (/chrome|chromium/i.test(ua)) return 'Chrome';
if (/safari/i.test(ua)) return 'Safari';
if (/opera|opr/i.test(ua)) return 'Opera';
return 'Other';
}
function parseOS(ua: string): string {
if (/windows/i.test(ua)) return 'Windows';
if (/mac os/i.test(ua)) return 'macOS';
if (/linux/i.test(ua)) return 'Linux';
if (/android/i.test(ua)) return 'Android';
if (/iphone|ipad/i.test(ua)) return 'iOS';
return 'Other';
}
async function hashIP(ip: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(ip + 'uload-salt');
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16);
}

View file

@ -1,73 +0,0 @@
import { Hono } from 'hono';
import Stripe from 'stripe';
import type { AuthVariables } from '@mana/shared-hono';
import type { Config } from '../config';
const PRICES = {
monthly: { lookup: 'uload_pro_monthly', amount: 499 },
yearly: { lookup: 'uload_pro_yearly', amount: 3999 },
} as const;
export function createStripeRoutes(config: Config) {
const stripe = config.stripeSecretKey ? new Stripe(config.stripeSecretKey) : null;
return new Hono<{ Variables: AuthVariables }>()
.post('/checkout', async (c) => {
if (!stripe) return c.json({ error: 'Stripe not configured' }, 501);
const userId = c.get('userId');
const userEmail = c.get('userEmail');
const { priceType } = await c.req.json<{ priceType: keyof typeof PRICES }>();
const price = PRICES[priceType];
if (!price) return c.json({ error: 'Invalid price type' }, 400);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: userEmail,
metadata: { userId },
line_items: [
{
price_data: {
currency: 'eur',
unit_amount: price.amount,
recurring: { interval: priceType === 'yearly' ? 'year' : 'month' },
product_data: { name: `uLoad Pro (${priceType})` },
},
quantity: 1,
},
],
success_url: `${config.baseUrl}/settings?checkout=success`,
cancel_url: `${config.baseUrl}/pricing`,
});
return c.json({ url: session.url });
})
.post('/webhook', async (c) => {
if (!stripe) return c.json({ error: 'Stripe not configured' }, 501);
const body = await c.req.text();
const sig = c.req.header('stripe-signature');
if (!sig) return c.json({ error: 'Missing signature' }, 400);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, config.stripeWebhookSecret);
} catch {
return c.json({ error: 'Invalid signature' }, 400);
}
switch (event.type) {
case 'checkout.session.completed': {
// TODO: Update user subscription status in mana-user or sync_changes
// const _session = event.data.object as Stripe.Checkout.Session;
break;
}
case 'customer.subscription.deleted': {
// TODO: Reset user to free tier
break;
}
}
return c.json({ received: true });
});
}

View file

@ -1,72 +0,0 @@
import { sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
/**
* Analytics service that reads click data from the dedicated uload.clicks table.
* Uses SQL indexes on link_id, clicked_at, country, device_type for fast aggregation.
*/
export class AnalyticsService {
constructor(private db: Database) {}
async getClickStats(linkId: string) {
const result = await this.db.execute(sql`
SELECT
count(*)::int as "totalClicks",
count(DISTINCT ip_hash)::int as "uniqueVisitors"
FROM uload.clicks
WHERE link_id = ${linkId}
`);
const rows = result as unknown as { totalClicks: number; uniqueVisitors: number }[];
return rows[0] ?? { totalClicks: 0, uniqueVisitors: 0 };
}
async getClicksOverTime(linkId: string, days = 30) {
return this.db.execute(sql`
SELECT
date_trunc('day', clicked_at)::date::text as date,
count(*)::int as count
FROM uload.clicks
WHERE link_id = ${linkId}
AND clicked_at >= now() - make_interval(days => ${days})
GROUP BY date_trunc('day', clicked_at)
ORDER BY date_trunc('day', clicked_at)
`) as unknown as { date: string; count: number }[];
}
async getTopReferrers(linkId: string, limit = 10) {
return this.db.execute(sql`
SELECT
COALESCE(referer, 'Direct') as referer,
count(*)::int as count
FROM uload.clicks
WHERE link_id = ${linkId}
GROUP BY referer
ORDER BY count(*) DESC
LIMIT ${limit}
`) as unknown as { referer: string; count: number }[];
}
async getDeviceBreakdown(linkId: string) {
return this.db.execute(sql`
SELECT
COALESCE(device_type, 'Unknown') as "deviceType",
count(*)::int as count
FROM uload.clicks
WHERE link_id = ${linkId}
GROUP BY device_type
ORDER BY count(*) DESC
`) as unknown as { deviceType: string; count: number }[];
}
async getCountryBreakdown(linkId: string) {
return this.db.execute(sql`
SELECT
COALESCE(country, 'Unknown') as country,
count(*)::int as count
FROM uload.clicks
WHERE link_id = ${linkId}
GROUP BY country
ORDER BY count(*) DESC
`) as unknown as { country: string; count: number }[];
}
}

View file

@ -1,76 +0,0 @@
import { sql } from 'drizzle-orm';
import type { Database } from '../db/connection';
interface ResolvedLink {
id: string;
originalUrl: string;
isActive: boolean;
password: string | null;
maxClicks: number | null;
clickCount: number;
expiresAt: string | null;
}
/**
* Reads link data from mana-sync's sync_changes table (local-first architecture).
* Writes clicks to the dedicated uload.clicks table for performant analytics.
*/
export class RedirectService {
constructor(private db: Database) {}
async resolve(shortCode: string): Promise<ResolvedLink | null> {
const result = await this.db.execute(sql`
SELECT DISTINCT ON (record_id)
record_id as id,
data->>'originalUrl' as "originalUrl",
COALESCE((data->>'isActive')::boolean, true) as "isActive",
data->>'password' as password,
(data->>'maxClicks')::int as "maxClicks",
COALESCE((data->>'clickCount')::int, 0) as "clickCount",
data->>'expiresAt' as "expiresAt"
FROM sync_changes
WHERE app_id = 'uload'
AND table_name = 'links'
AND data->>'shortCode' = ${shortCode}
AND op != 'delete'
ORDER BY record_id, created_at DESC
LIMIT 1
`);
const rows = result as unknown as ResolvedLink[];
const link = rows[0];
if (!link) return null;
if (!link.isActive) return null;
if (link.expiresAt && new Date(link.expiresAt) < new Date()) return null;
if (link.maxClicks && link.clickCount >= link.maxClicks) return null;
return link;
}
async trackClick(
linkId: string,
meta: {
ipHash?: string;
userAgent?: string;
referer?: string;
browser?: string;
deviceType?: string;
os?: string;
country?: string;
}
) {
await this.db.execute(sql`
INSERT INTO uload.clicks (link_id, ip_hash, user_agent, referer, browser, device_type, os, country)
VALUES (
${linkId},
${meta.ipHash || null},
${meta.userAgent || null},
${meta.referer || null},
${meta.browser || null},
${meta.deviceType || null},
${meta.os || null},
${meta.country || null}
)
`);
}
}

View file

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,5 +0,0 @@
{
"name": "@mana/uload",
"version": "0.0.1",
"private": true
}

View file

@ -1,23 +0,0 @@
{
"name": "@mana/uload-database",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"drizzle-orm": ">=0.38.0",
"postgres": ">=3.4.0"
},
"devDependencies": {
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"typescript": "^5.0.0"
}
}

View file

@ -1,3 +0,0 @@
export * from './schema';
export type { clicks as ClicksTable } from './schema';

View file

@ -1,42 +0,0 @@
/**
* uLoad database schema minimal server-side tables only.
*
* Links, tags, folders are handled via local-first (IndexedDB mana-sync sync_changes).
* Only clicks need a dedicated table for performant analytics aggregation.
*
* The uload-server reads links from sync_changes and writes clicks here.
*/
import { pgSchema, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
export const uloadSchema = pgSchema('uload');
// ============================================
// Clicks Table — server-generated click tracking
// ============================================
export const clicks = uloadSchema.table(
'clicks',
{
id: uuid('id').primaryKey().defaultRandom(),
linkId: text('link_id').notNull(),
ipHash: text('ip_hash'),
userAgent: text('user_agent'),
referer: text('referer'),
browser: text('browser'),
deviceType: text('device_type'),
os: text('os'),
country: text('country'),
city: text('city'),
clickedAt: timestamp('clicked_at').defaultNow().notNull(),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
linkIdIdx: index('clicks_link_id_idx').on(table.linkId),
clickedAtIdx: index('clicks_clicked_at_idx').on(table.clickedAt),
countryIdx: index('clicks_country_idx').on(table.country),
deviceTypeIdx: index('clicks_device_type_idx').on(table.deviceType),
})
);

View file

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src"]
}

View file

@ -927,8 +927,6 @@ services:
# ghost-API cleanup — every product module talks to mana-sync
# directly via mana-sync.
# See docs/PRE_LAUNCH_CLEANUP.md for the full rationale.
PUBLIC_ULOAD_SERVER_URL: http://uload-server:3070
PUBLIC_ULOAD_SERVER_URL_CLIENT: https://uload-api.mana.how
PUBLIC_MANA_MEDIA_URL: http://mana-media:3011
PUBLIC_MANA_MEDIA_URL_CLIENT: https://media.mana.how
PUBLIC_MANA_LLM_URL: http://mana-llm:3025
@ -1052,34 +1050,6 @@ services:
retries: 3
start_period: 45s
uload-server:
build:
context: apps/uload/apps/server
dockerfile: Dockerfile
image: uload-server:local
container_name: mana-app-uload-server
restart: always
# Tier-3 right-size 2026-04-28: live RSS ~51 MiB (20%). 128m is
# 2.5× headroom — enough for spike during multi-file uploads.
mem_limit: 128m
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3070
DATABASE_URL: postgresql://mana:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/mana_sync
MANA_AUTH_URL: http://mana-auth:3001
CORS_ORIGINS: http://mana-web:5000,https://mana.how,https://uload.mana.how,https://ulo.ad
ports:
- "3070:3070"
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3070/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
mana-llm:
build:
context: ../mana/services/mana-llm

View file

@ -85,13 +85,6 @@ scrape_configs:
scrape_interval: 30s
# ULoad Server
- job_name: 'uload-server'
static_configs:
- targets: ['mana-app-uload-server:3070']
metrics_path: '/metrics'
scrape_interval: 30s
# Memoro Server
- job_name: 'mana-llm'
static_configs:
- targets: ['mana-llm:3020']
@ -237,7 +230,6 @@ scrape_configs:
- https://mana.how/moodlit
# mana.how/context: Modul wurde 2026-04-29 gedropt (Commit 1815139dc) — Probe entfernt
- https://mana.how/questions
- https://mana.how/uload
- https://mana.how/notes
- https://mana.how/habits
- https://mana.how/guides

View file

@ -112,11 +112,6 @@
"dev:todo:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:todo:web\"",
"dev:todo:full": "./scripts/setup-databases.sh auth && concurrently -n auth,sync,api,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\" \"pnpm dev:todo:web\"",
"dev:todo:local": "concurrently -n sync,api,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:api\" \"pnpm dev:todo:web\"",
"dev:uload:web": "pnpm --filter @uload/web dev",
"dev:uload:server": "cd apps/uload/apps/server && bun run --hot src/index.ts",
"dev:uload:landing": "pnpm --filter @uload/landing dev",
"dev:uload:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:uload:server\" \"pnpm dev:uload:web\"",
"dev:uload:full": "./scripts/setup-databases.sh uload && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:uload:server\" \"pnpm dev:uload:web\"",
"dev:moodlit:web": "pnpm --filter @moodlit/web dev",
"dev:moodlit:landing": "pnpm --filter @moodlit/landing dev",
"dev:moodlit:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
@ -189,7 +184,6 @@
"docker:logs:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development logs -f chat-backend",
"docker:ps": "docker compose -f docker-compose.dev.yml --env-file .env.development ps -a",
"docker:clean": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile all down -v",
"deploy:landing:uload": "pnpm --filter @uload/landing build && npx wrangler pages deploy apps/uload/apps/landing/dist --project-name=uload-landing",
"deploy:landing:todo": "pnpm --filter @todo/landing build && npx wrangler pages deploy apps/todo/apps/landing/dist --project-name=todo-landing",
"deploy:landing:contacts": "pnpm --filter @contacts/landing build && npx wrangler pages deploy apps/contacts/apps/landing/dist --project-name=contacts-landing",
"deploy:landing:calendar": "pnpm --filter @calendar/landing build && npx wrangler pages deploy apps/calendar/apps/landing/dist --project-name=calendars-landing",

View file

@ -99,9 +99,6 @@ export const APP_ICONS = {
questions: svgToDataUrl(questionsSvg),
times: svgToDataUrl(timesSvg),
calc: svgToDataUrl(calcSvg),
uload: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
),
'news-research': svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#0891b2"/><stop offset="100%" style="stop-color:#22d3ee"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nr)"/><path d="M30 30a6 6 0 0 0-6 6v6M30 30a6 6 0 0 1 6 6v0M30 30v0" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><circle cx="30" cy="30" r="3" fill="white"/><circle cx="52" cy="52" r="18" stroke="white" stroke-width="4" fill="none"/><line x1="65" y1="65" x2="78" y2="78" stroke="white" stroke-width="5" stroke-linecap="round"/><line x1="44" y1="50" x2="58" y2="50" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="44" y1="56" x2="54" y2="56" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),

View file

@ -32,19 +32,6 @@ export const APP_BRANDING = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
uload: {
id: 'uload',
name: 'uLoad',
tagline: 'Smart URL Shortener',
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Link/Chain icon
logoPath:
'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 2,
},
chat: {
id: 'chat',
name: 'ManaChat',

View file

@ -17,7 +17,6 @@ export { default as ManaIcon } from './ManaIcon.svelte';
export {
ManaLogo,
CardsLogo,
UloadLogo,
ChatLogo,
PresiLogo,
QuotesLogo,

View file

@ -1,13 +0,0 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="uload" {size} {color} class={className} />

View file

@ -4,7 +4,6 @@
*/
export { default as ManaLogo } from './ManaLogo.svelte';
export { default as CardsLogo } from './CardsLogo.svelte';
export { default as UloadLogo } from './UloadLogo.svelte';
export { default as ChatLogo } from './ChatLogo.svelte';
export { default as PresiLogo } from './PresiLogo.svelte';
export { default as QuotesLogo } from './QuotesLogo.svelte';

View file

@ -379,23 +379,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'uload',
name: 'uLoad',
description: {
de: 'URL-Shortener & Link-Management',
en: 'URL Shortener & Link Management',
},
longDescription: {
de: 'Kürze URLs, tracke Klicks und verwalte deine Links.',
en: 'Shorten URLs, track clicks, and manage your links.',
},
icon: APP_ICONS.uload,
color: '#6366f1',
comingSoon: false,
status: 'beta',
requiredTier: 'guest',
},
{
id: 'news-research',
name: 'News Research',

View file

@ -70,7 +70,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'contacts',
'calendar',
'storage',
'uload',
'landing', // future
'website',
'presi',
@ -97,7 +96,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'events',
'mail',
'storage',
'uload',
'research-lab',
'club-members', // future — ClubDesk Paket A
'club-finance', // future — ClubDesk Paket B
@ -124,7 +122,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'events',
'mail',
'storage',
'uload',
'website',
'recipes',
'places',
@ -148,7 +145,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'events',
'storage',
'mail',
'uload',
'website',
'news-research',
'research-lab',
@ -172,7 +168,6 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
'calendar',
'storage',
'mail',
'uload',
'website',
'invoices',
'finance',

View file

@ -192,7 +192,6 @@ export const MANA_APP_INDEX: Record<string, number> = {
times: 16,
questions: 19,
moodlit: 20,
uload: 21,
calc: 22,
mana: 31,
};