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' },