mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 12:06:41 +02:00
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
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:
parent
2b08e2f3a2
commit
0b44acdde1
85 changed files with 13 additions and 7302 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"inventory": "Inventar",
|
||||
"questions": "Recherche",
|
||||
"skilltree": "Skills",
|
||||
"uload": "uLoad",
|
||||
"calc": "Rechner",
|
||||
"period": "Periode",
|
||||
"body": "Körper",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"inventory": "Inventory",
|
||||
"questions": "Research",
|
||||
"skilltree": "Skills",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculator",
|
||||
"period": "Period",
|
||||
"body": "Body",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"inventory": "Inventario",
|
||||
"questions": "Investigación",
|
||||
"skilltree": "Skills",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculadora",
|
||||
"period": "Ciclo",
|
||||
"body": "Cuerpo",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"inventory": "Inventaire",
|
||||
"questions": "Recherche",
|
||||
"skilltree": "Skills",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculatrice",
|
||||
"period": "Règles",
|
||||
"body": "Corps",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"inventory": "Inventario",
|
||||
"questions": "Ricerca",
|
||||
"skilltree": "Skills",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calcolatrice",
|
||||
"period": "Ciclo",
|
||||
"body": "Corpo",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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[],
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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, '');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -22,7 +22,6 @@ const SPLIT_APP_ID_LIST = [
|
|||
'skilltree',
|
||||
'times',
|
||||
'questions',
|
||||
'uload',
|
||||
'calc',
|
||||
'places',
|
||||
'automations',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
→ <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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue