feat(manacore/web): uload feature parity — tags, analytics, settings

Port missing features from standalone uload app to unified ManaCore module:
- Add Tag Management page (/uload/tags) with CRUD, color picker, inline edit
- Replace analytics placeholder with full server dashboard (timeline chart,
  device/country/referrer breakdowns, graceful fallback when server unavailable)
- Add Settings page (/uload/settings) with data overview, JSON export, clear data
- Fix bugs: missing LocalTag type, unregistered uloadTags table, wrong table
  name in AppView (linkFolders → uloadFolders)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:10:10 +02:00
parent 8c98dd871d
commit be20de23db
7 changed files with 567 additions and 93 deletions

View file

@ -33,14 +33,17 @@ db.version(1).stores({
// ─── Calendar (appId: 'calendar') ───
calendars: 'id, isDefault, isVisible',
events: 'id, calendarId, startDate, endDate, allDay, [calendarId+startDate]',
eventTags: 'id, eventId, tagId, [eventId+tagId]',
// ─── Contacts (appId: 'contacts') ───
contacts: 'id, firstName, lastName, email, company, isFavorite, isArchived',
contactTags: 'id, contactId, tagId, [contactId+tagId]',
// ─── Chat (appId: 'chat') ───
conversations: 'id, isArchived, isPinned, spaceId, templateId',
messages: 'id, conversationId, sender, [conversationId+sender]',
chatTemplates: 'id, isDefault',
conversationTags: 'id, conversationId, tagId, [conversationId+tagId]',
// ─── Picture (appId: 'picture') ───
images: 'id, isFavorite, isPublic, archivedAt, prompt',
@ -51,10 +54,12 @@ db.version(1).stores({
// ─── Cards (appId: 'cards') ───
cardDecks: 'id, isPublic',
cards: 'id, deckId, difficulty, nextReview, order, [deckId+order]',
deckTags: 'id, deckId, tagId, [deckId+tagId]',
// ─── Zitare (appId: 'zitare') ───
zitareFavorites: 'id, quoteId',
zitareLists: 'id',
zitareListTags: 'id, listId, tagId, [listId+tagId]',
// ─── Mukke (appId: 'mukke') ───
songs: 'id, artist, album, genre, favorite, title',
@ -62,6 +67,7 @@ db.version(1).stores({
playlistSongs: 'id, playlistId, songId, sortOrder, [playlistId+sortOrder]',
mukkeProjects: 'id, title, songId',
markers: 'id, beatId, type, sortOrder',
songTags: 'id, songId, tagId, [songId+tagId]',
// ─── Storage (appId: 'storage') ───
files: 'id, parentFolderId, mimeType, isFavorite, isDeleted, name',
@ -71,12 +77,14 @@ db.version(1).stores({
// ─── Presi (appId: 'presi') ───
presiDecks: 'id, isPublic',
slides: 'id, deckId, order, [deckId+order]',
presiDeckTags: 'id, deckId, tagId, [deckId+tagId]',
// ─── Inventar (appId: 'inventar') ───
invCollections: 'id, order, templateId',
invItems: 'id, collectionId, locationId, categoryId, status, name, [collectionId+order]',
invLocations: 'id, parentId, path, depth, order',
invCategories: 'id, parentId, order',
invItemTags: 'id, itemId, tagId, [itemId+tagId]',
// ─── Photos (appId: 'photos') ───
albums: 'id, isAutoGenerated, name',
@ -88,11 +96,13 @@ db.version(1).stores({
skills: 'id, branch, parentId, level',
activities: 'id, skillId, timestamp',
achievements: 'id, key, unlockedAt',
skillTags: 'id, skillId, tagId, [skillId+tagId]',
// ─── CityCorners (appId: 'citycorners') ───
cities: 'id, slug, country, name',
ccLocations: 'id, cityId, category, name',
ccFavorites: 'id, locationId',
ccLocationTags: 'id, locationId, tagId, [locationId+tagId]',
// ─── Times (appId: 'times') ───
timeClients: 'id, order, isArchived, shortCode',
@ -104,29 +114,35 @@ db.version(1).stores({
timeAlarms: 'id, enabled, time',
timeCountdownTimers: 'id, status',
timeWorldClocks: 'id, sortOrder, timezone',
entryTags: 'id, entryId, tagId, [entryId+tagId]',
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type]',
documentTags: 'id, documentId, tagId, [documentId+tagId]',
// ─── Questions (appId: 'questions') ───
qCollections: 'id, sortOrder, isDefault',
questions: 'id, collectionId, status, priority, [collectionId+status]',
answers: 'id, questionId, isAccepted',
questionTags: 'id, questionId, tagId, [questionId+tagId]',
// ─── NutriPhi (appId: 'nutriphi') ───
meals: 'id, date, mealType, [date+mealType]',
goals: 'id',
nutriFavorites: 'id, mealType, usageCount',
mealTags: 'id, mealId, tagId, [mealId+tagId]',
// ─── Planta (appId: 'planta') ───
plants: 'id, isActive, healthStatus',
plantPhotos: 'id, plantId, isPrimary, [plantId+isPrimary]',
wateringSchedules: 'id, plantId, nextWateringAt',
wateringLogs: 'id, plantId, wateredAt',
plantTags: 'id, plantId, tagId, [plantId+tagId]',
// ─── uLoad (appId: 'uload') ───
links: 'id, shortCode, isActive, folderId, order, clickCount, [folderId+order], [isActive+order]',
uloadTags: 'id, slug, name',
uloadFolders: 'id, order',
linkTags: 'id, linkId, tagId, [linkId+tagId]',
@ -137,6 +153,7 @@ db.version(1).stores({
// ─── Moodlit (appId: 'moodlit') ───
moods: 'id, name, animation, isDefault',
sequences: 'id, name',
moodTags: 'id, moodId, tagId, [moodId+tagId]',
// ─── Memoro (appId: 'memoro') ───
memos: 'id, processingStatus, isArchived, isPinned, language, [isArchived+createdAt]',
@ -152,6 +169,7 @@ db.version(1).stores({
steps: 'id, guideId, sectionId, order, [guideId+order]',
guideCollections: 'id',
runs: 'id, guideId, startedAt, completedAt',
guideTags: 'id, guideId, tagId, [guideId+tagId]',
// ─── Playground (appId: 'playground') ───
// No persistent data — stateless LLM playground
@ -171,19 +189,19 @@ db.version(1).stores({
export const SYNC_APP_MAP: Record<string, string[]> = {
manacore: ['userSettings', 'dashboardConfigs'],
todo: ['tasks', 'todoProjects', 'taskLabels', 'reminders', 'boardViews'],
calendar: ['calendars', 'events'],
contacts: ['contacts'],
chat: ['conversations', 'messages', 'chatTemplates'],
calendar: ['calendars', 'events', 'eventTags'],
contacts: ['contacts', 'contactTags'],
chat: ['conversations', 'messages', 'chatTemplates', 'conversationTags'],
picture: ['images', 'boards', 'boardItems', 'imageTags'],
cards: ['cardDecks', 'cards'],
zitare: ['zitareFavorites', 'zitareLists'],
mukke: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers'],
cards: ['cardDecks', 'cards', 'deckTags'],
zitare: ['zitareFavorites', 'zitareLists', 'zitareListTags'],
mukke: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
storage: ['files', 'storageFolders', 'fileTags'],
presi: ['presiDecks', 'slides'],
inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories'],
presi: ['presiDecks', 'slides', 'presiDeckTags'],
inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],
photos: ['albums', 'albumItems', 'photoFavorites', 'photoMediaTags'],
skilltree: ['skills', 'activities', 'achievements'],
citycorners: ['cities', 'ccLocations', 'ccFavorites'],
skilltree: ['skills', 'activities', 'achievements', 'skillTags'],
citycorners: ['cities', 'ccLocations', 'ccFavorites', 'ccLocationTags'],
times: [
'timeClients',
'timeProjects',
@ -193,16 +211,17 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
'timeAlarms',
'timeCountdownTimers',
'timeWorldClocks',
'entryTags',
],
context: ['contextSpaces', 'documents'],
questions: ['qCollections', 'questions', 'answers'],
nutriphi: ['meals', 'goals', 'nutriFavorites'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs'],
uload: ['links', 'uloadFolders', 'linkTags'],
context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'],
moodlit: ['moods', 'sequences'],
moodlit: ['moods', 'sequences', 'moodTags'],
memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
};
@ -265,6 +284,7 @@ export const TABLE_TO_SYNC_NAME: Record<string, string> = {
// memoro
memoroSpaces: 'spaces',
// uload
uloadTags: 'tags',
uloadFolders: 'folders',
// guides
guideCollections: 'collections',

View file

@ -25,7 +25,7 @@
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFolder>('linkFolders')
.table<LocalFolder>('uloadFolders')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {

View file

@ -5,11 +5,12 @@
*/
import { db } from '$lib/data/database';
import type { LocalLink, LocalFolder, LocalLinkTag } from './types';
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');
@ -79,5 +80,34 @@ export const ULOAD_GUEST_SEED = {
order: 1,
},
] satisfies LocalLink[],
uloadTags: [
{
id: 'tag-social',
name: 'Social Media',
slug: 'social-media',
color: '#8b5cf6',
icon: null,
isPublic: false,
usageCount: 0,
},
{
id: 'tag-docs',
name: 'Dokumentation',
slug: 'dokumentation',
color: '#3b82f6',
icon: null,
isPublic: false,
usageCount: 0,
},
{
id: 'tag-marketing',
name: 'Marketing',
slug: 'marketing',
color: '#10b981',
icon: null,
isPublic: false,
usageCount: 0,
},
] satisfies LocalTag[],
linkTags: [] as LocalLinkTag[],
};

View file

@ -21,6 +21,16 @@ export interface LocalLink extends BaseRecord {
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;
isPublic: boolean;
usageCount: number;
}
export interface LocalFolder extends BaseRecord {

View file

@ -1,135 +1,179 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { CaretLeft } from '@manacore/shared-icons';
import { useLinkById } from '$lib/modules/uload/queries';
import { authStore } from '$lib/stores/auth.svelte';
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);
// Analytics are server-side only; in the unified app we show local data
// and a placeholder for server analytics when available.
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>Analytics - uLoad - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<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 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Zurueck"
>
<CaretLeft size={20} />
<a href="/uload" class="rounded-lg p-2 transition-colors hover:bg-white/5" title="Zurueck">
<CaretLeft size={20} class="text-white/60" />
</a>
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
<h1 class="text-2xl font-bold text-white">Analytics</h1>
{#if link}
<p class="mt-1 text-sm opacity-60">
<span class="font-mono text-indigo-600">/{link.shortCode}</span>
<p class="mt-1 text-sm text-white/50">
<span class="font-mono text-indigo-400">/{link.shortCode}</span>
&rarr; <span class="truncate">{link.originalUrl}</span>
</p>
{/if}
</div>
</div>
{#if !link}
{#if loading}
<div class="space-y-4">
{#each Array(4) as _}
<div class="h-32 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800"></div>
<div class="h-32 animate-pulse rounded-xl bg-white/5"></div>
{/each}
</div>
{:else if !link}
<div class="rounded-xl border border-white/10 p-12 text-center">
<p class="text-white/50">Link nicht gefunden</p>
</div>
{:else}
<!-- Stats Overview -->
<div class="mb-6 grid gap-4 sm:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Clicks</p>
<p class="mt-1 text-3xl font-bold">{link.clickCount}</p>
<div class="rounded-xl border border-white/10 bg-white/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-white/40">Clicks</p>
<p class="mt-1 text-3xl font-bold text-white">
{stats?.totalClicks ?? link.clickCount}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Status</p>
<div class="rounded-xl border border-white/10 bg-white/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-white/40">Unique</p>
<p class="mt-1 text-3xl font-bold text-white">
{stats?.uniqueVisitors ?? '-'}
</p>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-white/40">Status</p>
<p class="mt-1 text-3xl font-bold">
{#if link.isActive}
<span class="text-green-500">Aktiv</span>
<span class="text-green-400">Aktiv</span>
{:else}
<span class="text-gray-400">Inaktiv</span>
<span class="text-white/30">Inaktiv</span>
{/if}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Erstellt</p>
<p class="mt-1 text-lg font-bold">
<div class="rounded-xl border border-white/10 bg-white/5 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-white/40">Erstellt</p>
<p class="mt-1 text-lg font-bold text-white">
{new Date(link.createdAt).toLocaleDateString('de')}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Short URL</p>
<p class="mt-1 truncate font-mono text-sm text-indigo-600">
ulo.ad/{link.shortCode}
</p>
</div>
</div>
<!-- Link Details -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Link Details</h2>
<div class="mb-6 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Link Details</h2>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Ziel-URL</span>
<span class="text-white/50">Ziel-URL</span>
<a
href={link.originalUrl}
target="_blank"
rel="noopener noreferrer"
class="max-w-md truncate text-indigo-600 hover:underline"
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="opacity-60">Titel</span>
<span>{link.title}</span>
<span class="text-white/50">Titel</span>
<span class="text-white">{link.title}</span>
</div>
{/if}
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<div class="border-t border-gray-100 pt-3 dark:border-gray-700">
<p class="mb-2 text-xs font-medium uppercase tracking-wider opacity-50">
<div class="border-t border-white/10 pt-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-white/40">
UTM-Parameter
</p>
<div class="grid gap-2 sm:grid-cols-3">
{#if link.utmSource}
<div class="text-sm">
<span class="opacity-50">Source:</span>
<div class="text-sm text-white/70">
<span class="text-white/40">Source:</span>
{link.utmSource}
</div>
{/if}
{#if link.utmMedium}
<div class="text-sm">
<span class="opacity-50">Medium:</span>
<div class="text-sm text-white/70">
<span class="text-white/40">Medium:</span>
{link.utmMedium}
</div>
{/if}
{#if link.utmCampaign}
<div class="text-sm">
<span class="opacity-50">Campaign:</span>
<div class="text-sm text-white/70">
<span class="text-white/40">Campaign:</span>
{link.utmCampaign}
</div>
{/if}
@ -138,52 +182,152 @@
{/if}
{#if link.expiresAt}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Laeuft ab</span>
<span>{new Date(link.expiresAt).toLocaleDateString('de')}</span>
<span class="text-white/50">Laeuft ab</span>
<span class="text-white">{new Date(link.expiresAt).toLocaleDateString('de')}</span>
</div>
{/if}
{#if link.maxClicks}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Max Klicks</span>
<span>{link.clickCount} / {link.maxClicks}</span>
<span class="text-white/50">Max Klicks</span>
<span class="text-white">{link.clickCount} / {link.maxClicks}</span>
</div>
{/if}
{#if link.password}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Passwortgeschuetzt</span>
<span>Ja</span>
<span class="text-white/50">Passwortgeschuetzt</span>
<span class="text-white">Ja</span>
</div>
{/if}
</div>
</div>
<!-- Timeline Placeholder -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<!-- Timeline -->
<div class="mb-6 rounded-xl border border-white/10 bg-white/5 p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Clicks ueber Zeit</h2>
<h2 class="text-lg font-semibold text-white">Clicks ueber Zeit</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-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'}"
: 'bg-white/10 text-white/60 hover:bg-white/15'}"
>
{d}T
</button>
{/each}
</div>
</div>
<div class="py-8 text-center">
<p class="text-sm opacity-40">
Detaillierte Analytics sind verfuegbar, wenn der uLoad-Server verbunden ist.
</p>
<p class="mt-1 text-xs opacity-30">
Lokaler Click-Count: {link.clickCount}
</p>
</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-white/90 px-2 py-1 text-xs text-gray-900 group-hover:block"
>
{day.count}
</div>
</div>
{/each}
</div>
<div class="mt-1 flex justify-between text-xs text-white/30">
<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-white/40">
Detaillierte Analytics sind verfuegbar, wenn der uLoad-Server verbunden ist.
</p>
<p class="mt-1 text-xs text-white/25">
Lokaler Click-Count: {link.clickCount}
</p>
</div>
{:else}
<p class="py-8 text-center text-sm text-white/40">Noch keine Daten fuer diesen Zeitraum</p>
{/if}
</div>
<!-- Breakdown Grid -->
{#if serverAvailable}
<div class="grid gap-6 md:grid-cols-3">
<!-- Devices -->
<div class="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Geraete</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-white/70">{d.deviceType || 'Unbekannt'}</span>
<span class="font-medium text-white">
{Math.round((d.count / totalDevices) * 100)}%
</span>
</div>
<div class="h-2 rounded-full bg-white/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-white/40">Keine Daten</p>
{/if}
</div>
<!-- Referrers -->
<div class="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Referrer</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-white/70">
{r.referer || 'Direkt'}
</span>
<span class="font-medium tabular-nums text-white">{r.count}</span>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-white/40">Keine Daten</p>
{/if}
</div>
<!-- Countries -->
<div class="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Laender</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-white/70">{c.country || 'Unbekannt'}</span>
<span class="font-medium text-white">
{Math.round((c.count / totalCountries) * 100)}%
</span>
</div>
<div class="h-2 rounded-full bg-white/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-white/40">Keine Daten</p>
{/if}
</div>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { ArrowLeft, Trash, DownloadSimple } from '@manacore/shared-icons';
import { linkTable, uloadTagTable, uloadFolderTable, linkTagTable } from '$lib/modules/uload';
import { useAllLinks, useAllTags, useAllFolders } from '$lib/modules/uload';
import { toast } from 'svelte-sonner';
const links = useAllLinks();
const tags = useAllTags();
const folders = useAllFolders();
async function clearAllData() {
if (!confirm('Alle lokalen uLoad-Daten loeschen? Dies kann nicht rueckgaengig gemacht werden.'))
return;
await linkTable.clear();
await uloadTagTable.clear();
await uloadFolderTable.clear();
await linkTagTable.clear();
toast.success('Alle uLoad-Daten geloescht');
}
async function exportData() {
const allLinks = await linkTable.toArray();
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('Export heruntergeladen');
}
</script>
<svelte:head>
<title>Settings - uLoad - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl p-4">
<div class="mb-6 flex items-center gap-3">
<a href="/uload" class="rounded-lg p-2 transition-colors hover:bg-white/5">
<ArrowLeft size={20} class="text-white/60" />
</a>
<h1 class="text-2xl font-bold text-white">uLoad Einstellungen</h1>
</div>
<!-- Data Overview -->
<div class="mb-6 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-4 text-lg font-semibold text-white">Daten</h2>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-2xl font-bold text-white">{links.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Links</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{tags.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Tags</p>
</div>
<div>
<p class="text-2xl font-bold text-white">{folders.value?.length ?? 0}</p>
<p class="text-sm text-white/40">Ordner</p>
</div>
</div>
</div>
<!-- Export -->
<div class="mb-4 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 class="mb-2 text-lg font-semibold text-white">Daten exportieren</h2>
<p class="mb-4 text-sm text-white/40">Exportiere alle Links, Tags und Ordner als JSON-Datei.</p>
<button
onclick={exportData}
class="flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15"
>
<DownloadSimple size={18} />
JSON exportieren
</button>
</div>
<!-- Danger Zone -->
<div class="rounded-xl border border-red-500/20 bg-red-500/5 p-6">
<h2 class="mb-2 text-lg font-semibold text-red-400">Gefahrenzone</h2>
<p class="mb-4 text-sm text-white/40">
Loescht alle lokalen uLoad-Daten (Links, Tags, Ordner). Synchronisierte Daten auf dem Server
bleiben erhalten.
</p>
<button
onclick={clearAllData}
class="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
>
<Trash size={18} />
Alle Daten loeschen
</button>
</div>
</div>

View file

@ -0,0 +1,164 @@
<script lang="ts">
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 '@manacore/shared-icons';
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;
await uloadTagTable.add({
id: crypto.randomUUID(),
name: newName.trim(),
slug: slugify(newName),
color: newColor,
icon: null,
isPublic: false,
usageCount: 0,
} as LocalTag);
toast.success(`Tag "${newName}" erstellt`);
newName = '';
newColor = '#6366f1';
showCreateForm = false;
}
async function deleteTag(tag: { id: string; name: string }) {
await uloadTagTable.delete(tag.id);
toast.success(`Tag "${tag.name}" geloescht`);
}
async function updateTag() {
if (!editingTag) return;
await uloadTagTable.update(editingTag.id, {
name: editingTag.name,
slug: slugify(editingTag.name),
color: editingTag.color,
});
toast.success('Tag aktualisiert');
editingTag = null;
}
</script>
<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-white/5">
<ArrowLeft size={20} class="text-white/60" />
</a>
<h1 class="text-2xl font-bold text-white">Tags</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'}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-xl border border-white/10 bg-white/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-white/60">Name</label>
<input
id="tag-name"
type="text"
bind:value={newName}
placeholder="z.B. Social Media"
class="w-full rounded-lg border border-white/10 bg-white/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-white/60">Farbe</label>
<input
id="tag-color"
type="color"
bind:value={newColor}
class="h-10 w-16 cursor-pointer rounded-lg border border-white/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"
>
Erstellen
</button>
</div>
</div>
{/if}
{#if !tags.value || tags.value.length === 0}
<div class="rounded-xl border-2 border-dashed border-white/10 p-12 text-center">
<p class="text-lg font-medium text-white/60">Noch keine Tags</p>
<p class="mt-1 text-sm text-white/40">Erstelle Tags um deine Links zu organisieren.</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-white/10 bg-white/5 p-4 transition-all hover:bg-white/8"
>
{#if editingTag?.id === tag.id}
<div class="space-y-3">
<input
type="text"
bind:value={editingTag.name}
class="w-full rounded border border-white/10 bg-white/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">Speichern</button
>
<button
onclick={() => (editingTag = null)}
class="rounded border border-white/10 px-3 py-1 text-sm text-white/60"
>Abbrechen</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-white/40">{getUsageCount(tag.id)} Links</span>
<button
onclick={() => (editingTag = { id: tag.id, name: tag.name, color: tag.color })}
class="rounded p-1 text-white/40 opacity-0 transition-all hover:bg-white/10 hover:text-white group-hover:opacity-100"
>
<PencilSimple size={16} />
</button>
<button
onclick={() => deleteTag(tag)}
class="rounded p-1 text-white/40 opacity-0 transition-all 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>