i18n(uload): wire components + routes to namespace — 67 strings cleared

Patches ListView, DetailView, /uload root page, /uload/links,
/uload/analytics/[id], /uload/settings, /uload/tags. Locale JSONs
landed in 812f3f7fa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 01:03:00 +02:00
parent c9a091e696
commit 899fccd455
6 changed files with 266 additions and 177 deletions

View file

@ -117,30 +117,30 @@
if (!newUrl) return;
if (!isValidUrl(newUrl)) {
toast.error('Bitte eine gueltige URL eingeben (mit https://)');
toast.error($_('uload.page.err_invalid_url_input'));
return;
}
const shortCode = newCustomCode || generateShortCode();
if (newCustomCode && !isValidCustomCode(newCustomCode)) {
toast.error('Custom Code darf nur Buchstaben, Zahlen, - und _ enthalten');
toast.error($_('uload.page.err_invalid_custom_code'));
return;
}
if (!(await isShortCodeUnique(shortCode))) {
toast.error(`Short Code "${shortCode}" ist bereits vergeben`);
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('Max Klicks muss mindestens 1 sein');
toast.error($_('uload.page.err_max_clicks'));
return;
}
if (newExpiresAt && new Date(newExpiresAt) <= new Date()) {
toast.error('Ablaufdatum muss in der Zukunft liegen');
toast.error($_('uload.page.err_expires_past'));
return;
}
@ -165,7 +165,7 @@
};
await encryptRecord('links', newRow);
await linkTable.add(newRow);
toast.success(`Link erstellt: ${shortCode}`);
toast.success($_('uload.page.toast_created', { values: { code: shortCode } }));
newUrl = '';
newTitle = '';
newCustomCode = '';
@ -195,18 +195,18 @@
if (!editingLink || !editUrl) return;
if (!isValidUrl(editUrl)) {
toast.error('Bitte eine gueltige URL eingeben (mit https://)');
toast.error($_('uload.page.err_invalid_url_input'));
return;
}
const maxClicks = editMaxClicks ? parseInt(editMaxClicks) : null;
if (maxClicks !== null && maxClicks < 1) {
toast.error('Max Klicks muss mindestens 1 sein');
toast.error($_('uload.page.err_max_clicks'));
return;
}
if (editExpiresAt && new Date(editExpiresAt) <= new Date()) {
toast.error('Ablaufdatum muss in der Zukunft liegen');
toast.error($_('uload.page.err_expires_past'));
return;
}
@ -222,7 +222,7 @@
};
await encryptRecord('links', diff);
await linkTable.update(editingLink.id, diff);
toast.success('Link aktualisiert');
toast.success($_('uload.page.toast_updated'));
editingLink = null;
}
@ -231,14 +231,15 @@
}
async function deleteLink(link: Link) {
if (!confirm(`"${link.title || link.shortCode}" wirklich loeschen?`)) return;
const name = link.title || link.shortCode;
if (!confirm($_('uload.page.confirm_delete', { values: { name } }))) return;
await linkTable.delete(link.id);
toast.success('Link geloescht');
toast.success($_('uload.page.toast_deleted'));
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success('Link kopiert!');
toast.success($_('uload.page.toast_copied'));
}
function downloadQr(code: string) {
@ -256,7 +257,7 @@
</script>
<svelte:head>
<title>uLoad - Mana</title>
<title>{$_('uload.page.title')}</title>
</svelte:head>
<RoutePage appId="uload">
@ -267,10 +268,13 @@
<div>
<h1 class="text-2xl font-bold">uLoad</h1>
<p class="mt-1 text-sm opacity-60">
{filteredLinks.length} Links
{#if folders.length > 0}
&middot; {folders.length} Ordner
{/if}
{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">
@ -278,13 +282,13 @@
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"
>
Alle Links
{$_('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 ? '- Ausblenden' : '+ Neuer Link'}
{showCreateForm ? $_('uload.page.hide_form') : $_('uload.page.show_form')}
</button>
</div>
</div>
@ -296,7 +300,9 @@
>
<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">URL</label>
<label for="url" class="mb-1 block text-sm font-medium"
>{$_('uload.page.label_url_modal')}</label
>
<input
id="url"
type="url"
@ -307,23 +313,26 @@
/>
</div>
<div>
<label for="title" class="mb-1 block text-sm font-medium">Titel (optional)</label>
<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="Mein Link"
placeholder={$_('uload.page.placeholder_title')}
class={inputClass}
/>
</div>
<div>
<label for="code" class="mb-1 block text-sm font-medium">Custom Code (optional)</label
<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="mein-link"
placeholder={$_('uload.page.placeholder_custom_code')}
class={inputClass}
/>
</div>
@ -337,13 +346,13 @@
<span class="transition-transform {showAdvanced ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
Erweitert
{$_('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"
>Ablaufdatum</label
>{$_('uload.page.label_expires')}</label
>
<input
id="expires"
@ -354,25 +363,25 @@
</div>
<div>
<label for="password" class="mb-1 block text-xs font-medium opacity-70"
>Passwort</label
>{$_('uload.page.label_password')}</label
>
<input
id="password"
type="text"
bind:value={newPassword}
placeholder="Optional"
placeholder={$_('uload.page.placeholder_optional')}
class={inputSmClass}
/>
</div>
<div>
<label for="maxclicks" class="mb-1 block text-xs font-medium opacity-70"
>Max Klicks</label
>{$_('uload.page.label_max_clicks')}</label
>
<input
id="maxclicks"
type="number"
bind:value={newMaxClicks}
placeholder="Unbegrenzt"
placeholder={$_('uload.page.placeholder_unlimited')}
min="1"
class={inputSmClass}
/>
@ -388,43 +397,43 @@
<span class="transition-transform {showUtm ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
UTM-Parameter
{$_('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"
>Source</label
>{$_('uload.page.label_source')}</label
>
<input
id="utm-source"
type="text"
bind:value={newUtmSource}
placeholder="newsletter"
placeholder={$_('uload.page.placeholder_source')}
class={inputSmClass}
/>
</div>
<div>
<label for="utm-medium" class="mb-1 block text-xs font-medium opacity-70"
>Medium</label
>{$_('uload.page.label_medium')}</label
>
<input
id="utm-medium"
type="text"
bind:value={newUtmMedium}
placeholder="email"
placeholder={$_('uload.page.placeholder_medium')}
class={inputSmClass}
/>
</div>
<div>
<label for="utm-campaign" class="mb-1 block text-xs font-medium opacity-70"
>Campaign</label
>{$_('uload.page.label_campaign')}</label
>
<input
id="utm-campaign"
type="text"
bind:value={newUtmCampaign}
placeholder="spring-2026"
placeholder={$_('uload.page.placeholder_campaign')}
class={inputSmClass}
/>
</div>
@ -437,7 +446,7 @@
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"
>
Link erstellen
{$_('uload.page.action_create')}
</button>
</div>
</div>
@ -453,18 +462,18 @@
<input
type="text"
bind:value={searchQuery}
placeholder="Links durchsuchen..."
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">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<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}>Alle Ordner</option>
<option value={null}>{$_('uload.page.option_all_folders')}</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
@ -484,13 +493,13 @@
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">Noch keine Links</p>
<p class="mt-1 text-sm opacity-40">Erstelle deinen ersten gekuerzten Link!</p>
<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"
>
+ Neuer Link
{$_('uload.page.show_form')}
</button>
</div>
{:else}
@ -516,19 +525,21 @@
{#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
>{$_('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"
>Passwort</span
>{$_('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="Laeuft ab: {formatDate(new Date(link.expiresAt))}">Ablauf</span
title={$_('uload.page.badge_expires_title', {
values: { date: formatDate(new Date(link.expiresAt)) },
})}>{$_('uload.page.badge_expires')}</span
>
{/if}
</div>
@ -552,7 +563,7 @@
<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="Analytics"
title={$_('uload.page.action_analytics_title')}
>
<ChartBar size={16} />
{link.clickCount}
@ -560,28 +571,30 @@
<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="Link kopieren"
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="QR-Code"
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={$_('common.edit')}
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 ? 'Deaktivieren' : 'Aktivieren'}
title={link.isActive
? $_('uload.page.action_deactivate_title')
: $_('uload.page.action_activate_title')}
>
<Lightning
size={16}
@ -591,7 +604,7 @@
<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="Loeschen"
title={$_('uload.page.action_delete_title')}
>
<Trash size={16} />
</button>
@ -620,7 +633,7 @@
role="none"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">Link bearbeiten</h3>
<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"
@ -631,50 +644,57 @@
<div class="space-y-4">
<div>
<label for="edit-url" class="mb-1 block text-sm font-medium">URL</label>
<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">Titel</label>
<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">Short Code</label>
<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">(nicht aenderbar)</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">UTM-Parameter</p>
<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="Source"
placeholder={$_('uload.page.label_source')}
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmMedium}
placeholder="Medium"
placeholder={$_('uload.page.label_medium')}
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmCampaign}
placeholder="Campaign"
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">Erweitert</p>
<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">Ablaufdatum</label
<label for="uload-expires-at" class="mb-1 block text-xs opacity-50"
>{$_('uload.page.label_expires')}</label
>
<input
id="uload-expires-at"
@ -684,22 +704,26 @@
/>
</div>
<div>
<label for="uload-password" class="mb-1 block text-xs opacity-50">Passwort</label>
<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="Optional"
placeholder={$_('uload.page.placeholder_optional')}
class={inputSmClass}
/>
</div>
<div>
<label for="uload-max-clicks" class="mb-1 block text-xs opacity-50">Max Klicks</label>
<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="Unbegrenzt"
placeholder={$_('uload.page.placeholder_unlimited')}
min="1"
class={inputSmClass}
/>
@ -713,14 +737,14 @@
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"
>
Abbrechen
{$_('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"
>
Speichern
{$_('uload.page.action_save')}
</button>
</div>
</div>
@ -742,7 +766,7 @@
role="none"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">QR-Code</h3>
<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"
@ -755,7 +779,7 @@
<div class="rounded-lg bg-white p-4">
<img
src="{QR_API}/?size=200x200&data={encodeURIComponent(getShortUrl(qrLink.shortCode))}"
alt="QR Code fuer {qrLink.shortCode}"
alt={$_('uload.page.qr_alt', { values: { code: qrLink.shortCode } })}
class="h-48 w-48"
/>
</div>
@ -765,13 +789,13 @@
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"
>
Link kopieren
{$_('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"
>
QR herunterladen
{$_('uload.page.action_download_qr')}
</button>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { formatDate } from '$lib/i18n/format';
import { page } from '$app/stores';
import { onMount } from 'svelte';
@ -71,18 +72,22 @@
</script>
<svelte:head>
<title>Analytics - uLoad - Mana</title>
<title>{$_('uload.analytics_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload" title="Link">
<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="Zurueck">
<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">Analytics</h1>
<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>
@ -100,35 +105,45 @@
</div>
{:else if !link}
<div class="rounded-xl border border-border/10 p-12 text-center">
<p class="text-muted-foreground">Link nicht gefunden</p>
<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">Clicks</p>
<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">Unique</p>
<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">Status</p>
<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">Aktiv</span>
<span class="text-green-400">{$_('uload.analytics_route.status_active')}</span>
{:else}
<span class="text-muted-foreground/70">Inaktiv</span>
<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">Erstellt</p>
<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>
@ -137,10 +152,13 @@
<!-- 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">Link Details</h2>
<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">Ziel-URL</span>
<span class="text-muted-foreground">{$_('uload.analytics_route.label_target_url')}</span
>
<a
href={link.originalUrl}
target="_blank"
@ -152,31 +170,37 @@
</div>
{#if link.title}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Titel</span>
<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">
UTM-Parameter
{$_('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">Source:</span>
<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">Medium:</span>
<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">Campaign:</span>
<span class="text-muted-foreground"
>{$_('uload.analytics_route.utm_campaign')}</span
>
{link.utmCampaign}
</div>
{/if}
@ -185,20 +209,30 @@
{/if}
{#if link.expiresAt}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Laeuft ab</span>
<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">Max Klicks</span>
<span class="text-white">{link.clickCount} / {link.maxClicks}</span>
<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">Passwortgeschuetzt</span>
<span class="text-white">Ja</span>
<span class="text-muted-foreground"
>{$_('uload.analytics_route.label_password_protected')}</span
>
<span class="text-white">{$_('uload.analytics_route.yes')}</span>
</div>
{/if}
</div>
@ -207,7 +241,9 @@
<!-- 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">Clicks ueber Zeit</h2>
<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
@ -216,7 +252,7 @@
? 'bg-indigo-600 text-white'
: 'bg-muted/10 text-muted-foreground hover:bg-muted/15'}"
>
{d}T
{$_('uload.analytics_route.days_unit', { values: { days: d } })}
</button>
{/each}
</div>
@ -244,15 +280,17 @@
{:else if !serverAvailable}
<div class="py-8 text-center">
<p class="text-sm text-muted-foreground">
Detaillierte Analytics sind verfuegbar, wenn der uLoad-Server verbunden ist.
{$_('uload.analytics_route.hint_no_server')}
</p>
<p class="mt-1 text-xs text-muted-foreground/70">
Lokaler Click-Count: {link.clickCount}
{$_('uload.analytics_route.hint_local_count', {
values: { count: link.clickCount },
})}
</p>
</div>
{:else}
<p class="py-8 text-center text-sm text-muted-foreground">
Noch keine Daten fuer diesen Zeitraum
{$_('uload.analytics_route.empty_period')}
</p>
{/if}
</div>
@ -262,13 +300,17 @@
<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">Geraete</h2>
<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 || 'Unbekannt'}</span>
<span class="text-foreground"
>{d.deviceType || $_('uload.analytics_route.unknown')}</span
>
<span class="font-medium text-white">
{Math.round((d.count / totalDevices) * 100)}%
</span>
@ -283,38 +325,48 @@
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Daten</p>
<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">Referrer</h2>
<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 || 'Direkt'}
{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">Keine Daten</p>
<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">Laender</h2>
<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 || 'Unbekannt'}</span>
<span class="text-foreground"
>{c.country || $_('uload.analytics_route.unknown')}</span
>
<span class="font-medium text-white">
{Math.round((c.count / totalCountries) * 100)}%
</span>
@ -329,7 +381,9 @@
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Daten</p>
<p class="text-sm text-muted-foreground">
{$_('uload.analytics_route.empty_no_data')}
</p>
{/if}
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import {
useAllLinks,
useAllTags,
@ -68,7 +69,7 @@
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success('Link kopiert!');
toast.success($_('uload.links_route.toast_copied'));
}
async function toggleActive(link: Link) {
@ -76,9 +77,10 @@
}
async function deleteLink(link: Link) {
if (!confirm(`"${link.title || link.shortCode}" wirklich loeschen?`)) return;
const name = link.title || link.shortCode;
if (!confirm($_('uload.links_route.confirm_delete_single', { values: { name } }))) return;
await linkTable.delete(link.id);
toast.success('Link geloescht');
toast.success($_('uload.links_route.toast_deleted_single'));
}
// Bulk actions
@ -101,22 +103,24 @@
}
async function bulkDelete() {
if (!confirm(`${selectedIds.size} Link(s) loeschen?`)) return;
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(`${selectedIds.size} Links geloescht`);
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(`${selectedIds.size} Links aktualisiert`);
toast.success($_('uload.links_route.toast_bulk_updated', { values: { count } }));
selectedIds.clear();
selectedIds = selectedIds;
selectMode = false;
@ -127,7 +131,7 @@
</script>
<svelte:head>
<title>Alle Links - uLoad - Mana</title>
<title>{$_('uload.links_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload">
@ -139,13 +143,13 @@
<a
href="/uload"
class="rounded-lg p-2 hover:bg-muted dark:hover:bg-muted"
title="Zurueck"
title={$_('uload.links_route.action_back_title')}
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold">
Alle Links
{$_('uload.links_route.heading')}
{#if filteredLinks.length > 0}
<span class="ml-1 text-xl opacity-50">({filteredLinks.length})</span>
{/if}
@ -165,7 +169,9 @@
? 'bg-indigo-600 text-white'
: 'hover:bg-muted dark:border-border dark:hover:bg-muted'}"
>
{selectMode ? 'Fertig' : 'Auswaehlen'}
{selectMode
? $_('uload.links_route.action_select_done')
: $_('uload.links_route.action_select_start')}
</button>
</div>
</div>
@ -180,18 +186,18 @@
<input
type="text"
bind:value={searchQuery}
placeholder="Links durchsuchen..."
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">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<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}>Alle Ordner</option>
<option value={null}>{$_('uload.links_route.option_all_folders')}</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
@ -211,18 +217,22 @@
onchange={toggleSelectAll}
class="h-4 w-4 rounded"
/>
<span class="text-sm font-medium">{selectedIds.size} ausgewaehlt</span>
<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"
>Aktivieren/Deaktivieren</button
>{$_('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"
>Loeschen</button
>{$_('uload.links_route.action_bulk_delete')}</button
>
</div>
{/if}
@ -239,11 +249,11 @@
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">Keine Links gefunden</p>
<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">Versuche andere Filtereinstellungen.</p>
<p class="mt-1 text-sm opacity-40">{$_('uload.links_route.empty_filtered')}</p>
{:else}
<p class="mt-1 text-sm opacity-40">Erstelle Links auf der uLoad-Hauptseite.</p>
<p class="mt-1 text-sm opacity-40">{$_('uload.links_route.empty_root')}</p>
{/if}
</div>
{:else}
@ -301,7 +311,7 @@
<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="Analytics"
title={$_('uload.links_route.action_analytics_title')}
>
<ChartBar size={16} />
{link.clickCount}
@ -309,14 +319,16 @@
<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="Link kopieren"
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 ? 'Deaktivieren' : 'Aktivieren'}
title={link.isActive
? $_('uload.links_route.action_deactivate_title')
: $_('uload.links_route.action_activate_title')}
>
<Lightning
size={16}
@ -326,7 +338,7 @@
<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="Loeschen"
title={$_('uload.links_route.action_delete_title')}
>
<Trash size={16} />
</button>

View file

@ -3,6 +3,7 @@
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';
@ -15,14 +16,13 @@
const folders = useAllFolders();
async function clearAllData() {
if (!confirm('Alle lokalen uLoad-Daten löschen? Dies kann nicht rückgängig gemacht werden.'))
return;
if (!confirm($_('uload.settings_route.confirm_clear'))) return;
await linkTable.clear();
await uloadTagTable.clear();
await uloadFolderTable.clear();
await linkTagTable.clear();
toast.success('Alle uLoad-Daten gelöscht');
toast.success($_('uload.settings_route.toast_cleared'));
}
async function exportData() {
@ -47,59 +47,58 @@
a.download = `uload-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('Export heruntergeladen');
toast.success($_('uload.settings_route.toast_exported'));
}
</script>
<svelte:head>
<title>uLoad-Einstellungen — Mana</title>
<title>{$_('uload.settings_route.title')}</title>
</svelte:head>
<RoutePage appId="uload" backHref="/uload">
<div class="pane">
<header class="bar">
<div class="title">
<strong>uLoad-Einstellungen</strong>
<span class="sub">Datenübersicht · Export · Gefahrenzone</span>
<strong>{$_('uload.settings_route.heading')}</strong>
<span class="sub">{$_('uload.settings_route.subtitle')}</span>
</div>
</header>
<section class="panel">
<h2>Daten</h2>
<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">Links</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">Tags</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">Ordner</p>
<p class="stat-label">{$_('uload.settings_route.stat_folders')}</p>
</div>
</div>
</section>
<section class="panel">
<h2>Daten exportieren</h2>
<p class="hint">Alle Links, Tags und Ordner als JSON-Datei herunterladen.</p>
<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} />
JSON exportieren
{$_('uload.settings_route.action_export')}
</button>
</section>
<section class="panel danger">
<h2>Gefahrenzone</h2>
<h2>{$_('uload.settings_route.section_danger')}</h2>
<p class="hint">
Löscht alle lokalen uLoad-Daten (Links, Tags, Ordner). Synchronisierte Daten auf dem Server
bleiben erhalten.
{$_('uload.settings_route.danger_hint')}
</p>
<button type="button" class="btn danger" onclick={clearAllData}>
<Trash size={16} />
Alle Daten löschen
{$_('uload.settings_route.action_clear')}
</button>
</section>
</div>

View file

@ -20,16 +20,17 @@
async function createTag() {
if (!newName.trim()) return;
const created = newName.trim();
await uloadTagTable.add({
id: crypto.randomUUID(),
name: newName.trim(),
name: created,
slug: slugify(newName),
color: newColor,
icon: null,
isPublic: false,
usageCount: 0,
} as LocalTag);
toast.success(`Tag "${newName}" erstellt`);
toast.success($_('uload.tags_route.toast_created', { values: { name: created } }));
newName = '';
newColor = '#6366f1';
showCreateForm = false;
@ -37,7 +38,7 @@
async function deleteTag(tag: { id: string; name: string }) {
await uloadTagTable.delete(tag.id);
toast.success(`Tag "${tag.name}" geloescht`);
toast.success($_('uload.tags_route.toast_deleted', { values: { name: tag.name } }));
}
async function updateTag() {
@ -47,7 +48,7 @@
slug: slugify(editingTag.name),
color: editingTag.color,
});
toast.success('Tag aktualisiert');
toast.success($_('uload.tags_route.toast_updated'));
editingTag = null;
}
</script>
@ -59,13 +60,13 @@
<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">Tags</h1>
<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 ? 'Ausblenden' : '+ Neuer Tag'}
{showCreateForm ? $_('uload.tags_route.hide_form') : $_('uload.tags_route.show_form')}
</button>
</div>
@ -74,20 +75,20 @@
<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"
>Name</label
>{$_('uload.tags_route.label_name')}</label
>
<input
id="tag-name"
type="text"
bind:value={newName}
placeholder="z.B. Social Media"
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"
>Farbe</label
>{$_('uload.tags_route.label_color')}</label
>
<input
id="tag-color"
@ -101,7 +102,7 @@
disabled={!newName.trim()}
class="rounded-lg bg-indigo-600 px-6 py-2 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
Erstellen
{$_('uload.tags_route.action_create')}
</button>
</div>
</div>
@ -109,9 +110,11 @@
{#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">Noch keine Tags</p>
<p class="text-lg font-medium text-muted-foreground">
{$_('uload.tags_route.empty_title')}
</p>
<p class="mt-1 text-sm text-muted-foreground">
Erstelle Tags um deine Links zu organisieren.
{$_('uload.tags_route.empty_desc')}
</p>
</div>
{:else}
@ -151,7 +154,11 @@
<span class="font-medium text-white">{tag.name}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">{getUsageCount(tag.id)} Links</span>
<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"