mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 15:19:40 +02:00
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:
parent
8c98dd871d
commit
be20de23db
7 changed files with 567 additions and 93 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
→ <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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
164
apps/manacore/apps/web/src/routes/(app)/uload/tags/+page.svelte
Normal file
164
apps/manacore/apps/web/src/routes/(app)/uload/tags/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue