chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -0,0 +1,94 @@
import type { LayoutServerLoad } from './$types';
import type { Workspace, WorkspaceMember } from '$lib/stores/workspaces';
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) {
return {
user: null,
personalWorkspace: null,
teamWorkspaces: [],
currentWorkspaceId: null,
// Keep old fields for backwards compatibility during migration
sharedAccounts: [],
viewingAs: null,
};
}
try {
// Get or create personal workspace
let personalWorkspace: Workspace | null = null;
try {
const personalWorkspaces = await locals.pb.collection('workspaces').getList<Workspace>(1, 1, {
filter: `owner="${locals.user.id}" && type="personal"`,
sort: 'created',
});
if (personalWorkspaces.items.length > 0) {
personalWorkspace = personalWorkspaces.items[0];
} else {
// Create personal workspace if it doesn't exist
personalWorkspace = await locals.pb.collection('workspaces').create<Workspace>({
name: `${locals.user.name || locals.user.email}'s Workspace`,
owner: locals.user.id,
type: 'personal',
subscription_status: locals.user.subscription_status || 'free',
});
}
} catch (error) {
console.error('Error managing personal workspace:', error);
}
// Get team workspaces where user is a member
let teamWorkspaces: Workspace[] = [];
try {
const memberships = await locals.pb
.collection('workspace_members')
.getList<WorkspaceMember>(1, 50, {
filter: `user="${locals.user.id}" && invitation_status="accepted"`,
expand: 'workspace',
sort: 'created',
});
teamWorkspaces = memberships.items
.filter((m) => m.expand?.workspace)
.map((m) => m.expand!.workspace as Workspace);
} catch (error) {
console.error('Error loading team workspaces:', error);
}
// Get current workspace from URL or default to personal
const currentWorkspaceId = url.searchParams.get('workspace') || personalWorkspace?.id || null;
// Keep backwards compatibility with old shared_access system
const sharedAccounts = await locals.pb
.collection('shared_access')
.getList(1, 50, {
filter: `user="${locals.user.id}" && invitation_status="accepted"`,
expand: 'owner',
sort: 'created',
})
.catch(() => ({ items: [] }));
const viewingAs = url.searchParams.get('viewing_as') || locals.user.id;
return {
user: locals.user,
personalWorkspace,
teamWorkspaces,
currentWorkspaceId,
// Keep old fields for backwards compatibility
sharedAccounts: sharedAccounts.items,
viewingAs,
};
} catch (error) {
console.error('Error loading workspaces:', error);
return {
user: locals.user,
personalWorkspace: null,
teamWorkspaces: [],
currentWorkspaceId: null,
sharedAccounts: [],
viewingAs: locals.user.id,
};
}
};

View file

@ -0,0 +1,147 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import FloatingSidebar from '$lib/components/FloatingSidebar.svelte';
import MobileSidebar from '$lib/components/MobileSidebar.svelte';
import AccountSwitcher from '$lib/components/AccountSwitcher.svelte';
import WorkspaceSwitcher from '$lib/components/WorkspaceSwitcher.svelte';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { accountsStore } from '$lib/stores/accounts';
import { workspacesStore } from '$lib/stores/workspaces';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: any } = $props();
let sidebarCollapsed = $state(false);
let mounted = $state(false);
let mobileMenuOpen = $state(false);
// Watch for URL workspace parameter changes
$effect(() => {
const urlWorkspaceId = $page.url.searchParams.get('workspace');
if (urlWorkspaceId) {
// URL parameter takes precedence
activeWorkspace.initFromUrl(urlWorkspaceId);
}
});
onMount(() => {
mounted = true;
// Initialize both stores during migration
if (data.user) {
// Old accounts store for backwards compatibility
accountsStore.init(data.user, data.sharedAccounts || [], data.viewingAs);
// New workspaces store
workspacesStore.init(
data.user,
data.personalWorkspace,
data.teamWorkspaces || [],
data.currentWorkspaceId
);
// Initialize active workspace from URL or localStorage
const urlWorkspaceId = $page.url.searchParams.get('workspace');
if (urlWorkspaceId) {
activeWorkspace.initFromUrl(urlWorkspaceId);
// Try to find workspace data
const workspace =
data.teamWorkspaces?.find((w) => w.id === urlWorkspaceId) ||
(data.personalWorkspace?.id === urlWorkspaceId ? data.personalWorkspace : null);
if (workspace) {
activeWorkspace.set(workspace);
}
}
}
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('sidebar-collapsed');
if (stored !== null) {
sidebarCollapsed = stored === 'true';
}
// Listen for storage changes to sync sidebar state
const handleStorageChange = () => {
const stored = localStorage.getItem('sidebar-collapsed');
if (stored !== null) {
sidebarCollapsed = stored === 'true';
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}
});
</script>
<!-- Full screen background container -->
<div class="fixed inset-0 -z-10 bg-theme-background"></div>
<!-- Floating Sidebar for authenticated users on desktop -->
<FloatingSidebar user={data.user} />
<!-- Mobile Sidebar (overlay) -->
<MobileSidebar user={data.user} open={mobileMenuOpen} onClose={() => (mobileMenuOpen = false)} />
<!-- Top Navigation Bar with Menu Button for mobile/tablet -->
{#if data.user}
<nav
class="bg-theme-surface/80 sticky top-0 z-30 border-b border-theme-border shadow-sm backdrop-blur-xl lg:hidden"
>
<div class="mx-auto max-w-7xl px-4 sm:px-6">
<div class="flex h-16 items-center justify-between">
<!-- Logo & Menu Button -->
<div class="flex items-center gap-3">
<button
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="rounded-lg p-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
aria-label="Menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<a href="/" class="flex items-center space-x-2 transition-opacity hover:opacity-80">
<svg
class="h-8 w-8 text-theme-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<span class="text-xl font-bold text-theme-text">uload</span>
</a>
</div>
<!-- Notifications & Workspace Switcher -->
<div class="flex items-center gap-2">
<NotificationBell />
<WorkspaceSwitcher />
</div>
</div>
</div>
</nav>
{/if}
<!-- Main Content with responsive margin -->
<main
class="min-h-screen transition-all duration-300 {mounted && data.user
? sidebarCollapsed
? 'lg:pl-24'
: 'lg:pl-72'
: ''}"
>
{@render children?.()}
</main>

View file

@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
return {
user: locals.user,
};
};

View file

@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
// Redirect to links page, preserving any query parameters (like workspace)
const searchParams = url.searchParams.toString();
const redirectUrl = searchParams ? `/my/links?${searchParams}` : '/my/links';
throw redirect(302, redirectUrl);
};

View file

@ -0,0 +1 @@
<!-- This page redirects to /my/links via +page.server.ts -->

View file

@ -0,0 +1,141 @@
import { error } from '@sveltejs/kit';
import type { Link, Click } from '$lib/pocketbase';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => {
const { id } = params;
console.log('[Analytics] Loading analytics for ID/short_code:', id);
console.log('[Analytics] User:', locals.user?.id, locals.user?.email);
// Check if user is authenticated
if (!locals.user) {
console.log('[Analytics] User not authenticated');
error(401, 'You must be logged in to view analytics');
}
try {
// Try to get link by ID first, then by short_code if ID fails
let link;
try {
console.log('[Analytics] Trying to fetch by ID:', id);
link = await locals.pb.collection('links').getOne<Link>(id);
console.log('[Analytics] Found link by ID');
} catch (e) {
console.log('[Analytics] Not found by ID, trying by short_code:', id);
// If not found by ID, try by short_code
const linkList = await locals.pb.collection('links').getList(1, 1, {
filter: `short_code="${id}"`,
});
console.log('[Analytics] Search by short_code result:', linkList.items.length, 'items');
if (linkList.items.length === 0) {
console.log('[Analytics] Link not found by short_code either');
error(404, 'Link not found');
}
link = linkList.items[0];
console.log('[Analytics] Found link by short_code:', link.id);
}
// Check if user owns the link (check both user_id and created_by)
console.log(
'[Analytics] Checking ownership - Link user_id:',
link.user_id,
'created_by:',
link.created_by
);
console.log('[Analytics] Current user ID:', locals.user.id);
if (link.user_id !== locals.user.id && link.created_by !== locals.user.id) {
console.log('[Analytics] Access denied - user does not own this link');
error(403, 'You do not have access to this link');
}
console.log('[Analytics] Access granted');
const clicks = await locals.pb.collection('analytics').getList(1, 500, {
filter: `link="${link.id}"`,
sort: '-created',
});
// Helper function to extract browser from user agent
function getBrowserFromUserAgent(userAgent: string): string {
if (!userAgent) return 'Unknown';
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
if (userAgent.includes('Opera')) return 'Opera';
return 'Other';
}
const browserStats = clicks.items.reduce(
(acc, click) => {
const browser = getBrowserFromUserAgent(click.user_agent || '');
acc[browser] = (acc[browser] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const deviceStats = clicks.items.reduce(
(acc, click) => {
const device = click.device || 'Unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const refererStats = clicks.items.reduce(
(acc, click) => {
if (click.referer) {
try {
const url = new URL(click.referer);
const domain = url.hostname;
acc[domain] = (acc[domain] || 0) + 1;
} catch {
acc['Direct'] = (acc['Direct'] || 0) + 1;
}
} else {
acc['Direct'] = (acc['Direct'] || 0) + 1;
}
return acc;
},
{} as Record<string, number>
);
const clicksByDay = clicks.items.reduce(
(acc, click) => {
const date = new Date(click.created).toLocaleDateString();
acc[date] = (acc[date] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const clicksByHour = clicks.items.reduce(
(acc, click) => {
const hour = new Date(click.created).getHours();
acc[hour] = (acc[hour] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
return {
link,
totalClicks: clicks.totalItems,
recentClicks: clicks.items.slice(0, 10),
browserStats: Object.entries(browserStats).sort((a, b) => b[1] - a[1]),
deviceStats: Object.entries(deviceStats).sort((a, b) => b[1] - a[1]),
refererStats: Object.entries(refererStats)
.sort((a, b) => b[1] - a[1])
.slice(0, 10),
clicksByDay: Object.entries(clicksByDay).sort(
(a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime()
),
clicksByHour: Array.from({ length: 24 }, (_, i) => [i.toString(), clicksByHour[i] || 0]),
};
} catch (err) {
console.log('[Analytics] Error occurred:', err);
error(404, 'Link not found');
}
};

View file

@ -0,0 +1,341 @@
<script lang="ts">
import type { PageData } from './$types';
import {
generateQRCodeURL,
downloadQRCode,
type QRCodeColor,
type QRCodeFormat,
} from '$lib/qrcode';
import { trackEvent, EVENTS } from '$lib/analytics';
import { onMount } from 'svelte';
let { data }: { data: PageData } = $props();
let showQRCode = $state(false);
let qrColor: QRCodeColor = $state('black');
let qrFormat: QRCodeFormat = $state('png');
onMount(() => {
// Track analytics page view
trackEvent(EVENTS.ANALYTICS_VIEWED, {
short_code: data.link.short_code,
total_clicks: String(data.totalClicks),
});
});
function formatUrl(url: string) {
if (typeof window === 'undefined') return url;
return `${window.location.origin}/${url}`;
}
function downloadQR() {
const url = formatUrl(data.link.short_code);
downloadQRCode(url, `qrcode-${data.link.short_code}`, 400, qrColor, qrFormat);
}
function getBrowserFromUserAgent(userAgent: string): string {
if (!userAgent) return 'Unknown';
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
if (userAgent.includes('Opera')) return 'Opera';
return 'Other';
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b bg-white shadow-sm">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Link Analytics</h1>
<p class="mt-1 text-sm text-gray-600">{data.link.title || 'Untitled Link'}</p>
</div>
<a href="/my" class="text-sm text-blue-600 hover:text-blue-800"> ← Back to Dashboard </a>
</div>
</div>
</header>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-8 rounded-lg bg-white p-6 shadow-md">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 class="mb-2 text-sm font-medium text-gray-500">Short URL</h3>
<p class="text-lg font-medium text-blue-600">{formatUrl(data.link.short_code)}</p>
</div>
<div>
<h3 class="mb-2 text-sm font-medium text-gray-500">Original URL</h3>
<p class="truncate text-lg text-gray-900">{data.link.original_url}</p>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4">
<div>
<h3 class="mb-1 text-sm font-medium text-gray-500">Total Clicks</h3>
<p class="text-2xl font-bold text-gray-900">{data.totalClicks}</p>
</div>
<div>
<h3 class="mb-1 text-sm font-medium text-gray-500">Status</h3>
<p class="text-lg">
{#if data.link.is_active}
<span class="text-green-600">Active</span>
{:else}
<span class="text-red-600">Inactive</span>
{/if}
</p>
</div>
<div>
<h3 class="mb-1 text-sm font-medium text-gray-500">Created</h3>
<p class="text-lg">{new Date(data.link.created).toLocaleDateString()}</p>
</div>
<div>
<h3 class="mb-1 text-sm font-medium text-gray-500">Features</h3>
<div class="flex gap-2">
{#if data.link.password}
<span class="rounded bg-red-100 px-2 py-1 text-xs text-red-700">🔒 Protected</span>
{/if}
{#if data.link.expires_at}
<span class="rounded bg-orange-100 px-2 py-1 text-xs text-orange-700">⏰ Expires</span
>
{/if}
{#if data.link.max_clicks}
<span class="rounded bg-purple-100 px-2 py-1 text-xs text-purple-700">🔢 Limited</span
>
{/if}
</div>
</div>
</div>
</div>
<div class="mb-8 rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">QR Code</h2>
<button
onclick={() => (showQRCode = !showQRCode)}
class="text-sm text-purple-600 hover:text-purple-800"
>
{showQRCode ? 'Hide' : 'Show'} QR Code
</button>
</div>
{#if showQRCode}
<div class="flex flex-col items-center gap-4">
<img
src={generateQRCodeURL(formatUrl(data.link.short_code), 250, qrColor, 'png')}
alt="QR Code for {data.link.short_code}"
class="rounded border-2 border-gray-300 p-3"
style="background: {qrColor === 'white'
? '#000'
: qrColor === 'gold'
? '#000'
: '#fff'}"
/>
<div class="flex gap-6">
<div>
<span class="mb-2 block text-sm font-medium text-gray-700">QR Code Color</span>
<div class="flex gap-2" role="group" aria-label="QR Code Color">
<button
onclick={() => (qrColor = 'black')}
class="h-10 w-10 rounded border-2 bg-black {qrColor === 'black'
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-300'}"
aria-label="Black color"
></button>
<button
onclick={() => (qrColor = 'white')}
class="h-10 w-10 rounded border-2 bg-white {qrColor === 'white'
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-300'}"
aria-label="White color"
></button>
<button
onclick={() => (qrColor = 'gold')}
class="h-10 w-10 rounded border-2 {qrColor === 'gold'
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-300'}"
style="background: #f8d62b"
aria-label="Gold color"
></button>
</div>
</div>
<div>
<label for="format" class="mb-2 block text-sm font-medium text-gray-700"
>Download Format</label
>
<select
id="format"
bind:value={qrFormat}
class="rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="png">PNG (High Quality)</option>
<option value="svg">SVG (Vector)</option>
<option value="jpg">JPG (Compressed)</option>
</select>
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => downloadQR()}
class="rounded-md bg-purple-600 px-6 py-2 text-white transition duration-200 hover:bg-purple-700"
>
Download as {qrFormat.toUpperCase()}
</button>
<button
onclick={() => navigator.clipboard.writeText(formatUrl(data.link.short_code))}
class="rounded-md bg-gray-600 px-6 py-2 text-white transition duration-200 hover:bg-gray-700"
>
Copy URL
</button>
</div>
<p class="mt-2 text-center text-sm text-gray-600">
Scan this QR code to access the short link directly
</p>
</div>
{/if}
</div>
<div class="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div class="rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Browser Distribution</h2>
{#if data.browserStats.length > 0}
<div class="space-y-3">
{#each data.browserStats as [browser, count]}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700">{browser}</span>
<div class="flex items-center gap-2">
<div class="h-2 w-32 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-blue-600"
style="width: {(count / data.totalClicks) * 100}%"
></div>
</div>
<span class="w-12 text-right text-sm text-gray-600">{count}</span>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-gray-500">No data yet</p>
{/if}
</div>
<div class="rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Device Types</h2>
{#if data.deviceStats.length > 0}
<div class="space-y-3">
{#each data.deviceStats as [device, count]}
<div class="flex items-center justify-between">
<span class="text-sm capitalize text-gray-700">{device}</span>
<div class="flex items-center gap-2">
<div class="h-2 w-32 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-green-600"
style="width: {(count / data.totalClicks) * 100}%"
></div>
</div>
<span class="w-12 text-right text-sm text-gray-600">{count}</span>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-gray-500">No data yet</p>
{/if}
</div>
</div>
<div class="mb-8 rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Top Referrers</h2>
{#if data.refererStats.length > 0}
<div class="space-y-2">
{#each data.refererStats as [referrer, count]}
<div class="flex items-center justify-between border-b py-2 last:border-0">
<span class="text-sm text-gray-700">{referrer}</span>
<span class="text-sm text-gray-600">{count} clicks</span>
</div>
{/each}
</div>
{:else}
<p class="text-gray-500">No referrer data yet</p>
{/if}
</div>
<div class="mb-8 rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Clicks by Day</h2>
{#if data.clicksByDay.length > 0}
<div class="overflow-x-auto">
<div class="flex min-w-max gap-2">
{#each data.clicksByDay as [day, count]}
<div class="flex flex-col items-center">
<div
class="w-8 rounded-t bg-blue-600"
style="height: {Math.max(
20,
(count / Math.max(...data.clicksByDay.map((d) => d[1] as number))) * 100
)}px"
title="{count} clicks"
></div>
<span class="mt-1 origin-left rotate-45 text-xs text-gray-600">{day}</span>
</div>
{/each}
</div>
</div>
{:else}
<p class="text-gray-500">No daily data yet</p>
{/if}
</div>
<div class="rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Recent Clicks</h2>
{#if data.recentClicks.length > 0}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Time</th
>
<th class="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500"
>Browser</th
>
<th class="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500"
>Device</th
>
<th class="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500"
>Referrer</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.recentClicks as click}
<tr>
<td class="px-4 py-2 text-sm text-gray-900">
{new Date(click.created).toLocaleString()}
</td>
<td class="px-4 py-2 text-sm text-gray-900"
>{getBrowserFromUserAgent(click.user_agent) || 'Unknown'}</td
>
<td class="px-4 py-2 text-sm capitalize text-gray-900"
>{click.device || 'Unknown'}</td
>
<td class="px-4 py-2 text-sm text-gray-900">
{#if click.referer}
{@const url = new URL(click.referer)}
{url.hostname}
{:else}
Direct
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-gray-500">No clicks yet</p>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// Simple and clean - just return the user data
// The parent layout already handles authentication
// Cards are loaded client-side anyway
return {
user: locals.user,
userCards: [], // Empty array, client will load cards
};
};

View file

@ -0,0 +1,403 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import type { Card } from '$lib/components/cards/types';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import SafeCardRenderer from '$lib/components/cards/SafeCardRenderer.svelte';
let { data }: { data: PageData } = $props();
// State
let userCards = $state<Card[]>([]);
let loading = $state(true);
let showStats = $state(false);
let isDragging = $state(false);
let draggedIndex = $state<number | null>(null);
let dropTargetIndex = $state<number | null>(null);
let showDeleteConfirm = $state(false);
let cardToDelete = $state<string | null>(null);
// Load user's cards
async function loadUserCards() {
if (!browser) return;
console.log('🔍 Loading all user cards...');
// Wait for PocketBase auth to be initialized
const { pb } = await import('$lib/pocketbase');
let authCheckAttempts = 0;
const maxAuthChecks = 10;
while (!pb.authStore.isValid && authCheckAttempts < maxAuthChecks) {
console.log(
`⏳ Waiting for auth initialization (attempt ${authCheckAttempts + 1}/${maxAuthChecks})`
);
await new Promise((resolve) => setTimeout(resolve, 100));
authCheckAttempts++;
}
if (!pb.authStore.isValid) {
console.error('❌ Auth not valid after waiting - aborting card load');
loading = false;
return;
}
console.log('✅ Auth is valid, proceeding with card load');
loading = true;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const cards = await unifiedCardService.getUserCards();
console.log('📦 Received cards from service:', cards);
console.log('📊 Number of cards:', cards.length);
userCards = cards;
loading = false;
} catch (error) {
console.error('❌ Error loading user cards:', error);
loading = false;
}
}
// Create new card
function createNewCard() {
console.log('🚀 createNewCard() called - Navigating to card builder...');
goto('/my/cards/builder');
}
// Edit card
function editCard(card: Card) {
if (card.id) {
goto(`/my/cards/builder?id=${card.id}`);
}
}
// Delete card
async function deleteCard(cardId: string) {
if (!browser) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const success = await unifiedCardService.deleteCard(cardId);
if (success) {
await loadUserCards();
showDeleteConfirm = false;
cardToDelete = null;
}
} catch (error) {
console.error('Failed to delete card:', error);
}
}
// Duplicate card
async function duplicateCard(card: Card) {
if (!browser || !card.id) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const newCard = await unifiedCardService.duplicateCard(card.id);
if (newCard) {
await loadUserCards();
}
} catch (error) {
console.error('Failed to duplicate card:', error);
}
}
// Toggle card visibility
async function toggleCardVisibility(card: Card) {
if (!browser || !card.id) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const updatedCard = {
...card,
metadata: {
...card.metadata,
is_active: !card.metadata?.is_active,
},
};
await unifiedCardService.updateCard(card.id, updatedCard);
await loadUserCards();
} catch (error) {
console.error('Failed to toggle visibility:', error);
}
}
// Toggle profile display
async function toggleProfileDisplay(card: Card) {
if (!browser || !card.id) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const newPage = card.page === 'profile' ? null : 'profile';
const updatedCard = {
...card,
page: newPage,
visibility: newPage === 'profile' ? 'public' : card.visibility,
};
await unifiedCardService.updateCard(card.id, updatedCard);
await loadUserCards();
} catch (error) {
console.error('Failed to toggle profile display:', error);
}
}
// Drag and drop handlers
function handleDragStart(event: DragEvent, index: number) {
isDragging = true;
draggedIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', '');
}
}
function handleDragOver(event: DragEvent, index: number) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
dropTargetIndex = index;
}
function handleDragLeave() {
dropTargetIndex = null;
}
function handleDrop(event: DragEvent, dropIndex: number) {
event.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
const newCards = [...userCards];
const [draggedCard] = newCards.splice(draggedIndex, 1);
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex;
newCards.splice(adjustedDropIndex, 0, draggedCard);
userCards = newCards;
// Update positions in backend
updateCardPositions();
}
isDragging = false;
draggedIndex = null;
dropTargetIndex = null;
}
function handleDragEnd() {
isDragging = false;
draggedIndex = null;
dropTargetIndex = null;
}
// Update card positions in backend
async function updateCardPositions() {
if (!browser) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const updates = userCards.map((card, index) => {
if (card.id) {
return unifiedCardService.updateCard(card.id, { position: index });
}
return Promise.resolve();
});
await Promise.all(updates);
} catch (error) {
console.error('Error updating positions:', error);
}
}
onMount(() => {
loadUserCards();
});
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">Profile Cards</h1>
<div class="flex gap-2">
<button
onclick={() => (showStats = !showStats)}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
title="Toggle Stats"
>
Stats
</button>
<button
onclick={() => createNewCard()}
class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
>
+ New Card
</button>
</div>
</div>
<!-- Quick Stats -->
{#if showStats}
<div class="mb-6 grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<p class="text-2xl font-bold text-theme-text">{userCards?.length || 0}</p>
<p class="text-sm text-theme-text-muted">Total Cards</p>
<p class="text-xs text-theme-accent">
{userCards?.filter((c) => c.page === 'profile').length || 0} on profile
</p>
</div>
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<p class="text-2xl font-bold text-theme-text">
{userCards?.filter((c) => c.metadata?.is_active !== false).length || 0}
</p>
<p class="text-sm text-theme-text-muted">Active Cards</p>
</div>
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<a href="/p/{data.user?.username || data.user?.id}" target="_blank" class="group">
<p class="text-lg font-semibold text-theme-text group-hover:underline">View Profile</p>
<p class="text-sm text-theme-text-muted">See how it looks</p>
</a>
</div>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-12">
<p class="text-theme-text-muted">Loading cards...</p>
</div>
{:else if userCards.length > 0}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-theme-text">Your Profile Cards</h2>
<p class="text-sm text-theme-text-muted">
Drag to reorder. Cards will appear in this order on your profile.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each userCards as card, index}
<div
class="relative rounded-lg border-2 bg-theme-surface p-4 transition-all {dropTargetIndex ===
index
? 'border-theme-primary'
: 'border-theme-border'} {isDragging && draggedIndex === index ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, index)}
ondragover={(e) => handleDragOver(e, index)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, index)}
ondragend={handleDragEnd}
>
<!-- Drag handle -->
<div class="absolute left-2 top-2 cursor-move text-theme-text-muted">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/>
</svg>
</div>
<!-- Card Preview -->
<div class="ml-8">
<SafeCardRenderer {card} compact={true} className="mb-4" />
<!-- Status badges -->
<div class="mb-3 flex flex-wrap gap-2">
{#if card.page === 'profile'}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800"
>
On Profile
</span>
{:else}
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-800"
>
Not on Profile
</span>
{/if}
{#if card.metadata?.is_active === false}
<span
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-800"
>
Hidden
</span>
{/if}
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-2">
<button
onclick={() => editCard(card)}
class="text-sm text-theme-primary hover:underline"
>
Edit
</button>
<button
onclick={() => duplicateCard(card)}
class="text-sm text-theme-primary hover:underline"
>
Duplicate
</button>
<button
onclick={() => toggleProfileDisplay(card)}
class="text-sm text-theme-primary hover:underline"
>
{card.page === 'profile' ? 'Remove from Profile' : 'Add to Profile'}
</button>
<button
onclick={() => toggleCardVisibility(card)}
class="text-sm text-theme-primary hover:underline"
>
{card.metadata?.is_active === false ? 'Show' : 'Hide'}
</button>
<button
onclick={() => {
cardToDelete = card.id || null;
showDeleteConfirm = true;
}}
class="text-sm text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<h3 class="mb-2 text-lg font-medium text-theme-text">No cards yet</h3>
<p class="mb-6 text-theme-text-muted">Create your first card to get started</p>
<button
onclick={() => createNewCard()}
class="inline-flex items-center rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
>
Create Your First Card
</button>
</div>
{/if}
</div>
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm && cardToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="max-w-md rounded-lg border border-theme-border bg-theme-surface p-6">
<h3 class="mb-4 text-lg font-semibold text-theme-text">Delete Card</h3>
<p class="mb-6 text-sm text-theme-text-muted">
Are you sure you want to delete this card? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => {
showDeleteConfirm = false;
cardToDelete = null;
}}
class="rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
<button
onclick={() => cardToDelete && deleteCard(cardToDelete)}
class="rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700"
>
Delete Card
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,482 @@
<script lang="ts">
import type { PageData } from './$types';
import ProfileCardItem from '$lib/components/cards/ProfileCardItem.svelte';
import { goto } from '$app/navigation';
import type { Card } from '$lib/components/cards/types';
import { browser } from '$app/environment';
let { data }: { data: PageData } = $props();
// State - start empty since server doesn't load cards anymore
let userCards = $state<Card[]>([]);
let loading = $state(true); // Start as true, will load on client
let isDragging = $state(false);
let draggedIndex = $state<number | null>(null);
let dropTargetIndex = $state<number | null>(null);
let showDeleteConfirm = $state(false);
let cardToDelete = $state<string | null>(null);
let showStats = $state(false);
let showProfileAppearance = $state(false);
// Load user's cards
async function loadUserCards() {
if (!browser) return;
console.log('🔍 Loading all user cards...');
// Wait for PocketBase auth to be initialized
const { pb } = await import('$lib/pocketbase');
let authCheckAttempts = 0;
const maxAuthChecks = 10;
while (!pb.authStore.isValid && authCheckAttempts < maxAuthChecks) {
console.log(`⏳ Waiting for auth initialization (attempt ${authCheckAttempts + 1}/${maxAuthChecks})`);
await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms
authCheckAttempts++;
}
if (!pb.authStore.isValid) {
console.error('❌ Auth not valid after waiting - aborting card load');
loading = false;
return;
}
console.log('✅ Auth is valid, proceeding with card load');
loading = true;
try {
// Dynamically import services only on client side
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
// Load ALL cards, not just profile ones
const cards = await unifiedCardService.getUserCards();
console.log('📦 Received cards from service:', cards);
console.log('📊 Number of cards:', cards.length);
userCards = cards;
loading = false;
} catch (error) {
console.error('❌ Error loading user cards:', error);
loading = false;
}
}
// Remove card from profile
async function removeCard(cardId: string) {
if (!browser) return;
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const success = await unifiedCardService.deleteCard(cardId);
if (success) {
await loadUserCards();
showDeleteConfirm = false;
cardToDelete = null;
}
}
// Toggle card visibility
async function toggleCardVisibility(card: Card) {
if (!browser) return;
if (card.id) {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const updatedCard = {
...card,
metadata: {
...card.metadata,
is_active: !card.metadata?.is_active
}
};
await unifiedCardService.updateCard(card.id, updatedCard);
await loadUserCards();
}
}
// Toggle profile display
async function toggleProfileDisplay(card: Card) {
if (!browser) return;
if (card.id) {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const newPage = card.page === 'profile' ? null : 'profile';
const updatedCard = {
...card,
page: newPage,
// Automatically set to public if enabling profile display
visibility: newPage === 'profile' ? 'public' : card.visibility
};
await unifiedCardService.updateCard(card.id, updatedCard);
await loadUserCards();
}
}
// Update card position
async function updateCardPositions() {
if (!browser) return;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
// Update all card positions
const updates = userCards.map((card, index) => {
if (card.id) {
return unifiedCardService.updateCard(card.id, {
position: index
});
}
return Promise.resolve();
});
await Promise.all(updates);
} catch (error) {
console.error('Error updating positions:', error);
}
}
// Drag and drop handlers
function handleDragStart(event: DragEvent, index: number) {
isDragging = true;
draggedIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', '');
}
}
function handleDragOver(event: DragEvent, index: number) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
dropTargetIndex = index;
}
function handleDragLeave() {
dropTargetIndex = null;
}
function handleDrop(event: DragEvent, dropIndex: number) {
event.preventDefault();
if (draggedIndex !== null && draggedIndex !== dropIndex) {
const newCards = [...userCards];
const [draggedCard] = newCards.splice(draggedIndex, 1);
// Adjust drop index if dragging from before to after
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex;
newCards.splice(adjustedDropIndex, 0, draggedCard);
userCards = newCards;
updateCardPositions();
}
isDragging = false;
draggedIndex = null;
dropTargetIndex = null;
}
function handleDragEnd() {
isDragging = false;
draggedIndex = null;
dropTargetIndex = null;
}
// Edit card configuration
function editCard(card: Card) {
if (card.id) {
goto(`/my/cards/builder?id=${card.id}`);
}
}
// Create new card
function createNewCard() {
console.log('🚀 createNewCard() called - Navigating to card builder...');
goto('/my/cards/builder');
}
// Duplicate card
async function duplicateCard(card: Card) {
if (!browser) return;
if (card.id) {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const newCard = await unifiedCardService.duplicateCard(card.id);
if (newCard) {
await loadUserCards();
}
}
}
// Load additional cards on client side if needed (e.g., after operations)
// Initial load is handled by server-side data
// Only run client-side operations when in browser
import { onMount } from 'svelte';
onMount(() => {
// Component is mounted in browser - load cards
loadUserCards();
});
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">Profile Cards</h1>
<div class="flex gap-2">
<button
onclick={() => showStats = !showStats}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
title="Toggle Stats"
aria-label="Toggle Statistics"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</button>
<button
onclick={() => showProfileAppearance = !showProfileAppearance}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
title="Profile Appearance"
aria-label="Toggle Profile Appearance"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a1 1 0 00-1-1h-3.382a1 1 0 00-.894.553l-1.448 2.894a1 1 0 01-.894.553h-1.764a1 1 0 01-.894-.553l-1.448-2.894A1 1 0 008.382 14H5a1 1 0 00-1 1v2a4 4 0 004 4z" />
</svg>
</button>
<button
onclick={() => createNewCard()}
class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
>
+ New Card
</button>
</div>
</div>
<!-- Quick Stats -->
{#if showStats}
<div class="mb-6 grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-2xl font-bold text-theme-text">{userCards?.length || 0}</p>
<p class="text-sm text-theme-text-muted">Total Cards</p>
<p class="text-xs text-theme-accent">{userCards?.filter(c => c.page === 'profile').length || 0} on profile</p>
</div>
<svg class="h-8 w-8 text-theme-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
</div>
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-2xl font-bold text-theme-text">{userCards?.filter(c => c.metadata?.isActive !== false).length || 0}</p>
<p class="text-sm text-theme-text-muted">Active Cards</p>
</div>
<svg
class="h-8 w-8 text-theme-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<div class="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-sm">
<a
href="/p/{data.user?.username || data.user?.id}"
target="_blank"
class="group flex items-center justify-between"
>
<div>
<p class="text-lg font-semibold text-theme-text group-hover:underline">View Profile</p>
<p class="text-sm text-theme-text-muted">See how it looks</p>
</div>
<svg class="h-8 w-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
{/if}
<!-- Profile Background Color Setting -->
{#if showProfileAppearance}
<div class="mb-6 rounded-lg border border-theme-border bg-theme-surface p-6 shadow-sm">
<h3 class="mb-4 text-lg font-semibold text-theme-text">Profile Appearance</h3>
<div class="flex items-center gap-3">
<label for="profileBackground" class="text-sm font-medium text-theme-text">
Profile Background
</label>
<input
type="color"
id="profileBackground"
name="profileBackground"
value={data.user?.profileBackground || '#f9fafb'}
onchange={async (e) => {
const color = e.currentTarget.value;
try {
const response = await fetch('/settings?/updateProfile', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
profileBackground: color,
name: data.user?.name || '',
email: data.user?.email || '',
bio: data.user?.bio || '',
location: data.user?.location || '',
website: data.user?.website || '',
github: data.user?.github || '',
twitter: data.user?.twitter || '',
linkedin: data.user?.linkedin || '',
instagram: data.user?.instagram || ''
})
});
if (response.ok) {
// Update local state
if (data.user) {
data.user.profileBackground = color;
}
}
} catch (error) {
console.error('Failed to update profile background:', error);
}
}}
class="h-10 w-20 cursor-pointer rounded border border-theme-border"
/>
<select
onchange={(e) => {
const input = document.getElementById('profileBackground') as HTMLInputElement;
if (input && e.currentTarget.value) {
input.value = e.currentTarget.value;
input.dispatchEvent(new Event('change'));
}
}}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text"
>
<option value="">Custom Color</option>
<option value="#f9fafb">Light Gray (Default)</option>
<option value="#dbeafe">Light Blue</option>
<option value="#dcfce7">Light Green</option>
<option value="#fef3c7">Light Yellow</option>
<option value="#fce7f3">Light Pink</option>
<option value="#e9d5ff">Light Purple</option>
<option value="#1f2937">Dark Gray</option>
<option value="#0f172a">Dark Blue</option>
<option value="#000000">Black</option>
</select>
<span class="text-sm text-theme-text-muted">
Choose a color for your profile page background
</span>
</div>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-12">
<p class="text-theme-text-muted">Loading cards...</p>
</div>
{:else if userCards.length > 0}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-theme-text">Your Profile Cards</h2>
<p class="text-sm text-theme-text-muted">
Drag to reorder. Cards will appear in this order on your profile.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each userCards as card, index}
<ProfileCardItem
{card}
{index}
{isDragging}
{dropTargetIndex}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onEdit={editCard}
onDuplicate={duplicateCard}
onToggleVisibility={toggleCardVisibility}
onToggleProfileDisplay={toggleProfileDisplay}
onDelete={(cardId) => {
cardToDelete = cardId;
showDeleteConfirm = true;
}}
/>
{/each}
</div>
</div>
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<h3 class="mb-2 text-lg font-medium text-theme-text">No cards on your profile yet</h3>
<p class="mb-6 text-theme-text-muted">
Create cards with our visual drag-and-drop builder
</p>
<button
onclick={() => createNewCard()}
class="inline-flex items-center rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
>
Create Your First Card
</button>
</div>
{/if}
</div>
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm && cardToDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="max-w-md rounded-lg bg-theme-surface border border-theme-border p-6">
<h3 class="mb-4 text-lg font-semibold text-theme-text">Delete Card</h3>
<p class="mb-6 text-sm text-theme-text-muted">
Are you sure you want to delete this card? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => {
showDeleteConfirm = false;
cardToDelete = null;
}}
class="rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
<button
onclick={() => cardToDelete && removeCard(cardToDelete)}
class="rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700"
>
Delete Card
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// Redirect to login if not authenticated
if (!locals.user) {
throw redirect(302, '/login');
}
return {
user: locals.user,
};
};

View file

@ -0,0 +1,676 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { Card } from '$lib/components/cards/types';
import { page } from '$app/stores';
import { browser } from '$app/environment';
// State
let card = $state<Card>({
type: 'user',
config: {
mode: 'beginner',
modules: [
{
id: 'header-default',
type: 'header',
order: 0,
props: {
title: 'Dein Name',
subtitle: 'Deine Position',
avatar: '',
},
},
{
id: 'links-default',
type: 'links',
order: 1,
props: {
links: [],
style: 'button',
layout: 'vertical',
},
},
],
layout: {
padding: '2rem',
gap: '1.5rem',
maxWidth: '450px',
},
animations: {
hover: true,
entrance: 'fade',
},
},
metadata: {
name: 'Meine Card',
description: '',
},
constraints: {},
visibility: 'public',
category: '',
variant: '',
user_id: '',
usage_count: 0,
likes_count: 0,
});
let loading = $state(false);
let saving = $state(false);
let editingCard = $state<Card | null>(null);
let userLinks = $state<any[]>([]);
let loadingLinks = $state(false);
let userAvatarUrl = $state<string>('');
// Editing states
let editingTitle = $state(false);
let editingSubtitle = $state(false);
let tempTitle = $state('');
let tempSubtitle = $state('');
let showLinkSelector = $state(false);
let selectedLinks = $state<Set<string>>(new Set());
// Get header and links modules - use functions to ensure reactivity
function getHeaderModule() {
return card.config.modules?.find((m) => m.type === 'header');
}
function getLinksModule() {
return card.config.modules?.find((m) => m.type === 'links');
}
// For backward compatibility with existing code
let headerModule = $derived(getHeaderModule());
let linksModule = $derived(getLinksModule());
// Load existing card if editing
onMount(async () => {
// Load user links first
await loadUserLinks();
// Load user avatar
await loadUserAvatar();
const cardId = $page.url.searchParams.get('id');
if (cardId) {
loading = true;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const existingCard = await unifiedCardService.getCard(cardId);
if (existingCard) {
editingCard = existingCard;
card = { ...existingCard };
// Initialize selected links after loading the card
const linksModuleInCard = existingCard.config.modules?.find((m) => m.type === 'links');
if (linksModuleInCard?.props.links && Array.isArray(linksModuleInCard.props.links)) {
// Extract IDs from the saved links
const savedLinkIds = linksModuleInCard.props.links
.map((l: any) => l.id)
.filter((id: any) => id); // Filter out any undefined/null IDs
selectedLinks = new Set(savedLinkIds);
}
}
} catch (error) {
console.error('Error loading card:', error);
} finally {
loading = false;
}
}
});
// Load user's links
async function loadUserLinks() {
if (!browser) return;
loadingLinks = true;
try {
const { pb } = await import('$lib/pocketbase');
if (!pb.authStore.isValid) {
goto('/login');
return;
}
const links = await pb.collection('links').getFullList({
filter: `user_id="${pb.authStore.model?.id}"`,
sort: '-created',
});
userLinks = links.map((link) => ({
id: link.id,
title: link.title,
url: link.original_url,
shortCode: link.short_code,
icon: link.icon || '🔗',
}));
} catch (error) {
console.error('Error loading links:', error);
} finally {
loadingLinks = false;
}
}
// Load user avatar
async function loadUserAvatar() {
if (!browser) return;
try {
const { pb } = await import('$lib/pocketbase');
if (!pb.authStore.isValid || !pb.authStore.model) {
return;
}
const user = pb.authStore.model;
// If user has an avatar, get the file URL
if (user.avatar) {
userAvatarUrl = pb.getFileUrl(user, user.avatar);
// Set the avatar in the header module for new cards (not when editing)
const moduleIndex = card.config.modules?.findIndex((m) => m.type === 'header');
if (moduleIndex !== undefined && moduleIndex >= 0 && card.config.modules) {
// Only set if there's no avatar already
if (!card.config.modules[moduleIndex].props.avatar && !editingCard) {
card.config.modules[moduleIndex].props.avatar = userAvatarUrl;
// Force reactivity
card = {
...card,
config: {
...card.config,
modules: [...card.config.modules],
},
};
}
}
}
// Also set user name as default if not editing
if (user.name && !editingCard) {
const moduleIndex = card.config.modules?.findIndex((m) => m.type === 'header');
if (moduleIndex !== undefined && moduleIndex >= 0 && card.config.modules) {
if (card.config.modules[moduleIndex].props.title === 'Dein Name') {
card.config.modules[moduleIndex].props.title = user.name;
// Force reactivity
card = {
...card,
config: {
...card.config,
modules: [...card.config.modules],
},
};
}
}
}
} catch (error) {
console.error('Error loading user avatar:', error);
}
}
// Toggle link selection
function toggleLinkSelection(linkId: string) {
const newSelection = new Set(selectedLinks);
if (newSelection.has(linkId)) {
newSelection.delete(linkId);
} else {
newSelection.add(linkId);
}
selectedLinks = newSelection;
updateSelectedLinks();
}
// Update selected links in card
function updateSelectedLinks() {
const selectedLinksList = userLinks.filter((link) => selectedLinks.has(link.id));
const formattedLinks = selectedLinksList.map((link) => ({
id: link.id,
label: link.title,
href: `${window.location.origin}/l/${link.shortCode}`,
icon: link.icon,
}));
// Find the links module and update it
const moduleIndex = card.config.modules?.findIndex((m) => m.type === 'links');
if (moduleIndex !== undefined && moduleIndex >= 0 && card.config.modules) {
// Create a deep copy of the modules array
const newModules = card.config.modules.map((module, index) => {
if (index === moduleIndex) {
return {
...module,
props: {
...module.props,
links: formattedLinks,
},
};
}
return module;
});
// Update the entire card to trigger reactivity
card = {
...card,
config: {
...card.config,
modules: newModules,
},
};
}
}
// Start editing title
function startEditingTitle() {
if (headerModule) {
tempTitle = headerModule.props.title || '';
editingTitle = true;
}
}
// Save title
function saveTitle() {
if (headerModule) {
headerModule.props.title = tempTitle;
card = { ...card };
}
editingTitle = false;
}
// Start editing subtitle
function startEditingSubtitle() {
if (headerModule) {
tempSubtitle = headerModule.props.subtitle || '';
editingSubtitle = true;
}
}
// Save subtitle
function saveSubtitle() {
if (headerModule) {
headerModule.props.subtitle = tempSubtitle;
card = { ...card };
}
editingSubtitle = false;
}
// Handle avatar upload
async function handleAvatarUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// For now, we'll use a data URL
// In production, you'd upload to a server
const reader = new FileReader();
reader.onload = (e) => {
if (headerModule && e.target?.result) {
headerModule.props.avatar = e.target.result as string;
card = { ...card };
}
};
reader.readAsDataURL(file);
}
// Save card
async function saveCard() {
if (!browser) return;
saving = true;
try {
const { unifiedCardService } = await import('$lib/services/unifiedCardService');
const { pb } = await import('$lib/pocketbase');
if (!pb.authStore.model) {
goto('/login');
return;
}
// Create a plain object to avoid sending Svelte proxy objects
const cardData = {
type: card.type,
config: {
mode: card.config.mode,
modules: card.config.modules?.map((m) => ({
id: m.id,
type: m.type,
order: m.order,
props:
m.type === 'links' && m.props.links
? {
...m.props,
links: m.props.links.map((link: any) => ({
id: link.id,
label: link.label,
href: link.href,
icon: link.icon,
description: link.description,
disabled: link.disabled,
})),
}
: { ...m.props },
visibility: m.visibility,
className: m.className,
grid: m.grid,
})),
theme: card.config.theme ? { ...card.config.theme } : undefined,
layout: card.config.layout ? { ...card.config.layout } : undefined,
animations: card.config.animations ? { ...card.config.animations } : undefined,
},
metadata: {
name: card.metadata.name,
description: card.metadata.description,
},
constraints: card.constraints ? { ...card.constraints } : {},
visibility: card.visibility,
category: card.category,
variant: card.variant,
user_id: pb.authStore.model.id,
page: 'profile',
usage_count: card.usage_count || 0,
likes_count: card.likes_count || 0,
};
console.log('Saving card data:', JSON.stringify(cardData, null, 2));
let savedCard;
if (editingCard?.id) {
savedCard = await unifiedCardService.updateCard(editingCard.id, cardData);
} else {
savedCard = await unifiedCardService.createCard(cardData);
}
if (savedCard) {
// Use window.location for a full page refresh to avoid state issues
window.location.href = '/my/cards';
}
} catch (error) {
console.error('Error saving card:', error);
alert('Fehler beim Speichern der Card');
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Card Builder - uload</title>
</svelte:head>
<div class="min-h-screen bg-theme-background">
{#if loading}
<div class="flex h-screen items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-theme-border border-t-theme-primary"
></div>
<p class="text-theme-text-muted">Lade Card...</p>
</div>
</div>
{:else}
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">
{editingCard ? 'Card bearbeiten' : 'Neue Card'}
</h1>
<div class="flex items-center gap-2">
<button
onclick={() => goto('/my/cards')}
class="rounded-lg border border-theme-border bg-theme-surface px-4 py-2 font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
>
Abbrechen
</button>
<button
onclick={saveCard}
disabled={saving}
class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-theme-primary-hover disabled:opacity-50"
>
{saving ? 'Speichern...' : editingCard ? 'Änderungen speichern' : 'Card erstellen'}
</button>
</div>
</div>
<!-- Main Content - Card in Center -->
<div class="flex justify-center">
<div class="w-full max-w-lg">
<!-- Card Preview with Inline Editing -->
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-lg">
<!-- Header Module -->
{#if headerModule}
<div class="mb-6 text-center">
<!-- Avatar -->
<div class="relative mx-auto mb-4 h-24 w-24">
{#if headerModule.props.avatar || userAvatarUrl}
<img
src={headerModule.props.avatar || userAvatarUrl}
alt="Avatar"
class="h-full w-full rounded-full object-cover"
/>
{:else}
<div
class="flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600"
>
<span class="text-3xl font-bold text-white">
{(headerModule.props.title || 'U')[0].toUpperCase()}
</span>
</div>
{/if}
<label
class="absolute bottom-0 right-0 cursor-pointer rounded-full bg-theme-primary p-2 text-white shadow-lg transition-all hover:scale-110 hover:bg-theme-primary-hover"
title="Anderes Bild wählen"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
<input
type="file"
accept="image/*"
class="hidden"
onchange={handleAvatarUpload}
/>
</label>
</div>
<!-- Title -->
{#if editingTitle}
<input
type="text"
bind:value={tempTitle}
onblur={saveTitle}
onkeydown={(e) => e.key === 'Enter' && saveTitle()}
class="mb-2 w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-2xl font-bold text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
autofocus
/>
{:else}
<h2
onclick={startEditingTitle}
class="mb-2 cursor-pointer text-2xl font-bold text-theme-text transition-colors hover:text-theme-primary"
>
{headerModule.props.title || 'Klicke zum Bearbeiten'}
</h2>
{/if}
<!-- Subtitle -->
{#if editingSubtitle}
<input
type="text"
bind:value={tempSubtitle}
onblur={saveSubtitle}
onkeydown={(e) => e.key === 'Enter' && saveSubtitle()}
class="w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
autofocus
/>
{:else}
<p
onclick={startEditingSubtitle}
class="cursor-pointer text-theme-text-muted transition-colors hover:text-theme-primary"
>
{headerModule.props.subtitle || 'Position hinzufügen'}
</p>
{/if}
</div>
{/if}
<!-- Links Module - Always visible -->
{#key card.config.modules}
{@const currentLinksModule = card.config.modules?.find((m) => m.type === 'links')}
<div class="mt-6 border-t border-theme-border pt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">Deine Links</h3>
<button
onclick={() => (showLinkSelector = !showLinkSelector)}
class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-theme-primary-hover"
>
{showLinkSelector ? '✓ Fertig' : '+ Links hinzufügen'}
</button>
</div>
{#if showLinkSelector}
<!-- Link Selector -->
<div class="rounded-lg border border-theme-border bg-theme-background p-4">
{#if loadingLinks}
<p class="text-center text-sm text-theme-text-muted">
<span class="inline-block animate-spin"></span> Lade deine Links...
</p>
{:else if userLinks.length === 0}
<div class="text-center">
<p class="mb-2 text-theme-text-muted">Du hast noch keine Links erstellt.</p>
<a
href="/my/links"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-theme-primary-hover"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Ersten Link erstellen
</a>
</div>
{:else}
<div class="space-y-2">
<p class="mb-3 text-sm text-theme-text-muted">
Wähle die Links aus, die auf deiner Card erscheinen sollen:
</p>
{#each userLinks as link}
<label
class="flex cursor-pointer items-center gap-3 rounded-lg border border-theme-border bg-theme-surface p-3 transition-all hover:bg-theme-surface-hover"
>
<input
type="checkbox"
checked={selectedLinks.has(link.id)}
onchange={() => toggleLinkSelection(link.id)}
class="h-5 w-5 rounded border-2 text-theme-primary focus:ring-2 focus:ring-theme-accent"
/>
<span class="text-xl">{link.icon}</span>
<div class="flex-1">
<p class="font-medium text-theme-text">{link.title}</p>
<p class="text-xs text-theme-text-muted">ulo.ad/l/{link.shortCode}</p>
</div>
</label>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Display Selected Links -->
{#if currentLinksModule?.props?.links && currentLinksModule.props.links.length > 0}
<div class="space-y-2">
{#each currentLinksModule.props.links as link}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-theme-border bg-theme-background px-4 py-3 text-theme-text transition-all hover:scale-[1.02] hover:bg-theme-surface-hover"
>
<span class="text-xl">{link.icon}</span>
<span class="flex-1 font-medium">{link.label}</span>
<svg
class="h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
{/each}
</div>
{:else}
<div
class="rounded-lg border-2 border-dashed border-theme-border bg-theme-background p-6 text-center"
>
<svg
class="mx-auto mb-3 h-12 w-12 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<p class="mb-2 text-theme-text">Noch keine Links hinzugefügt</p>
<p class="text-sm text-theme-text-muted">
Klicke auf "Links hinzufügen" um deine uload Links auszuwählen
</p>
</div>
{/if}
{/if}
</div>
{/key}
</div>
<!-- Card Settings -->
<div class="mt-6 rounded-lg border border-theme-border bg-theme-surface p-4">
<div class="flex items-center gap-4">
<label class="flex-1">
<span class="mb-1 block text-xs font-medium text-theme-text-muted">Card Name</span>
<input
type="text"
bind:value={card.metadata.name}
class="w-full rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-sm text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</label>
<label class="flex-1">
<span class="mb-1 block text-xs font-medium text-theme-text-muted"
>Sichtbarkeit</span
>
<select
bind:value={card.visibility}
class="w-full rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value="public">🌍 Öffentlich</option>
<option value="unlisted">🔗 Nicht gelistet</option>
<option value="private">🔒 Privat</option>
</select>
</label>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,515 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { links, clicks, tags, linkTags, workspaces } from '$lib/db/schema';
import { eq, and, or, desc, count, ilike, sql } from 'drizzle-orm';
// Simple short code generator
function generateShortCode(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 7; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export const load: PageServerLoad = async ({ locals, url }) => {
// Check authentication first
if (!locals.user) {
console.log('[LINKS] No user found, redirecting to login');
redirect(303, '/login');
}
try {
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '20');
const search = url.searchParams.get('search') || '';
const status = url.searchParams.get('status') || 'all';
const offset = (page - 1) * limit;
// Build query conditions
const conditions = [eq(links.userId, locals.user.id)];
if (search) {
conditions.push(
or(
ilike(links.title, `%${search}%`),
ilike(links.originalUrl, `%${search}%`),
ilike(links.description, `%${search}%`)
)!
);
}
if (status === 'active') {
conditions.push(eq(links.isActive, true));
} else if (status === 'inactive') {
conditions.push(eq(links.isActive, false));
}
// Get total count
const [{ total }] = await locals.db
.select({ total: count() })
.from(links)
.where(and(...conditions));
// Get paginated links
const userLinks = await locals.db
.select()
.from(links)
.where(and(...conditions))
.orderBy(desc(links.createdAt))
.limit(limit)
.offset(offset);
// Get click counts for each link
const linksWithClicks = await Promise.all(
userLinks.map(async (link) => {
const [clickResult] = await locals.db
.select({ count: count() })
.from(clicks)
.where(eq(clicks.linkId, link.id));
// Get last click
const [lastClick] = await locals.db
.select({ clickedAt: clicks.clickedAt })
.from(clicks)
.where(eq(clicks.linkId, link.id))
.orderBy(desc(clicks.clickedAt))
.limit(1);
return {
...link,
clicks: clickResult?.count || 0,
last_clicked_at: lastClick?.clickedAt || null,
};
})
);
// Load user's tags
const userTags = await locals.db
.select()
.from(tags)
.where(eq(tags.userId, locals.user.id))
.orderBy(tags.name);
return {
links: {
items: linksWithClicks,
page,
perPage: limit,
totalItems: total,
totalPages: Math.ceil(total / limit),
},
tags: userTags,
user: locals.user,
filters: {
search,
status,
page,
limit,
},
};
} catch (err: any) {
console.error('[LINKS] ERROR in load function:', err);
return {
links: {
items: [],
page: 1,
perPage: 20,
totalItems: 0,
totalPages: 0,
},
tags: [],
user: locals.user,
filters: {
search: url.searchParams.get('search') || '',
status: url.searchParams.get('status') || 'all',
page: parseInt(url.searchParams.get('page') || '1'),
limit: parseInt(url.searchParams.get('limit') || '20'),
},
};
}
};
export const actions = {
create: async ({ request, url, locals }) => {
if (!locals.user?.id) {
return fail(401, { error: 'Sie müssen eingeloggt sein, um Links zu erstellen' });
}
const data = await request.formData();
const urlToShorten = data.get('url') as string;
const title = data.get('title') as string;
const description = data.get('description') as string;
const expiresIn = data.get('expires_in') as string;
const maxClicks = data.get('max_clicks') as string;
const password = data.get('password') as string;
const customCode = data.get('custom_code') as string;
const tagIds = data.getAll('tags') as string[];
if (!urlToShorten) {
return fail(400, { error: 'URL is required' });
}
let shortCode = customCode?.trim() || generateShortCode();
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
try {
let expiresAt = null;
if (expiresIn) {
const days = parseInt(expiresIn);
if (!isNaN(days) && days > 0) {
const date = new Date();
date.setDate(date.getDate() + days);
expiresAt = date;
}
}
// Create the link
const [newLink] = await locals.db
.insert(links)
.values({
userId: locals.user.id,
originalUrl: urlToShorten,
shortCode: shortCode,
title: title || null,
description: description || null,
isActive: true,
expiresAt: expiresAt,
maxClicks: maxClicks ? parseInt(maxClicks) : null,
password: password || null,
clickCount: 0,
})
.returning();
// Create link_tags relationships
if (tagIds && tagIds.length > 0) {
for (const tagId of tagIds) {
try {
await locals.db.insert(linkTags).values({
linkId: newLink.id,
tagId: tagId,
});
// Update tag usage count
await locals.db
.update(tags)
.set({ usageCount: sql`${tags.usageCount} + 1` })
.where(eq(tags.id, tagId));
} catch (err) {
console.error('Failed to associate tag:', err);
}
}
}
const shortUrl = `${url.origin}/${newLink.shortCode}`;
return {
success: true,
shortUrl,
link: newLink,
};
} catch (err: any) {
// Check for unique constraint violation
if (err?.code === '23505' || err?.message?.includes('unique')) {
shortCode = generateShortCode();
attempts++;
} else {
console.error('Failed to create link:', err);
return fail(400, { error: 'Failed to create short link' });
}
}
}
return fail(400, { error: 'Could not generate unique short code' });
},
toggle: async ({ request, locals }) => {
if (!locals.user?.id) {
return fail(401, { error: 'Not authenticated' });
}
const data = await request.formData();
const id = data.get('id') as string;
const isActive = data.get('is_active') === 'true';
try {
// Verify ownership
const [link] = await locals.db
.select()
.from(links)
.where(and(eq(links.id, id), eq(links.userId, locals.user.id)))
.limit(1);
if (!link) {
return fail(403, { error: 'Link not found or not owned by you' });
}
await locals.db
.update(links)
.set({
isActive: !isActive,
updatedAt: new Date(),
})
.where(eq(links.id, id));
return { toggled: true };
} catch (err) {
console.error('Failed to toggle link:', err);
return fail(400, { error: 'Failed to toggle link status' });
}
},
delete: async ({ request, locals }) => {
if (!locals.user?.id) {
return fail(401, { error: 'Not authenticated' });
}
const data = await request.formData();
const id = data.get('id') as string;
try {
// Verify ownership
const [link] = await locals.db
.select()
.from(links)
.where(and(eq(links.id, id), eq(links.userId, locals.user.id)))
.limit(1);
if (!link) {
return fail(403, { error: 'Link not found or not owned by you' });
}
// Delete associated link_tags first (CASCADE should handle this, but be explicit)
const existingLinkTags = await locals.db
.select()
.from(linkTags)
.where(eq(linkTags.linkId, id));
for (const lt of existingLinkTags) {
await locals.db.delete(linkTags).where(eq(linkTags.id, lt.id));
// Update tag usage count
await locals.db
.update(tags)
.set({ usageCount: sql`GREATEST(${tags.usageCount} - 1, 0)` })
.where(eq(tags.id, lt.tagId));
}
// Delete the link (clicks will be deleted by CASCADE)
await locals.db.delete(links).where(eq(links.id, id));
return { deleted: true };
} catch (err) {
console.error('Failed to delete link:', err);
return fail(400, { error: 'Failed to delete link' });
}
},
update: async ({ request, url, locals }) => {
if (!locals.user?.id) {
return fail(401, { error: 'Sie müssen eingeloggt sein, um Links zu bearbeiten' });
}
const data = await request.formData();
const id = data.get('id') as string;
const urlToShorten = data.get('url') as string;
const title = data.get('title') as string;
const description = data.get('description') as string;
const expiresIn = data.get('expires_in') as string;
const maxClicks = data.get('max_clicks') as string;
const password = data.get('password') as string;
const tagIds = data.getAll('tags') as string[];
if (!id) {
return fail(400, { error: 'Link ID is required for update' });
}
if (!urlToShorten) {
return fail(400, { error: 'URL is required' });
}
try {
// Verify ownership
const [existingLink] = await locals.db
.select()
.from(links)
.where(and(eq(links.id, id), eq(links.userId, locals.user.id)))
.limit(1);
if (!existingLink) {
return fail(403, { error: 'You can only edit your own links' });
}
let expiresAt = null;
if (expiresIn) {
const days = parseInt(expiresIn);
if (!isNaN(days) && days > 0) {
const date = new Date();
date.setDate(date.getDate() + days);
expiresAt = date;
}
}
// Update the link
const [updatedLink] = await locals.db
.update(links)
.set({
originalUrl: urlToShorten,
title: title || null,
description: description || null,
expiresAt: expiresAt,
maxClicks: maxClicks ? parseInt(maxClicks) : null,
password: password || null,
updatedAt: new Date(),
})
.where(eq(links.id, id))
.returning();
// Update link_tags relationships
// Delete existing
const existingLinkTags = await locals.db
.select()
.from(linkTags)
.where(eq(linkTags.linkId, id));
for (const lt of existingLinkTags) {
await locals.db.delete(linkTags).where(eq(linkTags.id, lt.id));
await locals.db
.update(tags)
.set({ usageCount: sql`GREATEST(${tags.usageCount} - 1, 0)` })
.where(eq(tags.id, lt.tagId));
}
// Create new
if (tagIds && tagIds.length > 0) {
for (const tagId of tagIds) {
try {
await locals.db.insert(linkTags).values({
linkId: id,
tagId: tagId,
});
await locals.db
.update(tags)
.set({ usageCount: sql`${tags.usageCount} + 1` })
.where(eq(tags.id, tagId));
} catch (err) {
console.error('Failed to associate tag:', err);
}
}
}
const shortUrl = `${url.origin}/${updatedLink.shortCode}`;
return {
success: true,
shortUrl,
link: updatedLink,
};
} catch (err: any) {
console.error('Failed to update link:', err);
return fail(400, { error: 'Failed to update link' });
}
},
bulkAction: async ({ request, locals }) => {
if (!locals.user?.id) {
return fail(401, { error: 'Sie müssen eingeloggt sein' });
}
const data = await request.formData();
const action = data.get('action') as string;
const linkIdsJson = data.get('linkIds') as string;
if (!linkIdsJson) {
return fail(400, { error: 'No links selected' });
}
let linkIds: string[];
try {
linkIds = JSON.parse(linkIdsJson);
} catch {
return fail(400, { error: 'Invalid link IDs' });
}
if (linkIds.length === 0) {
return fail(400, { error: 'No links selected' });
}
// Verify all links belong to current user
for (const linkId of linkIds) {
const [link] = await locals.db
.select()
.from(links)
.where(and(eq(links.id, linkId), eq(links.userId, locals.user.id)))
.limit(1);
if (!link) {
return fail(403, { error: 'You can only modify your own links' });
}
}
switch (action) {
case 'bulk-delete': {
try {
for (const linkId of linkIds) {
// Delete link_tags
const existingLinkTags = await locals.db
.select()
.from(linkTags)
.where(eq(linkTags.linkId, linkId));
for (const lt of existingLinkTags) {
await locals.db.delete(linkTags).where(eq(linkTags.id, lt.id));
await locals.db
.update(tags)
.set({ usageCount: sql`GREATEST(${tags.usageCount} - 1, 0)` })
.where(eq(tags.id, lt.tagId));
}
// Delete link
await locals.db.delete(links).where(eq(links.id, linkId));
}
return { success: true, deleted: linkIds.length };
} catch (err) {
console.error('Failed to delete links:', err);
return fail(400, { error: 'Failed to delete links' });
}
}
case 'bulk-toggle-active': {
try {
// Get current states
const userLinks = await locals.db
.select({ id: links.id, isActive: links.isActive })
.from(links)
.where(
and(eq(links.userId, locals.user.id), sql`${links.id} = ANY(${linkIds}::uuid[])`)
);
// Determine new state (toggle majority)
const activeCount = userLinks.filter((l) => l.isActive).length;
const newState = activeCount <= userLinks.length / 2;
for (const linkId of linkIds) {
await locals.db
.update(links)
.set({ isActive: newState, updatedAt: new Date() })
.where(eq(links.id, linkId));
}
return { success: true, toggled: linkIds.length, newState };
} catch (err) {
console.error('Failed to toggle links:', err);
return fail(400, { error: 'Failed to toggle link status' });
}
}
default:
return fail(400, { error: 'Invalid action' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,653 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import LinkCreationCard from '$lib/components/links/LinkCreationCard.svelte';
import LinkList from '$lib/components/links/LinkList.svelte';
import LinkStats from '$lib/components/links/LinkStats.svelte';
import LinkUsageBar from '$lib/components/LinkUsageBar.svelte';
import ViewToggle from '$lib/components/ViewToggle.svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { toastMessages } from '$lib/services/toast';
import { viewModes } from '$lib/stores/viewModes';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
import type { Tag } from '$lib/pocketbase';
let { data, form }: { data: PageData; form: ActionData } = $props();
let copiedStates = $state<Record<string, boolean>>({});
let showCreateForm = $state(true);
let successMessageVisible = $state(false);
// Get workspace from store or data
let currentWorkspace = $derived(activeWorkspace.getData() || data?.workspace);
// Debug logging
$effect(() => {
console.log('[CLIENT LINKS] Data received:', {
links: data?.links?.items?.length || 0,
totalLinks: data?.links?.totalItems || 0,
folders: data?.folders?.length || 0,
tags: data?.tags?.length || 0,
user: data?.user,
workspace: data?.workspace,
workspaceFromStore: activeWorkspace.getData(),
});
if (currentWorkspace) {
console.log('[CLIENT LINKS] Current workspace:', currentWorkspace);
}
if (data?.links?.items?.length > 0) {
console.log('[CLIENT LINKS] First link:', data.links.items[0]);
}
// Log debug info if available
if (data?._debug) {
console.log('[CLIENT DEBUG] Server debug info:', data._debug);
}
});
// Filter states
let searchQuery = $state(data.filters.search);
let selectedTag = $state(data.filters.tag);
let selectedStatus = $state(data.filters.status);
let showFilters = $state(
typeof window !== 'undefined' ? localStorage.getItem('showLinksFilters') === 'true' : false
);
// Multi-select states
let isSelectMode = $state(false);
let selectedLinks = $state<Set<string>>(new Set());
let showBulkTagModal = $state(false);
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
copiedStates['main'] = true;
toastMessages.linkCopied();
setTimeout(() => (copiedStates['main'] = false), 2000);
}
function applyFilters() {
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (selectedTag && selectedTag !== 'all') params.set('tag', selectedTag);
if (selectedStatus && selectedStatus !== 'all') params.set('status', selectedStatus);
goto(`/my/links?${params.toString()}`);
}
function clearFilters() {
searchQuery = '';
selectedTag = 'all';
selectedStatus = 'all';
goto('/my/links');
}
function changePage(newPage: number) {
const params = new URLSearchParams($page.url.searchParams);
params.set('page', newPage.toString());
goto(`/my/links?${params.toString()}`);
}
function toggleSelectMode() {
isSelectMode = !isSelectMode;
if (!isSelectMode) {
selectedLinks.clear();
selectedLinks = selectedLinks; // Trigger reactivity
}
}
function toggleLinkSelection(linkId: string) {
if (selectedLinks.has(linkId)) {
selectedLinks.delete(linkId);
} else {
selectedLinks.add(linkId);
}
selectedLinks = selectedLinks; // Trigger reactivity
}
function toggleSelectAll() {
if (selectedLinks.size === data.links.items.length) {
selectedLinks.clear();
selectedLinks = selectedLinks; // Trigger reactivity
} else {
selectedLinks = new Set(data.links.items.map((l) => l.id));
}
}
async function bulkToggleActive() {
if (selectedLinks.size === 0) return;
const formData = new FormData();
formData.append('action', 'bulk-toggle-active');
formData.append('linkIds', JSON.stringify(Array.from(selectedLinks)));
try {
const response = await fetch('?/bulkAction', {
method: 'POST',
body: formData,
});
if (response.ok) {
toastMessages.success('Links wurden aktualisiert');
selectedLinks.clear();
selectedLinks = selectedLinks; // Trigger reactivity
isSelectMode = false;
goto('/my/links', { invalidateAll: true });
}
} catch (error) {
toastMessages.error('Fehler beim Aktualisieren der Links');
}
}
async function bulkDelete() {
if (selectedLinks.size === 0) return;
if (!confirm(`Möchten Sie wirklich ${selectedLinks.size} Link(s) löschen?`)) {
return;
}
const formData = new FormData();
formData.append('action', 'bulk-delete');
formData.append('linkIds', JSON.stringify(Array.from(selectedLinks)));
try {
const response = await fetch('?/bulkAction', {
method: 'POST',
body: formData,
});
if (response.ok) {
toastMessages.success(`${selectedLinks.size} Link(s) wurden gelöscht`);
selectedLinks.clear();
selectedLinks = selectedLinks; // Trigger reactivity
isSelectMode = false;
goto('/my/links', { invalidateAll: true });
}
} catch (error) {
toastMessages.error('Fehler beim Löschen der Links');
}
}
async function applyBulkTags() {
const modalElement = document.querySelector('[data-bulk-tag-modal]');
if (!modalElement) return;
const checkboxes = modalElement.querySelectorAll('input[type="checkbox"]:checked');
const tagIds = Array.from(checkboxes).map((cb) => (cb as HTMLInputElement).value);
if (tagIds.length === 0) {
toastMessages.error('Bitte wählen Sie mindestens einen Tag aus');
return;
}
const formData = new FormData();
formData.append('action', 'bulk-tag');
formData.append('linkIds', JSON.stringify(Array.from(selectedLinks)));
formData.append('tagIds', JSON.stringify(tagIds));
try {
const response = await fetch('?/bulkAction', {
method: 'POST',
body: formData,
});
if (response.ok) {
toastMessages.success('Tags wurden zugewiesen');
showBulkTagModal = false;
selectedLinks.clear();
selectedLinks = selectedLinks; // Trigger reactivity
isSelectMode = false;
goto('/my/links', { invalidateAll: true });
}
} catch (error) {
toastMessages.error('Fehler beim Zuweisen der Tags');
}
}
function handleLinkCreated(link: any, shortUrl: string) {
successMessageVisible = true;
// Keep form open for continuous link creation
setTimeout(() => (successMessageVisible = false), 5000);
// Immediately reload the page to show the new link
goto('/my/links', { invalidateAll: true });
}
$effect(() => {
if (form?.success && form?.link) {
handleLinkCreated(form.link, form.shortUrl);
}
});
// Listen for custom events
let editingLink = $state(null);
let qrModalLink = $state(null);
let showQRModal = $state(false);
onMount(() => {
const handleShowCreateForm = () => {
showCreateForm = true;
};
const handleEditLink = (event) => {
editingLink = event.detail;
showCreateForm = true;
};
const handleShowQRModal = (event) => {
qrModalLink = event.detail;
showQRModal = true;
};
window.addEventListener('show-create-form', handleShowCreateForm);
window.addEventListener('edit-link', handleEditLink);
window.addEventListener('show-qr-modal', handleShowQRModal);
return () => {
window.removeEventListener('show-create-form', handleShowCreateForm);
window.removeEventListener('edit-link', handleEditLink);
window.removeEventListener('show-qr-modal', handleShowQRModal);
};
});
// Save filter state to localStorage
$effect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('showLinksFilters', showFilters.toString());
}
});
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">
Links
{#if data.links?.totalItems > 0}
<span class="ml-2 text-2xl text-theme-text-muted">({data.links.totalItems})</span>
{/if}
</h1>
<div class="flex items-center gap-2">
{#if isSelectMode && selectedLinks.size > 0}
<!-- Bulk action buttons -->
<button
onclick={() => (showBulkTagModal = true)}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover"
title="Tag selected links"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-medium">Tag</span>
</button>
<button
onclick={bulkToggleActive}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover"
title="Toggle active/inactive"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span class="font-medium">Aktivieren/Deaktivieren</span>
</button>
<button
onclick={bulkDelete}
class="flex items-center gap-2 rounded-lg border border-red-500 bg-red-50 px-3 py-2 text-red-600 transition-all hover:bg-red-100"
title="Delete selected links"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span class="font-medium">Löschen ({selectedLinks.size})</span>
</button>
<div class="h-6 w-px bg-theme-border"></div>
{/if}
{#if $viewModes.links !== 'stats'}
<button
onclick={toggleSelectMode}
class="flex items-center gap-2 rounded-lg border border-theme-border px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover {isSelectMode
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
: 'bg-theme-surface'}"
title={isSelectMode ? 'Exit select mode' : 'Enter select mode'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span class="font-medium">{isSelectMode ? 'Fertig' : 'Auswählen'}</span>
</button>
{/if}
{#if $viewModes.links !== 'stats'}
<button
onclick={() => (showFilters = !showFilters)}
class="flex items-center gap-2 rounded-lg border border-theme-border px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover {showFilters
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
: 'bg-theme-surface'}"
title={showFilters ? 'Hide Filters' : 'Show Filters'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<span class="font-medium">Filter</span>
</button>
{/if}
<ViewToggle
currentView={$viewModes.links}
onViewChange={(view) => viewModes.setLinksView(view)}
showStats={true}
/>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg {showCreateForm
? 'border border-theme-border bg-theme-surface'
: 'bg-theme-primary'} px-4 py-2 font-medium {showCreateForm
? 'text-theme-text'
: 'text-white'} shadow-lg transition-all hover:scale-105 {showCreateForm
? 'hover:bg-theme-surface-hover'
: 'hover:bg-theme-primary-hover'}"
>
{showCreateForm ? ' Formular ausblenden' : '+ Neuer Link'}
</button>
</div>
</div>
<!-- Link Usage Display -->
<LinkUsageBar user={data.user} />
<!-- Create Form (standardmäßig sichtbar) -->
<LinkCreationCard
user={data.user}
folders={data.folders}
workspace={currentWorkspace}
tags={data.tags}
defaultOpen={showCreateForm}
{editingLink}
onSuccess={(link, shortUrl) => {
handleLinkCreated(link, shortUrl);
editingLink = null; // Clear editing state after success
}}
refreshOnSuccess={true}
/>
<!-- Filters (collapsible) -->
{#if showFilters}
<div
class="animate-fade-in mb-6 rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl"
>
<h2 class="mb-4 text-lg font-semibold text-theme-text">Filters</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
<!-- Search -->
<div>
<label for="search" class="mb-1 block text-sm font-medium text-theme-text">
Search
</label>
<input
type="text"
id="search"
bind:value={searchQuery}
placeholder="Search links..."
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<!-- Tag Filter -->
<div>
<label for="tag-filter" class="mb-1 block text-sm font-medium text-theme-text">
Tag
</label>
<select
id="tag-filter"
bind:value={selectedTag}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value="all">All tags</option>
{#each data.tags as tag}
<option value={tag.id}>
{tag.icon}
{tag.name}
</option>
{/each}
</select>
</div>
<!-- Status Filter -->
<div>
<label for="status-filter" class="mb-1 block text-sm font-medium text-theme-text">
Status
</label>
<select
id="status-filter"
bind:value={selectedStatus}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value="all">All links</option>
<option value="active">Active only</option>
<option value="inactive">Inactive only</option>
</select>
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<button
onclick={applyFilters}
class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Apply Filters
</button>
<button
onclick={clearFilters}
class="rounded-lg border border-theme-border bg-theme-surface px-4 py-2 font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Clear All
</button>
</div>
</div>
</div>
{/if}
<!-- Select All checkbox when in select mode -->
{#if isSelectMode && data.links?.items?.length > 0}
<div
class="mb-4 flex items-center gap-4 rounded-lg border border-theme-border bg-theme-surface p-4"
>
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selectedLinks.size === data.links.items.length}
onchange={toggleSelectAll}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<span class="font-medium text-theme-text">
{selectedLinks.size === data.links.items.length ? 'Alle abwählen' : 'Alle auswählen'}
</span>
</label>
<span class="text-theme-text-muted">
{selectedLinks.size} von {data.links.items.length} ausgewählt
</span>
</div>
{/if}
<!-- Links List or Stats View -->
{#if $viewModes.links === 'stats'}
<LinkStats
links={data.links.items}
totalClicks={data.links.items.reduce((sum, link) => sum + (link.clicks || 0), 0)}
period="30d"
/>
{:else}
<LinkList
links={data.links}
username={data.user?.username}
viewMode={$viewModes.links}
onPageChange={changePage}
{isSelectMode}
{selectedLinks}
onToggleSelect={toggleLinkSelection}
/>
{/if}
</div>
</div>
<!-- Bulk Tag Modal -->
{#if showBulkTagModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-theme-surface p-6 shadow-2xl">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">Tags zuweisen</h3>
<button
onclick={() => (showBulkTagModal = false)}
class="rounded-lg p-1 transition-colors hover:bg-theme-surface-hover"
>
<svg
class="h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div data-bulk-tag-modal class="space-y-4">
<p class="text-sm text-theme-text-muted">
Wählen Sie Tags für {selectedLinks.size} ausgewählte Link(s):
</p>
<div class="max-h-60 space-y-2 overflow-y-auto">
{#each data.tags as tag}
<label
class="flex cursor-pointer items-center gap-2 rounded p-2 hover:bg-theme-surface-hover"
>
<input
type="checkbox"
value={tag.id}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<span class="text-theme-text">
{tag.icon}
{tag.name}
</span>
</label>
{/each}
</div>
<div class="flex gap-2">
<button
onclick={applyBulkTags}
class="flex-1 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Tags zuweisen
</button>
<button
onclick={() => (showBulkTagModal = false)}
class="flex-1 rounded-lg border border-theme-border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- QR Code Modal -->
{#if showQRModal && qrModalLink}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-theme-surface p-6 shadow-2xl">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">QR Code</h3>
<button
onclick={() => {
showQRModal = false;
qrModalLink = null;
}}
class="rounded-lg p-1 transition-colors hover:bg-theme-surface-hover"
>
<svg
class="h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex flex-col items-center gap-4">
<div class="rounded-lg bg-white p-4">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(
window.location.origin + '/' + qrModalLink.short_code
)}"
alt="QR Code"
class="h-48 w-48"
/>
</div>
<div class="text-center">
<p class="mb-2 text-sm text-theme-text-muted">Scan to visit:</p>
<p class="font-mono text-sm text-theme-primary">
{window.location.origin}/{qrModalLink.short_code}
</p>
</div>
<div class="flex w-full gap-2">
<button
onclick={() => {
const url = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(window.location.origin + '/' + qrModalLink.short_code)}`;
const a = document.createElement('a');
a.href = url;
a.download = `qr-${qrModalLink.short_code}.png`;
a.click();
}}
class="flex-1 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Download QR
</button>
<button
onclick={() => {
showQRModal = false;
qrModalLink = null;
}}
class="flex-1 rounded-lg border border-theme-border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Close
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { onMount } from 'svelte';
let debugData = $state<any>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
const response = await fetch('/api/test-pb');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
debugData = await response.json();
console.log('[DEBUG PAGE] Data received:', debugData);
} catch (err: any) {
error = err?.message || 'Unknown error';
console.error('[DEBUG PAGE] Error:', err);
} finally {
loading = false;
}
});
</script>
<div class="min-h-screen bg-gray-50 p-8 dark:bg-gray-900">
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
PocketBase Debug Information
</h1>
{#if loading}
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
<p class="text-gray-600 dark:text-gray-400">Loading debug information...</p>
</div>
{:else if error}
<div
class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20"
>
<h2 class="mb-2 font-semibold text-red-800 dark:text-red-400">Error</h2>
<p class="text-red-600 dark:text-red-300">{error}</p>
</div>
{:else if debugData}
<div class="space-y-6">
<!-- User Info -->
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">User Information</h2>
<pre class="overflow-x-auto rounded bg-gray-100 p-4 text-sm dark:bg-gray-900">
{JSON.stringify(debugData.user, null, 2)}</pre>
</div>
<!-- PocketBase Info -->
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
PocketBase Connection
</h2>
<pre class="overflow-x-auto rounded bg-gray-100 p-4 text-sm dark:bg-gray-900">
{JSON.stringify(debugData.pb, null, 2)}</pre>
</div>
<!-- Test Results -->
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Test Results</h2>
{#each Object.entries(debugData.tests) as [testName, result]}
<div
class="mb-4 rounded border p-4 {result.success
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20'
: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20'}"
>
<h3
class="mb-2 font-medium {result.success
? 'text-green-800 dark:text-green-400'
: 'text-red-800 dark:text-red-400'}"
>
{testName}: {result.success ? '✅ Success' : '❌ Failed'}
</h3>
<pre
class="overflow-x-auto text-xs {result.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'}">
{JSON.stringify(result, null, 2)}</pre>
</div>
{/each}
</div>
<!-- Raw Data -->
<details class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
<summary class="cursor-pointer font-semibold text-gray-900 dark:text-white"
>Raw Debug Data</summary
>
<pre class="mt-4 overflow-x-auto rounded bg-gray-100 p-4 text-xs dark:bg-gray-900">
{JSON.stringify(debugData, null, 2)}</pre>
</details>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,280 @@
import { fail } from '@sveltejs/kit';
import { pb, generateTagSlug, DEFAULT_TAG_COLORS, type Tag } from '$lib/pocketbase';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
console.log('\n=== TAGS PAGE LOAD DEBUG ===');
console.log('[TAGS] Timestamp:', new Date().toISOString());
console.log('[TAGS] User ID:', locals.user?.id);
console.log('[TAGS] User email:', locals.user?.email);
console.log('[TAGS] PB auth valid:', locals.pb?.authStore?.isValid);
try {
const filter = `user_id="${locals.user?.id}"`;
console.log('[TAGS] Filter:', filter);
const tags = await locals.pb.collection('tags').getList<Tag>(1, 100, {
filter,
sort: '-usage_count,name',
});
console.log('[TAGS] Response:');
console.log('[TAGS] - Total items:', tags.totalItems);
console.log('[TAGS] - Items received:', tags.items.length);
if (tags.items.length > 0) {
console.log('[TAGS] First tag:', JSON.stringify(tags.items[0], null, 2));
}
// Get link count and total clicks for each tag
const tagsWithStats = await Promise.all(
tags.items.map(async (tag) => {
const linkTags = await locals.pb.collection('linktags').getList(1, 100, {
filter: `tag_id="${tag.id}"`,
expand: 'link_id',
});
// Calculate total clicks for all links with this tag
let totalClicks = 0;
for (const linkTag of linkTags.items) {
if (linkTag.expand?.link_id) {
try {
const clicks = await locals.pb.collection('clicks').getList(1, 1, {
filter: `link_id="${linkTag.link_id}"`,
});
totalClicks += clicks.totalItems;
} catch (err) {
console.error(`[TAGS] Failed to get clicks for link ${linkTag.link_id}:`, err);
}
}
}
return {
...tag,
linkCount: linkTags.totalItems,
totalClicks,
};
})
);
console.log('[TAGS] Returning', tagsWithStats.length, 'tags with stats');
console.log('=== END TAGS PAGE LOAD ===\n');
return {
tags: tagsWithStats,
};
} catch (err) {
console.error('[TAGS] ERROR in load function:', err);
console.error('[TAGS] Error details:', JSON.stringify(err, null, 2));
return {
tags: [],
};
}
};
export const actions = {
create: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name') as string;
const color = data.get('color') as string;
const icon = data.get('icon') as string;
const isPublic = data.get('is_public') === 'on';
if (!name) {
return fail(400, { error: 'Tag name is required' });
}
try {
const tag = await locals.pb.collection('tags').create({
name: name.trim(),
slug: generateTagSlug(name.trim()),
color: color || DEFAULT_TAG_COLORS[0],
icon: icon || '',
user_id: locals.user?.id,
is_public: isPublic,
usage_count: 0,
});
return { success: true, tag };
} catch (err) {
return fail(400, { error: 'Failed to create tag' });
}
},
update: async ({ request, locals }) => {
const data = await request.formData();
const id = data.get('id') as string;
const name = data.get('name') as string;
const color = data.get('color') as string;
const icon = data.get('icon') as string;
const isPublic = data.get('is_public') === 'on';
if (!id || !name) {
return fail(400, { error: 'Tag ID and name are required' });
}
try {
await locals.pb.collection('tags').update(id, {
name: name.trim(),
slug: generateTagSlug(name.trim()),
color: color || DEFAULT_TAG_COLORS[0],
icon: icon || '',
is_public: isPublic,
});
return { updated: true };
} catch (err) {
return fail(400, { error: 'Failed to update tag' });
}
},
delete: async ({ request, locals }) => {
const data = await request.formData();
const id = data.get('id') as string;
if (!id) {
return fail(400, { error: 'Tag ID is required' });
}
try {
// Delete all link_tags relationships first
const linkTags = await locals.pb.collection('linktags').getList(1, 100, {
filter: `tag_id="${id}"`,
});
for (const linkTag of linkTags.items) {
await locals.pb.collection('linktags').delete(linkTag.id);
}
// Delete the tag
await locals.pb.collection('tags').delete(id);
return { deleted: true };
} catch (err) {
return fail(400, { error: 'Failed to delete tag' });
}
},
bulkAction: async ({ request, locals }) => {
// Ensure user is authenticated
if (!locals.user?.id) {
return fail(401, { error: 'Sie müssen eingeloggt sein' });
}
const data = await request.formData();
const action = data.get('action') as string;
const tagIdsJson = data.get('tagIds') as string;
if (!tagIdsJson) {
return fail(400, { error: 'No tags selected' });
}
let tagIds: string[];
try {
tagIds = JSON.parse(tagIdsJson);
} catch {
return fail(400, { error: 'Invalid tag IDs' });
}
if (tagIds.length === 0) {
return fail(400, { error: 'No tags selected' });
}
// Verify all tags belong to the current user
for (const tagId of tagIds) {
try {
const tag = await locals.pb.collection('tags').getOne(tagId);
if (tag.user_id !== locals.user.id) {
return fail(403, { error: 'You can only modify your own tags' });
}
} catch (err) {
return fail(404, { error: `Tag ${tagId} not found` });
}
}
switch (action) {
case 'bulk-delete': {
try {
for (const tagId of tagIds) {
// Delete all link_tags relationships first
const linkTags = await locals.pb.collection('linktags').getList(1, 100, {
filter: `tag_id="${tagId}"`,
});
for (const linkTag of linkTags.items) {
await locals.pb.collection('linktags').delete(linkTag.id);
}
// Delete the tag
await locals.pb.collection('tags').delete(tagId);
}
return { success: true, deleted: tagIds.length };
} catch (err) {
console.error('Failed to delete tags:', err);
return fail(400, { error: 'Failed to delete tags' });
}
}
case 'bulk-merge': {
const targetTagId = data.get('targetTagId') as string;
if (!targetTagId) {
return fail(400, { error: 'No target tag selected' });
}
if (!tagIds.includes(targetTagId)) {
return fail(400, { error: 'Target tag must be one of the selected tags' });
}
try {
// Get the target tag
const targetTag = await locals.pb.collection('tags').getOne(targetTagId);
// Merge all other tags into the target tag
for (const tagId of tagIds) {
if (tagId === targetTagId) continue;
// Get all link_tags for this tag
const linkTags = await locals.pb.collection('linktags').getList(1, 100, {
filter: `tag_id="${tagId}"`,
});
// For each link_tag, check if target tag already has this link
for (const linkTag of linkTags.items) {
// Check if this link already has the target tag
const existingLinkTag = await locals.pb.collection('linktags').getList(1, 1, {
filter: `link_id="${linkTag.link_id}" && tag_id="${targetTagId}"`,
});
if (existingLinkTag.totalItems === 0) {
// Create new link_tag with target tag
await locals.pb.collection('linktags').create({
link_id: linkTag.link_id,
tag_id: targetTagId,
});
}
// Delete the old link_tag
await locals.pb.collection('linktags').delete(linkTag.id);
}
// Update target tag usage count
const tag = await locals.pb.collection('tags').getOne(tagId);
await locals.pb.collection('tags').update(targetTagId, {
usage_count: (targetTag.usage_count || 0) + (tag.usage_count || 0),
});
// Delete the merged tag
await locals.pb.collection('tags').delete(tagId);
}
return { success: true, merged: tagIds.length - 1, targetTag: targetTag.name };
} catch (err) {
console.error('Failed to merge tags:', err);
return fail(400, { error: 'Failed to merge tags' });
}
}
default:
return fail(400, { error: 'Invalid action' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,473 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import TagBadge from '$lib/components/TagBadge.svelte';
import TagList from '$lib/components/TagList.svelte';
import TagStats from '$lib/components/tags/TagStats.svelte';
import ViewToggle from '$lib/components/ViewToggle.svelte';
import { page } from '$app/stores';
import { DEFAULT_TAG_COLORS } from '$lib/pocketbase';
import { viewModes } from '$lib/stores/viewModes';
import { Search, ArrowUpDown } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
let isSubmitting = $state(false);
let searchQuery = $state('');
let sortBy = $state<'name' | 'date' | 'usage' | 'links' | 'clicks'>('usage');
let sortOrder = $state<'asc' | 'desc'>('desc');
let selectedColor = $state(DEFAULT_TAG_COLORS[0]);
// Multi-select states
let isSelectMode = $state(false);
let selectedTags = $state<Set<string>>(new Set());
let showMergeModal = $state(false);
let mergeTargetTag = $state<string>('');
// Filter and sort tags
let filteredAndSortedTags = $derived.by(() => {
let tags = data.tags || [];
// Filter by search query
if (searchQuery) {
tags = tags.filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()));
}
// Sort tags
return [...tags].sort((a, b) => {
let compareValue = 0;
switch (sortBy) {
case 'name':
compareValue = a.name.localeCompare(b.name);
break;
case 'date':
compareValue = new Date(b.created).getTime() - new Date(a.created).getTime();
break;
case 'usage':
compareValue = (b.usage_count || 0) - (a.usage_count || 0);
break;
case 'links':
compareValue = (b.linkCount || 0) - (a.linkCount || 0);
break;
case 'clicks':
compareValue = (b.totalClicks || 0) - (a.totalClicks || 0);
break;
}
return sortOrder === 'asc' ? -compareValue : compareValue;
});
});
function toggleSortOrder() {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
}
function toggleSelectMode() {
isSelectMode = !isSelectMode;
if (!isSelectMode) {
selectedTags.clear();
selectedTags = selectedTags; // Trigger reactivity
}
}
function toggleTagSelection(tagId: string) {
if (selectedTags.has(tagId)) {
selectedTags.delete(tagId);
} else {
selectedTags.add(tagId);
}
selectedTags = selectedTags; // Trigger reactivity
}
function toggleSelectAll() {
if (selectedTags.size === filteredAndSortedTags.length) {
selectedTags.clear();
selectedTags = selectedTags; // Trigger reactivity
} else {
selectedTags = new Set(filteredAndSortedTags.map((t) => t.id));
}
}
async function bulkDelete() {
if (selectedTags.size === 0) return;
if (
!confirm(
`Möchten Sie wirklich ${selectedTags.size} Tag(s) löschen? Alle Verknüpfungen zu Links werden entfernt.`
)
) {
return;
}
const formData = new FormData();
formData.append('action', 'bulk-delete');
formData.append('tagIds', JSON.stringify(Array.from(selectedTags)));
try {
const response = await fetch('?/bulkAction', {
method: 'POST',
body: formData,
});
if (response.ok) {
selectedTags.clear();
selectedTags = selectedTags; // Trigger reactivity
isSelectMode = false;
window.location.reload();
}
} catch (error) {
alert('Fehler beim Löschen der Tags');
}
}
async function mergeTags() {
if (selectedTags.size < 2 || !mergeTargetTag) return;
const formData = new FormData();
formData.append('action', 'bulk-merge');
formData.append('tagIds', JSON.stringify(Array.from(selectedTags)));
formData.append('targetTagId', mergeTargetTag);
try {
const response = await fetch('?/bulkAction', {
method: 'POST',
body: formData,
});
if (response.ok) {
showMergeModal = false;
selectedTags.clear();
selectedTags = selectedTags; // Trigger reactivity
isSelectMode = false;
mergeTargetTag = '';
window.location.reload();
}
} catch (error) {
alert('Fehler beim Zusammenführen der Tags');
}
}
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">Tags</h1>
<div class="flex items-center gap-4">
{#if isSelectMode && selectedTags.size > 0}
<!-- Bulk action buttons -->
<button
onclick={() => (showMergeModal = true)}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover"
title="Merge selected tags"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
<span class="font-medium">Zusammenführen</span>
</button>
<button
onclick={bulkDelete}
class="flex items-center gap-2 rounded-lg border border-red-500 bg-red-50 px-3 py-2 text-red-600 transition-all hover:bg-red-100"
title="Delete selected tags"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span class="font-medium">Löschen ({selectedTags.size})</span>
</button>
<div class="h-6 w-px bg-theme-border"></div>
{/if}
{#if $viewModes.tags !== 'stats'}
<button
onclick={toggleSelectMode}
class="flex items-center gap-2 rounded-lg border border-theme-border px-3 py-2 text-theme-text transition-all hover:bg-theme-surface-hover {isSelectMode
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
: 'bg-theme-surface'}"
title={isSelectMode ? 'Exit select mode' : 'Enter select mode'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span class="font-medium">{isSelectMode ? 'Fertig' : 'Auswählen'}</span>
</button>
{/if}
<ViewToggle
currentView={$viewModes.tags}
onViewChange={(view) => viewModes.setTagsView(view)}
showStats={true}
/>
<button
onclick={() => (showForm = !showForm)}
class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-theme-primary-hover"
>
{showForm ? 'Cancel' : '+ New Tag'}
</button>
</div>
</div>
<!-- Search and Sort Controls (hide in stats view) -->
{#if $viewModes.tags !== 'stats'}
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="relative max-w-md flex-1">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-muted" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search tags..."
class="w-full rounded-lg border border-theme-border bg-theme-surface py-2 pl-10 pr-4 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div class="flex items-center gap-2">
<label for="sort-select" class="text-sm font-medium text-theme-text">Sort by:</label>
<select
id="sort-select"
bind:value={sortBy}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value="name">Name</option>
<option value="date">Date Created</option>
<option value="usage">Usage Count</option>
<option value="links">Number of Links</option>
<option value="clicks">Total Clicks</option>
</select>
<button
onclick={toggleSortOrder}
class="rounded-lg border border-theme-border bg-theme-surface p-2 text-theme-text transition-all hover:bg-theme-surface-hover"
aria-label="Toggle sort order"
>
<ArrowUpDown class="h-4 w-4" />
</button>
</div>
</div>
{/if}
{#if showForm}
<div class="mb-8 rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl sm:p-8">
<h2 class="mb-4 text-xl font-semibold text-theme-text">Create New Tag</h2>
<form
method="POST"
action="?/create"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
showForm = false;
selectedColor = DEFAULT_TAG_COLORS[0];
};
}}
>
<div class="space-y-4">
<div>
<label for="name" class="mb-1 block text-sm font-medium text-theme-text">
Tag Name
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="e.g. Work, Personal, Important"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<span class="mb-2 block text-sm font-medium text-theme-text"> Color </span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Tag color selection">
{#each DEFAULT_TAG_COLORS as color}
<button
type="button"
onclick={() => (selectedColor = color)}
class="h-10 w-10 rounded border-2 transition-all hover:scale-110 {selectedColor ===
color
? 'border-theme-text'
: 'border-theme-border'}"
style="background-color: {color}"
aria-label="Select color {color}"
></button>
{/each}
</div>
<input type="hidden" name="color" value={selectedColor} />
</div>
<div>
<label class="flex items-center space-x-3">
<input
type="checkbox"
name="is_public"
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-accent"
/>
<span class="text-sm font-medium text-theme-text">
Make this tag public (visible on your profile)
</span>
</label>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-theme-text-muted">Preview:</span>
<TagBadge
tag={{
id: 'preview',
name: 'Preview Tag',
color: selectedColor,
user_id: '',
slug: '',
is_public: false,
created: '',
updated: '',
}}
size="md"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
class="flex w-full items-center justify-center rounded-lg bg-theme-primary px-4 py-3 font-medium text-white transition duration-200 hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Tag'}
</button>
</div>
</form>
{#if form?.error}
<div
class="mt-4 rounded border border-red-400 bg-red-100 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{form.error}
</div>
{/if}
</div>
{/if}
<!-- Select All checkbox when in select mode -->
{#if isSelectMode && filteredAndSortedTags.length > 0}
<div
class="mb-4 flex items-center gap-4 rounded-lg border border-theme-border bg-theme-surface p-4"
>
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selectedTags.size === filteredAndSortedTags.length}
onchange={toggleSelectAll}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<span class="font-medium text-theme-text">
{selectedTags.size === filteredAndSortedTags.length
? 'Alle abwählen'
: 'Alle auswählen'}
</span>
</label>
<span class="text-theme-text-muted">
{selectedTags.size} von {filteredAndSortedTags.length} ausgewählt
</span>
</div>
{/if}
{#if $viewModes.tags === 'stats'}
<TagStats tags={filteredAndSortedTags} totalLinks={data.totalLinks || 0} period="30d" />
{:else}
<TagList
tags={filteredAndSortedTags}
viewMode={$viewModes.tags}
{isSelectMode}
{selectedTags}
onToggleSelect={toggleTagSelection}
/>
{/if}
</div>
</div>
<!-- Merge Tags Modal -->
{#if showMergeModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-theme-surface p-6 shadow-2xl">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">Tags zusammenführen</h3>
<button
onclick={() => (showMergeModal = false)}
class="rounded-lg p-1 transition-colors hover:bg-theme-surface-hover"
>
<svg
class="h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-4">
<p class="text-sm text-theme-text-muted">
Wählen Sie den Ziel-Tag aus. Alle Links der anderen {selectedTags.size - 1} Tags werden auf
diesen Tag übertragen:
</p>
<div class="max-h-60 space-y-2 overflow-y-auto">
{#each filteredAndSortedTags.filter((t) => selectedTags.has(t.id)) as tag}
<label
class="flex cursor-pointer items-center gap-2 rounded p-2 hover:bg-theme-surface-hover"
>
<input
type="radio"
name="merge-target"
value={tag.id}
bind:group={mergeTargetTag}
class="h-4 w-4 border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<TagBadge {tag} size="sm" />
<span class="text-theme-text">{tag.name}</span>
</label>
{/each}
</div>
<div class="flex gap-2">
<button
onclick={mergeTags}
disabled={!mergeTargetTag}
class="flex-1 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
Tags zusammenführen
</button>
<button
onclick={() => {
showMergeModal = false;
mergeTargetTag = '';
}}
class="flex-1 rounded-lg border border-theme-border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,425 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fail } from '@sveltejs/kit';
import * as actions from './+page.server';
import { pb, generateTagSlug, DEFAULT_TAG_COLORS } from '$lib/pocketbase';
import { createTestTag, createTestUser } from '$tests/factories';
// Mock @sveltejs/kit
vi.mock('@sveltejs/kit', () => ({
fail: vi.fn((status, data) => ({ status, data })),
}));
// Mock PocketBase
vi.mock('$lib/pocketbase', () => ({
pb: {
collection: vi.fn(),
},
generateTagSlug: vi.fn((name) => name.toLowerCase().replace(/\s+/g, '-')),
DEFAULT_TAG_COLORS: ['#3B82F6', '#EF4444', '#10B981'],
}));
describe('Tags Page Server Actions', () => {
let mockCollection: any;
let testUser: any;
beforeEach(() => {
vi.clearAllMocks();
testUser = createTestUser({
id: 'user123',
email: 'test@example.com',
});
// Setup mock collection methods
mockCollection = {
getList: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
(pb.collection as any).mockReturnValue(mockCollection);
});
describe('load function', () => {
it('should load tags for authenticated user', async () => {
const mockTags = [
createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' }),
createTestTag({ id: 'tag2', name: 'Personal', user_id: 'user123' }),
];
mockCollection.getList
.mockResolvedValueOnce({
items: mockTags,
totalItems: 2,
})
.mockResolvedValue({
items: [],
totalItems: 0,
});
const result = await actions.load({
locals: { user: testUser },
} as any);
expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, {
filter: `user_id="user123"`,
sort: '-usage_count,name',
});
expect(result.tags).toHaveLength(2);
expect(result.tags[0]).toHaveProperty('linkCount', 0);
});
it('should return empty array on error', async () => {
mockCollection.getList.mockRejectedValue(new Error('Database error'));
const result = await actions.load({
locals: { user: testUser },
} as any);
expect(result.tags).toEqual([]);
});
it('should include link counts for each tag', async () => {
const mockTag = createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' });
mockCollection.getList
.mockResolvedValueOnce({
items: [mockTag],
totalItems: 1,
})
.mockResolvedValueOnce({
items: [],
totalItems: 5, // 5 links using this tag
});
const result = await actions.load({
locals: { user: testUser },
} as any);
expect(result.tags[0].linkCount).toBe(5);
});
});
describe('create action', () => {
it('should create a new tag successfully', async () => {
const formData = new FormData();
formData.append('name', 'New Tag');
formData.append('color', '#3B82F6');
formData.append('icon', '🏷️');
formData.append('is_public', 'on');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
const expectedTag = {
id: 'new-tag-id',
name: 'New Tag',
slug: 'new-tag',
color: '#3B82F6',
icon: '🏷️',
user_id: 'user123',
is_public: true,
usage_count: 0,
};
mockCollection.create.mockResolvedValue(expectedTag);
const result = await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(mockCollection.create).toHaveBeenCalledWith({
name: 'New Tag',
slug: 'new-tag',
color: '#3B82F6',
icon: '🏷️',
user_id: 'user123',
is_public: true,
usage_count: 0,
});
expect(result).toEqual({ success: true, tag: expectedTag });
});
it('should trim tag name', async () => {
const formData = new FormData();
formData.append('name', ' Trimmed Tag ');
formData.append('color', '#3B82F6');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.create.mockResolvedValue({ id: 'tag-id' });
await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(mockCollection.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Trimmed Tag',
slug: 'trimmed-tag',
})
);
});
it('should use default color if not provided', async () => {
const formData = new FormData();
formData.append('name', 'Tag');
formData.append('color', '');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.create.mockResolvedValue({ id: 'tag-id' });
await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(mockCollection.create).toHaveBeenCalledWith(
expect.objectContaining({
color: DEFAULT_TAG_COLORS[0],
})
);
});
it('should handle is_public correctly', async () => {
const formData = new FormData();
formData.append('name', 'Private Tag');
// is_public not set (checkbox unchecked)
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.create.mockResolvedValue({ id: 'tag-id' });
await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(mockCollection.create).toHaveBeenCalledWith(
expect.objectContaining({
is_public: false,
})
);
});
it('should fail if name is not provided', async () => {
const formData = new FormData();
formData.append('name', '');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
const result = await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Tag name is required' });
expect(mockCollection.create).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
const formData = new FormData();
formData.append('name', 'Test Tag');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.create.mockRejectedValue(new Error('Database error'));
const result = await actions.actions.create({
request: mockRequest,
locals: { user: testUser },
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to create tag' });
});
});
describe('update action', () => {
it('should update tag successfully', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
formData.append('name', 'Updated Tag');
formData.append('color', '#EF4444');
formData.append('icon', '⭐');
formData.append('is_public', 'on');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.update.mockResolvedValue({ id: 'tag123' });
const result = await actions.actions.update({
request: mockRequest,
} as any);
expect(mockCollection.update).toHaveBeenCalledWith('tag123', {
name: 'Updated Tag',
slug: 'updated-tag',
color: '#EF4444',
icon: '⭐',
is_public: true,
});
expect(result).toEqual({ updated: true });
});
it('should fail if id is not provided', async () => {
const formData = new FormData();
formData.append('name', 'Tag');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
const result = await actions.actions.update({
request: mockRequest,
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' });
expect(mockCollection.update).not.toHaveBeenCalled();
});
it('should fail if name is not provided', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
formData.append('name', '');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
const result = await actions.actions.update({
request: mockRequest,
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' });
});
it('should handle database errors', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
formData.append('name', 'Tag');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.update.mockRejectedValue(new Error('Database error'));
const result = await actions.actions.update({
request: mockRequest,
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to update tag' });
});
});
describe('delete action', () => {
it('should delete tag and its relationships', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
// Mock linktags relationships
mockCollection.getList.mockResolvedValue({
items: [{ id: 'link_tag_1' }, { id: 'link_tag_2' }],
});
mockCollection.delete.mockResolvedValue(true);
const result = await actions.actions.delete({
request: mockRequest,
} as any);
// Should delete linktags first
expected(pb.collection).toHaveBeenCalledWith('linktags');
expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, {
filter: `tag_id="tag123"`,
});
expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_1');
expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_2');
// Then delete the tag
expect(pb.collection).toHaveBeenCalledWith('tags');
expect(mockCollection.delete).toHaveBeenCalledWith('tag123');
expect(result).toEqual({ deleted: true });
});
it('should handle tags with no relationships', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.getList.mockResolvedValue({
items: [],
});
mockCollection.delete.mockResolvedValue(true);
const result = await actions.actions.delete({
request: mockRequest,
} as any);
expect(mockCollection.delete).toHaveBeenCalledTimes(1);
expect(mockCollection.delete).toHaveBeenCalledWith('tag123');
expect(result).toEqual({ deleted: true });
});
it('should fail if id is not provided', async () => {
const formData = new FormData();
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
const result = await actions.actions.delete({
request: mockRequest,
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID is required' });
expect(mockCollection.delete).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
const formData = new FormData();
formData.append('id', 'tag123');
const mockRequest = {
formData: vi.fn().mockResolvedValue(formData),
};
mockCollection.getList.mockRejectedValue(new Error('Database error'));
const result = await actions.actions.delete({
request: mockRequest,
} as any);
expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to delete tag' });
});
});
});

View file

@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
return {
user: locals.user
? {
email: locals.user.email,
username: locals.user.username,
}
: null,
};
};

View file

@ -0,0 +1,337 @@
<script lang="ts">
import type { PageData } from './$types';
import UpgradeButton from '$lib/components/UpgradeButton.svelte';
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import { Check, X, Star } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
// Check if user was redirected from cancelled checkout
let wasCancelled = $derived($page.url.searchParams.get('cancelled') === 'true');
const plans = [
{
name: 'Free',
price: '0€',
period: 'für immer',
features: ['10 Links pro Monat', 'Basis Analytics', 'QR Codes', 'Link Anpassung'],
limitations: ['Limitierte Links', 'Standard Support'],
priceType: null,
popular: false,
},
{
name: 'Pro Monatlich',
price: '4,99€',
period: 'pro Monat',
features: [
'300 Links pro Monat',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'Keine Werbung',
'API Zugang',
],
limitations: [],
priceType: 'monthly',
popular: false,
},
{
name: 'Pro Jährlich',
price: '39,99€',
period: 'pro Jahr',
savings: 'Spare 20€ pro Jahr!',
features: [
'600 Links pro Monat',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'Keine Werbung',
'API Zugang',
],
limitations: [],
priceType: 'yearly',
popular: true,
},
{
name: 'Pro Lifetime',
price: '129,99€',
period: 'einmalig',
savings: 'Für immer Pro!',
features: [
'Alle Pro Features',
'Lebenslanger Zugang',
'Unbegrenzte Links',
'Alle zukünftigen Features',
'Priority Support',
'Early Access zu neuen Features',
'API Zugang',
],
limitations: [],
priceType: 'lifetime',
popular: false,
},
];
let openFaq = $state<number | null>(null);
</script>
<svelte:head>
<title>Preise - ulo.ad</title>
<meta name="description" content="Wähle den perfekten Plan für deine Bedürfnisse" />
</svelte:head>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background px-4 py-12">
<div class="mx-auto max-w-7xl">
<!-- Header -->
<div class="mb-12 text-center">
<h1 class="mb-4 text-4xl font-bold text-theme-text">Wähle deinen Plan</h1>
<p class="text-xl text-theme-text-muted">
Starte kostenlos und upgrade wenn du mehr brauchst
</p>
{#if wasCancelled}
<div
class="mx-auto mt-6 flex max-w-md items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20"
>
<svg
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
Kein Problem! Du kannst jederzeit upgraden, wenn du bereit bist.
</p>
</div>
</div>
{/if}
</div>
<!-- Pricing Cards -->
<div class="mx-auto grid max-w-5xl gap-6 md:grid-cols-2 lg:grid-cols-4">
{#each plans as plan}
<div
class="relative flex flex-col rounded-xl {plan.popular
? 'border-2 border-theme-primary shadow-xl'
: 'border border-theme-border'} bg-white p-6 dark:bg-gray-800"
>
{#if plan.popular}
<div
class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-theme-primary px-4 py-1"
>
<div class="flex items-center gap-1">
<Star class="h-4 w-4 fill-white text-white" />
<span class="text-xs font-bold text-white">BELIEBT</span>
</div>
</div>
{/if}
<!-- Plan Header -->
<div class="mb-6">
<h3 class="text-xl font-bold text-theme-text">{plan.name}</h3>
<div class="mt-2 flex items-baseline">
<span class="text-3xl font-bold text-theme-text">{plan.price}</span>
<span class="ml-2 text-theme-text-muted">/{plan.period}</span>
</div>
{#if plan.savings}
<p class="mt-2 text-sm font-medium text-green-600 dark:text-green-400">
{plan.savings}
</p>
{/if}
</div>
<!-- Features -->
<ul class="mb-6 flex-1 space-y-3">
{#each plan.features as feature}
<li class="flex items-start gap-2">
<Check class="h-5 w-5 flex-shrink-0 text-green-500" />
<span class="text-sm text-theme-text">{feature}</span>
</li>
{/each}
{#each plan.limitations as limitation}
<li class="flex items-start gap-2">
<X class="h-5 w-5 flex-shrink-0 text-red-500" />
<span class="text-sm text-theme-text-muted">{limitation}</span>
</li>
{/each}
</ul>
<!-- CTA Button -->
<div>
{#if plan.priceType === null}
<button
disabled
class="w-full rounded-lg bg-gray-100 px-4 py-2 text-center font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
Aktueller Plan
</button>
{:else}
<UpgradeButton
priceType={plan.priceType}
className="w-full {plan.popular
? 'bg-theme-primary hover:bg-theme-primary-hover text-white'
: 'bg-theme-surface hover:bg-theme-surface-hover text-theme-text'}"
/>
{/if}
</div>
</div>
{/each}
</div>
<!-- FAQ Section -->
<div class="mt-16">
<h2 class="mb-8 text-center text-2xl font-bold text-theme-text">Häufige Fragen</h2>
<div class="mx-auto max-w-3xl space-y-4">
<div class="rounded-lg border border-theme-border bg-white dark:bg-gray-800">
<button
onclick={() => (openFaq = openFaq === 1 ? null : 1)}
class="flex w-full items-center justify-between p-4 text-left"
>
<span class="font-medium text-theme-text">
Was ist der Unterschied zwischen den Pro-Plänen?
</span>
<svg
class="h-5 w-5 text-theme-text-muted transition-transform {openFaq === 1
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if openFaq === 1}
<div class="border-t border-theme-border px-4 pb-4">
<p class="text-theme-text-muted">
Alle Pro-Pläne haben die gleichen Features, unterscheiden sich aber im Preis:
Monatlich (4,99€/Monat), Jährlich (39,99€/Jahr - spare 20€), oder Lifetime (129,99€
einmalig - für immer Pro ohne weitere Zahlungen).
</p>
</div>
{/if}
</div>
<div class="rounded-lg border border-theme-border bg-white dark:bg-gray-800">
<button
onclick={() => (openFaq = openFaq === 2 ? null : 2)}
class="flex w-full items-center justify-between p-4 text-left"
>
<span class="font-medium text-theme-text">Kann ich jederzeit upgraden?</span>
<svg
class="h-5 w-5 text-theme-text-muted transition-transform {openFaq === 2
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if openFaq === 2}
<div class="border-t border-theme-border px-4 pb-4">
<p class="text-theme-text-muted">
Ja, du kannst jederzeit von Free zu Pro upgraden. Deine Links und Einstellungen
bleiben dabei erhalten. Du kannst auch zwischen den verschiedenen Pro-Plänen
wechseln.
</p>
</div>
{/if}
</div>
<div class="rounded-lg border border-theme-border bg-white dark:bg-gray-800">
<button
onclick={() => (openFaq = openFaq === 3 ? null : 3)}
class="flex w-full items-center justify-between p-4 text-left"
>
<span class="font-medium text-theme-text"> Lohnt sich der Lifetime-Plan? </span>
<svg
class="h-5 w-5 text-theme-text-muted transition-transform {openFaq === 3
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if openFaq === 3}
<div class="border-t border-theme-border px-4 pb-4">
<p class="text-theme-text-muted">
Der Lifetime-Plan (129,99€) amortisiert sich bereits nach etwa 2,2 Jahren im
Vergleich zum monatlichen Plan. Du erhältst alle Pro-Features für immer, ohne
weitere monatliche Gebühren und hast Zugang zu allen zukünftigen Features.
</p>
</div>
{/if}
</div>
<div class="rounded-lg border border-theme-border bg-white dark:bg-gray-800">
<button
onclick={() => (openFaq = openFaq === 4 ? null : 4)}
class="flex w-full items-center justify-between p-4 text-left"
>
<span class="font-medium text-theme-text">Kann ich jederzeit kündigen?</span>
<svg
class="h-5 w-5 text-theme-text-muted transition-transform {openFaq === 4
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if openFaq === 4}
<div class="border-t border-theme-border px-4 pb-4">
<p class="text-theme-text-muted">
Ja, du kannst dein Abo jederzeit in den Einstellungen kündigen. Du behältst den
Zugang bis zum Ende des aktuellen Abrechnungszeitraums. Danach wechselst du
automatisch zum Free Plan.
</p>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<Footer />

View file

@ -0,0 +1,182 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
return {
user: locals.user,
avatarUrl: locals.user?.avatar ? locals.pb.getFileUrl(locals.user, locals.user.avatar) : null,
};
};
export const actions = {
updateProfile: async ({ request, locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
const data = await request.formData();
const name = data.get('name') as string;
const email = data.get('email') as string;
const bio = data.get('bio') as string;
const location = data.get('location') as string;
const website = data.get('website') as string;
const github = data.get('github') as string;
const twitter = data.get('twitter') as string;
const linkedin = data.get('linkedin') as string;
const instagram = data.get('instagram') as string;
const profileBackground = data.get('profileBackground') as string;
const avatar = data.get('avatar') as File;
// Card customization colors
const cardBackground = data.get('cardBackground') as string;
const cardBorder = data.get('cardBorder') as string;
const cardLinks = data.get('cardLinks') as string;
const cardText = data.get('cardText') as string;
try {
// Prepare card customization JSON
const cardCustomization = {
cardBackgroundColor: cardBackground || '#ffffff',
cardBorderColor: cardBorder || '#e2e8f0',
cardLinkColor: cardLinks || '#0ea5e9',
cardTextColor: cardText || '#0f172a',
};
// Prepare update data
const updateData: any = {
name: name || '',
email: email || locals.user.email,
bio: bio || '',
location: location || '',
website: website || '',
github: github || '',
twitter: twitter || '',
linkedin: linkedin || '',
instagram: instagram || '',
profileBackground: profileBackground || '',
cardCustomization: JSON.stringify(cardCustomization),
};
// Add avatar if a new file was uploaded
if (avatar && avatar.size > 0) {
updateData.avatar = avatar;
}
// Use the pb instance from locals which has the correct auth
const updatedUser = await locals.pb.collection('users').update(locals.user.id, updateData);
locals.user = updatedUser;
return {
success: true,
message: 'Profile updated successfully',
};
} catch (err) {
console.error('Profile update error:', err);
console.error('User ID:', locals.user.id);
console.error('Auth valid:', locals.pb.authStore.isValid);
return fail(400, { error: `Failed to update profile: ${err.message || err}` });
}
},
updatePassword: async ({ request, locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
const data = await request.formData();
const currentPassword = data.get('currentPassword') as string;
const newPassword = data.get('newPassword') as string;
const confirmPassword = data.get('confirmPassword') as string;
if (!currentPassword || !newPassword || !confirmPassword) {
return fail(400, { passwordError: 'All password fields are required' });
}
if (newPassword !== confirmPassword) {
return fail(400, { passwordError: 'New passwords do not match' });
}
if (newPassword.length < 8) {
return fail(400, { passwordError: 'Password must be at least 8 characters long' });
}
try {
// First authenticate with current password to verify it's correct
await locals.pb.collection('users').authWithPassword(locals.user.email, currentPassword);
// Update the password
await locals.pb.collection('users').update(locals.user.id, {
password: newPassword,
passwordConfirm: confirmPassword,
});
return {
passwordSuccess: true,
passwordMessage: 'Password updated successfully',
};
} catch (err) {
return fail(400, { passwordError: 'Current password is incorrect' });
}
},
updatePreferences: async ({ request, locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
const data = await request.formData();
const emailNotifications = data.get('emailNotifications') === 'on';
const publicProfile = data.get('publicProfile') === 'on';
const showClickStats = data.get('showClickStats') === 'on';
const defaultExpiry = data.get('defaultExpiry') as string;
try {
await locals.pb.collection('users').update(locals.user.id, {
emailNotifications,
publicProfile,
showClickStats,
defaultExpiry: defaultExpiry ? parseInt(defaultExpiry) : null,
});
return {
preferencesSuccess: true,
preferencesMessage: 'Preferences updated successfully',
};
} catch (err) {
return fail(400, { preferencesError: 'Failed to update preferences' });
}
},
deleteAccount: async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
try {
// Delete all user's links first
const links = await locals.pb.collection('links').getFullList({
filter: `user_id="${locals.user.id}"`,
});
for (const link of links) {
await locals.pb.collection('links').delete(link.id);
}
// Delete the user account
await locals.pb.collection('users').delete(locals.user.id);
// Clear the auth store
locals.pb.authStore.clear();
locals.user = null;
redirect(302, '/');
} catch (err) {
return fail(400, { deleteError: 'Failed to delete account' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,783 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import { toastMessages, notify } from '$lib/services/toast';
import * as m from '$paraglide/messages';
let { data, form }: { data: PageData; form: ActionData } = $props();
let isSubmitting = $state(false);
let showDeleteConfirm = $state(false);
let deleteConfirmText = $state('');
function formatUrl(username: string) {
if (typeof window === 'undefined') return '';
return `${window.location.origin}/p/${username}`;
}
function setCardColors(bg: string, border: string, links: string, text: string) {
const bgInput = document.getElementById('cardBackground') as HTMLInputElement;
const borderInput = document.getElementById('cardBorder') as HTMLInputElement;
const linksInput = document.getElementById('cardLinks') as HTMLInputElement;
const textInput = document.getElementById('cardText') as HTMLInputElement;
if (bgInput) bgInput.value = bg;
if (borderInput) borderInput.value = border;
if (linksInput) linksInput.value = links;
if (textInput) textInput.value = text;
}
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-theme-text dark:text-white">Settings</h1>
<p class="mt-2 text-theme-text dark:text-theme-text">Manage your account and preferences</p>
</div>
<div class="space-y-6">
<!-- Profile Section -->
<div class="rounded-xl bg-white p-6 shadow-xl dark:bg-theme-surface">
<h2 class="mb-6 text-xl font-semibold text-theme-text dark:text-white">
Profile Information
</h2>
<form
method="POST"
action="?/updateProfile"
enctype="multipart/form-data"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
if (result.type === 'success') {
toastMessages.profileUpdated();
} else if (result.type === 'failure' && result.data?.error) {
notify.error(m.error_save(), result.data.error);
}
await update();
isSubmitting = false;
};
}}
>
<div class="space-y-4">
<!-- Avatar Upload Section -->
<div>
<label
for="avatar"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Profile Picture
</label>
<div class="flex items-center space-x-4">
{#if data.avatarUrl}
<img
src={data.avatarUrl}
alt="Profile"
class="h-20 w-20 rounded-full object-cover"
/>
{:else}
<div
class="flex h-20 w-20 items-center justify-center rounded-full bg-theme-surface"
>
<span class="text-2xl font-semibold text-theme-text-muted">
{(data.user?.name || data.user?.username || 'U').charAt(0).toUpperCase()}
</span>
</div>
{/if}
<div>
<input
type="file"
id="avatar"
name="avatar"
accept="image/*"
class="block w-full text-sm text-theme-text file:mr-4 file:rounded-full file:border-0 file:bg-theme-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-theme-primary-hover"
/>
<p class="mt-1 text-xs text-theme-text-muted">JPG, PNG oder GIF. Max 5MB.</p>
</div>
</div>
</div>
<div>
<label
for="username"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Username
</label>
<input
type="text"
id="username"
name="username"
value={data.user?.username || ''}
disabled
readonly
class="w-full cursor-not-allowed rounded-md border border-theme-border bg-gray-100 px-3 py-2 text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
/>
{#if data.user?.username}
<p class="mt-1 text-xs text-theme-text-muted dark:text-theme-text-muted">
Profile URL: {formatUrl(data.user.username)} • Username kann nicht geändert werden
</p>
{/if}
</div>
<div>
<label
for="name"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Display Name
</label>
<input
type="text"
id="name"
name="name"
value={data.user?.name || ''}
placeholder="John Doe"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label
for="email"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={data.user?.email || ''}
required
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label
for="bio"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Bio
</label>
<textarea
id="bio"
name="bio"
rows="3"
value={data.user?.bio || ''}
placeholder="Tell visitors about yourself..."
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
></textarea>
</div>
<div>
<label
for="location"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Location
</label>
<input
type="text"
id="location"
name="location"
value={data.user?.location || ''}
placeholder="San Francisco, CA"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<!-- Profile Appearance -->
<div class="border-t border-theme-border pt-4">
<h3 class="mb-3 text-sm font-medium text-theme-text dark:text-theme-text">
Profile Appearance
</h3>
<div class="mb-4">
<label for="profileBackground" class="mb-2 block text-xs text-theme-text-muted">
Profile Background
</label>
<div class="flex items-center gap-3">
<input
type="color"
id="profileBackground"
name="profileBackground"
value={data.user?.profileBackground || '#f9fafb'}
class="h-10 w-20 cursor-pointer rounded border border-theme-border"
/>
<select
name="profileBackgroundPreset"
onchange={(e) => {
const input = document.getElementById(
'profileBackground'
) as HTMLInputElement;
if (input && e.currentTarget.value) {
input.value = e.currentTarget.value;
}
}}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text"
>
<option value="">Custom Color</option>
<option value="#f9fafb">Light Gray (Default)</option>
<option value="#dbeafe">Light Blue</option>
<option value="#dcfce7">Light Green</option>
<option value="#fef3c7">Light Yellow</option>
<option value="#fce7f3">Light Pink</option>
<option value="#e9d5ff">Light Purple</option>
<option value="#1f2937">Dark Gray</option>
<option value="#0f172a">Dark Blue</option>
<option value="#000000">Black</option>
</select>
<span class="text-sm text-theme-text-muted">
Choose a color for your profile page background
</span>
</div>
</div>
<!-- Card Appearance Customization -->
<div class="mb-6 border-t border-theme-border pt-4">
<h4 class="mb-3 text-sm font-medium text-theme-text">Card Appearance</h4>
<p class="mb-4 text-xs text-theme-text-muted">
Customize the colors of your cards to match your style
</p>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Card Background Color -->
<div>
<label for="cardBackground" class="mb-2 block text-xs text-theme-text-muted">
Card Background
</label>
<div class="flex items-center gap-2">
<input
type="color"
id="cardBackground"
name="cardBackground"
value={data.user?.cardCustomization?.cardBackgroundColor || '#ffffff'}
class="h-8 w-16 cursor-pointer rounded border border-theme-border"
/>
<span class="text-xs text-theme-text-muted">Background color</span>
</div>
</div>
<!-- Card Border Color -->
<div>
<label for="cardBorder" class="mb-2 block text-xs text-theme-text-muted">
Card Border
</label>
<div class="flex items-center gap-2">
<input
type="color"
id="cardBorder"
name="cardBorder"
value={data.user?.cardCustomization?.cardBorderColor || '#e2e8f0'}
class="h-8 w-16 cursor-pointer rounded border border-theme-border"
/>
<span class="text-xs text-theme-text-muted">Border color</span>
</div>
</div>
<!-- Card Link Color -->
<div>
<label for="cardLinks" class="mb-2 block text-xs text-theme-text-muted">
Card Links/Buttons
</label>
<div class="flex items-center gap-2">
<input
type="color"
id="cardLinks"
name="cardLinks"
value={data.user?.cardCustomization?.cardLinkColor || '#0ea5e9'}
class="h-8 w-16 cursor-pointer rounded border border-theme-border"
/>
<span class="text-xs text-theme-text-muted">Link & button color</span>
</div>
</div>
<!-- Card Text Color -->
<div>
<label for="cardText" class="mb-2 block text-xs text-theme-text-muted">
Card Text
</label>
<div class="flex items-center gap-2">
<input
type="color"
id="cardText"
name="cardText"
value={data.user?.cardCustomization?.cardTextColor || '#0f172a'}
class="h-8 w-16 cursor-pointer rounded border border-theme-border"
/>
<span class="text-xs text-theme-text-muted">Text color</span>
</div>
</div>
</div>
<!-- Card Color Presets -->
<div class="mt-4">
<label class="mb-2 block text-xs text-theme-text-muted"> Quick Presets </label>
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => setCardColors('#ffffff', '#e2e8f0', '#0ea5e9', '#0f172a')}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
>
Default
</button>
<button
type="button"
onclick={() => setCardColors('#f8fafc', '#cbd5e1', '#3b82f6', '#1e293b')}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
>
Cool Blue
</button>
<button
type="button"
onclick={() => setCardColors('#f0fdf4', '#bbf7d0', '#22c55e', '#166534')}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
>
Fresh Green
</button>
<button
type="button"
onclick={() => setCardColors('#1f2937', '#374151', '#60a5fa', '#f3f4f6')}
class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
>
Dark Mode
</button>
</div>
</div>
</div>
</div>
<!-- Social Links Section -->
<div class="border-t border-theme-border pt-4">
<h3 class="mb-3 text-sm font-medium text-theme-text dark:text-theme-text">
Social Links
</h3>
<div class="space-y-3">
<div>
<label for="website" class="mb-1 block text-xs text-theme-text-muted">
Website
</label>
<input
type="url"
id="website"
name="website"
value={data.user?.website || ''}
placeholder="https://example.com"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label for="github" class="mb-1 block text-xs text-theme-text-muted">
GitHub Username
</label>
<input
type="text"
id="github"
name="github"
value={data.user?.github || ''}
placeholder="username"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label for="twitter" class="mb-1 block text-xs text-theme-text-muted">
Twitter/X Username
</label>
<input
type="text"
id="twitter"
name="twitter"
value={data.user?.twitter || ''}
placeholder="username"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label for="linkedin" class="mb-1 block text-xs text-theme-text-muted">
LinkedIn Username
</label>
<input
type="text"
id="linkedin"
name="linkedin"
value={data.user?.linkedin || ''}
placeholder="username"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label for="instagram" class="mb-1 block text-xs text-theme-text-muted">
Instagram Username
</label>
<input
type="text"
id="instagram"
name="instagram"
value={data.user?.instagram || ''}
placeholder="username"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
class="rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary disabled:cursor-not-allowed disabled:opacity-50 dark:bg-theme-primary dark:hover:bg-theme-primary"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
{#if form?.success}
<div
class="mt-4 rounded-lg bg-green-50 p-3 text-green-700 dark:bg-green-900/20 dark:text-green-400"
>
{form.message}
</div>
{/if}
{#if form?.error}
<div
class="mt-4 rounded-lg bg-red-50 p-3 text-red-700 dark:bg-red-900/20 dark:text-red-400"
>
{form.error}
</div>
{/if}
</div>
<!-- Password Section -->
<div class="rounded-xl bg-white p-6 shadow-xl dark:bg-theme-surface">
<h2 class="mb-6 text-xl font-semibold text-theme-text dark:text-white">Change Password</h2>
<form
method="POST"
action="?/updatePassword"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
if (result.type === 'success') {
toastMessages.passwordChanged();
} else if (result.type === 'failure' && result.data?.error) {
notify.error(m.error_password_change(), result.data.error);
}
await update();
isSubmitting = false;
};
}}
>
<div class="space-y-4">
<div>
<label
for="currentPassword"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Current Password
</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
required
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label
for="newPassword"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
New Password
</label>
<input
type="password"
id="newPassword"
name="newPassword"
minlength="8"
required
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label
for="confirmPassword"
class="mb-1 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Confirm New Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
minlength="8"
required
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
class="rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary disabled:cursor-not-allowed disabled:opacity-50 dark:bg-theme-primary dark:hover:bg-theme-primary"
>
{isSubmitting ? 'Updating...' : 'Update Password'}
</button>
</div>
</form>
{#if form?.passwordSuccess}
<div
class="mt-4 rounded-lg bg-green-50 p-3 text-green-700 dark:bg-green-900/20 dark:text-green-400"
>
{form.passwordMessage}
</div>
{/if}
{#if form?.passwordError}
<div
class="mt-4 rounded-lg bg-red-50 p-3 text-red-700 dark:bg-red-900/20 dark:text-red-400"
>
{form.passwordError}
</div>
{/if}
</div>
<!-- Workspaces Section -->
<div class="rounded-xl bg-white p-6 shadow-xl dark:bg-theme-surface">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-theme-text dark:text-white">Workspaces</h2>
<p class="mt-1 text-sm text-theme-text-muted">
Manage your workspaces and team collaboration
</p>
</div>
<a
href="/settings/workspaces"
class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Manage Workspaces
</a>
</div>
<div class="mt-4 rounded-lg bg-theme-surface p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-theme-text-muted">Current Plan</p>
<p class="font-medium capitalize text-theme-text">
{data.user?.subscription_status || 'free'}
</p>
</div>
<div class="text-right">
<p class="text-sm text-theme-text-muted">Team Members</p>
<p class="font-medium text-theme-text">
{#if data.user?.subscription_status === 'free'}
0 / 1
{:else if data.user?.subscription_status === 'pro'}
0 / 3
{:else if data.user?.subscription_status === 'team'}
0 / 10
{:else if data.user?.subscription_status === 'team_plus'}
0 / ∞
{:else}
0 / 1
{/if}
</p>
</div>
</div>
</div>
</div>
<!-- Preferences Section -->
<div class="rounded-xl bg-white p-6 shadow-xl dark:bg-theme-surface">
<h2 class="mb-6 text-xl font-semibold text-theme-text dark:text-white">Preferences</h2>
<form
method="POST"
action="?/updatePreferences"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
>
<div class="space-y-6">
<div>
<h3 class="mb-3 text-sm font-medium text-theme-text dark:text-theme-text">
Notifications
</h3>
<label class="flex cursor-pointer items-center space-x-3">
<input
type="checkbox"
name="emailNotifications"
checked={data.user?.emailNotifications}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-accent"
/>
<span class="text-sm text-theme-text dark:text-theme-text">
Email me when my links reach click milestones
</span>
</label>
</div>
<div>
<h3 class="mb-3 text-sm font-medium text-theme-text dark:text-theme-text">Privacy</h3>
<label class="flex cursor-pointer items-center space-x-3">
<input
type="checkbox"
name="publicProfile"
checked={data.user?.publicProfile !== false}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-accent"
/>
<span class="text-sm text-theme-text dark:text-theme-text">
Make my profile public at /p/{data.user?.username || 'username'}
</span>
</label>
<label class="mt-3 flex cursor-pointer items-center space-x-3">
<input
type="checkbox"
name="showClickStats"
checked={data.user?.showClickStats !== false}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-accent"
/>
<span class="text-sm text-theme-text dark:text-theme-text">
Show click statistics on public profile
</span>
</label>
</div>
<div>
<h3 class="mb-3 text-sm font-medium text-theme-text dark:text-theme-text">
Default Settings
</h3>
<label
for="defaultExpiry"
class="mb-1 block text-sm text-theme-text dark:text-theme-text"
>
Default link expiry (days)
</label>
<input
type="number"
id="defaultExpiry"
name="defaultExpiry"
min="1"
max="365"
value={data.user?.defaultExpiry || ''}
placeholder="Never"
class="w-full max-w-xs rounded-md border border-theme-border bg-white px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-theme-border dark:bg-theme-surface dark:text-white"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
class="rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary disabled:cursor-not-allowed disabled:opacity-50 dark:bg-theme-primary dark:hover:bg-theme-primary"
>
{isSubmitting ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</form>
{#if form?.preferencesSuccess}
<div
class="mt-4 rounded-lg bg-green-50 p-3 text-green-700 dark:bg-green-900/20 dark:text-green-400"
>
{form.preferencesMessage}
</div>
{/if}
{#if form?.preferencesError}
<div
class="mt-4 rounded-lg bg-red-50 p-3 text-red-700 dark:bg-red-900/20 dark:text-red-400"
>
{form.preferencesError}
</div>
{/if}
</div>
<!-- Danger Zone Section -->
<div class="rounded-xl bg-white p-6 shadow-xl dark:bg-theme-surface">
<h2 class="mb-6 text-xl font-semibold text-red-600 dark:text-red-400">Danger Zone</h2>
<div
class="rounded-lg border-2 border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-900/20"
>
<h3 class="mb-2 text-lg font-semibold text-red-700 dark:text-red-400">Delete Account</h3>
<p class="mb-4 text-sm text-red-600 dark:text-red-300">
Once you delete your account, there is no going back. All your links and data will be
permanently removed.
</p>
{#if !showDeleteConfirm}
<button
onclick={() => (showDeleteConfirm = true)}
class="rounded-lg bg-red-600 px-6 py-2 font-medium text-white transition-colors hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
>
Delete My Account
</button>
{:else}
<div class="space-y-4">
<p class="text-sm text-red-700 dark:text-red-300">
Please type <strong>DELETE</strong> to confirm:
</p>
<input
type="text"
bind:value={deleteConfirmText}
placeholder="Type DELETE"
class="w-full max-w-xs rounded-md border border-red-300 bg-white px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-600 dark:bg-theme-surface dark:text-white"
/>
<div class="flex gap-3">
<form
method="POST"
action="?/deleteAccount"
use:enhance={() => {
if (deleteConfirmText !== 'DELETE') {
alert('Please type DELETE to confirm');
return () => {};
}
return async ({ update }) => {
await update();
};
}}
>
<button
type="submit"
disabled={deleteConfirmText !== 'DELETE'}
class="rounded-lg bg-red-600 px-6 py-2 font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-600"
>
Permanently Delete Account
</button>
</form>
<button
onclick={() => {
showDeleteConfirm = false;
deleteConfirmText = '';
}}
class="rounded-lg border border-theme-border bg-white px-6 py-2 font-medium text-theme-text transition-colors hover:bg-theme-surface dark:border-theme-border dark:bg-theme-surface dark:text-theme-text dark:hover:bg-theme-surface"
>
Cancel
</button>
</div>
</div>
{/if}
{#if form?.deleteError}
<div
class="mt-4 rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{form.deleteError}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,240 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { canAddTeamMembers, getTeamMemberLimit, DEFAULT_PERMISSIONS } from '$lib/types/accounts';
import type { SharedAccess } from '$lib/types/accounts';
import { sendTeamInvitationEmail, sendInvitationAcceptedEmail } from '$lib/email';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
// Get team members for this account
const teamMembers = await locals.pb.collection('shared_access').getList<SharedAccess>(1, 50, {
filter: `owner="${locals.user.id}" && invitation_status="accepted"`,
expand: 'user',
sort: '-created',
});
// Get pending invitations for existing users
const pendingInvites = await locals.pb.collection('shared_access').getList<SharedAccess>(1, 50, {
filter: `owner="${locals.user.id}" && invitation_status="pending"`,
expand: 'user',
sort: '-created',
});
// Get pending invitations for new users
const pendingNewUserInvites = await locals.pb.collection('pending_invitations').getList(1, 50, {
filter: `owner="${locals.user.id}" && accepted_at = null && expires_at > "${new Date().toISOString()}"`,
sort: '-created',
});
return {
teamMembers: teamMembers.items,
pendingInvites: pendingInvites.items,
pendingNewUserInvites: pendingNewUserInvites.items,
};
};
export const actions = {
invite: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not authenticated' });
}
// Everyone can invite team members, but check limits based on plan
const data = await request.formData();
const email = data.get('email') as string;
if (!email) {
return fail(400, { error: 'Email is required' });
}
// Check team member limit based on subscription
const teamLimit = getTeamMemberLimit(locals.user.subscription_status);
const currentMembers = await locals.pb.collection('shared_access').getList(1, 1, {
filter: `owner="${locals.user.id}" && (invitation_status="accepted" || invitation_status="pending")`,
});
if (teamLimit !== Infinity && currentMembers.totalItems >= teamLimit) {
return fail(403, {
error: `Team member limit reached. Your ${locals.user.subscription_status || 'free'} plan allows ${teamLimit} team member${teamLimit === 1 ? '' : 's'}.`,
});
}
try {
// Check if user exists
let invitedUser;
let isNewUser = false;
try {
invitedUser = await locals.pb.collection('users').getFirstListItem(`email="${email}"`);
} catch {
// User doesn't exist yet - we'll create a pending invitation
isNewUser = true;
}
if (!isNewUser) {
// Check if already invited
const existing = await locals.pb.collection('shared_access').getList(1, 1, {
filter: `owner="${locals.user.id}" && user="${invitedUser.id}"`,
});
if (existing.items.length > 0) {
return fail(400, { error: 'This user already has access or is already invited' });
}
// Create the invitation for existing user
const invitation = await locals.pb.collection('shared_access').create({
owner: locals.user.id,
user: invitedUser.id,
permissions: DEFAULT_PERMISSIONS,
invitation_token: generateInviteToken(),
invitation_status: 'pending',
invited_at: new Date().toISOString(),
});
// Email will be sent automatically by PocketBase hook
return { success: true, message: 'Invitation sent successfully' };
} else {
// Create pending invitation for new user
const token = generateInviteToken();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiry
const invitation = await locals.pb.collection('pending_invitations').create({
email,
owner: locals.user.id,
token,
expires_at: expiresAt.toISOString(),
});
// Send invitation email
const inviterName = locals.user.name || locals.user.username || locals.user.email;
const emailSent = await sendTeamInvitationEmail(email, inviterName, token);
if (!emailSent) {
console.error('[TEAM] Failed to send invitation email to:', email);
}
return {
success: true,
message: 'Invitation sent! The user will need to create an account to join your team.',
};
}
} catch (err: any) {
console.error('Error inviting team member:', err);
return fail(500, { error: 'Failed to send invitation' });
}
},
remove: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not authenticated' });
}
const data = await request.formData();
const memberId = data.get('memberId') as string;
if (!memberId) {
return fail(400, { error: 'Member ID is required' });
}
try {
// Verify ownership
const member = await locals.pb.collection('shared_access').getOne(memberId);
if (member.owner !== locals.user.id) {
return fail(403, { error: 'Not authorized' });
}
// Remove access
await locals.pb.collection('shared_access').delete(memberId);
return { success: true, message: 'Team member removed' };
} catch (err: any) {
console.error('Error removing team member:', err);
return fail(500, { error: 'Failed to remove team member' });
}
},
cancelInvite: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not authenticated' });
}
const data = await request.formData();
const inviteId = data.get('inviteId') as string;
if (!inviteId) {
return fail(400, { error: 'Invite ID is required' });
}
try {
// Verify ownership
const invite = await locals.pb.collection('shared_access').getOne(inviteId);
if (invite.owner !== locals.user.id) {
return fail(403, { error: 'Not authorized' });
}
// Cancel invitation
await locals.pb.collection('shared_access').delete(inviteId);
return { success: true, message: 'Invitation cancelled' };
} catch (err: any) {
console.error('Error cancelling invitation:', err);
return fail(500, { error: 'Failed to cancel invitation' });
}
},
resend: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not authenticated' });
}
const data = await request.formData();
const inviteId = data.get('inviteId') as string;
if (!inviteId) {
return fail(400, { error: 'Invite ID is required' });
}
try {
// Verify ownership
const invite = await locals.pb.collection('shared_access').getOne(inviteId);
if (invite.owner !== locals.user.id) {
return fail(403, { error: 'Not authorized' });
}
// Update invitation with new token
const newToken = generateInviteToken();
await locals.pb.collection('shared_access').update(inviteId, {
invitation_token: newToken,
invited_at: new Date().toISOString(),
});
// Resend invitation email
const inviterName = locals.user.name || locals.user.username || locals.user.email;
const emailSent = await sendTeamInvitationEmail(invite.user.email, inviterName, newToken);
if (!emailSent) {
console.error('[TEAM] Failed to resend invitation email to:', invite.user.email);
}
return { success: true, message: 'Invitation resent' };
} catch (err: any) {
console.error('Error resending invitation:', err);
return fail(500, { error: 'Failed to resend invitation' });
}
},
} satisfies Actions;
function generateInviteToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 32; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
}

View file

@ -0,0 +1,285 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Users, Mail, UserPlus, Trash2, Shield, AlertCircle, Check, X } from 'lucide-svelte';
import type { PageData, ActionData } from './$types';
import { canAddTeamMembers, getTeamMemberLimit } from '$lib/types/accounts';
let { data, form }: { data: PageData; form: ActionData } = $props();
let inviteEmail = $state('');
let isInviting = $state(false);
// Everyone can invite, but with different limits
const teamLimit = $derived(getTeamMemberLimit(data.user?.subscription_status));
const remainingSlots = $derived(
teamLimit === 0 ? Infinity : teamLimit - (data.teamMembers?.length || 0)
);
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-theme-text">Team Management</h1>
<p class="mt-2 text-theme-text-muted">Invite team members to collaborate on your account</p>
</div>
<!-- Current Plan Info -->
<div class="mb-6 rounded-lg bg-theme-surface p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-theme-text-muted">Current Plan</p>
<p class="text-xl font-semibold capitalize text-theme-text">
{data.user?.subscription_status || 'free'}
</p>
</div>
<div class="text-right">
<p class="text-sm text-theme-text-muted">Team Members</p>
<p class="text-xl font-semibold text-theme-text">
{data.teamMembers?.length || 0}{teamLimit > 0 ? ` / ${teamLimit}` : ''}
</p>
</div>
</div>
{#if teamLimit > 0 && (data.teamMembers?.length || 0) >= teamLimit}
<div class="bg-theme-warning/10 mt-4 rounded-lg p-4">
<div class="flex items-start gap-3">
<AlertCircle class="text-theme-warning h-5 w-5" />
<div>
<p class="font-medium text-theme-text">Team member limit reached</p>
<p class="mt-1 text-sm text-theme-text-muted">
Upgrade to a higher plan for more team members.
</p>
<a
href="/pricing"
class="mt-2 inline-block text-sm font-medium text-theme-primary hover:underline"
>
View Plans →
</a>
</div>
</div>
</div>
{/if}
</div>
<!-- Invite Form - Available for all users -->
<div class="mb-8 rounded-lg bg-white p-6 shadow-sm dark:bg-gray-800">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<UserPlus class="h-5 w-5" />
Invite Team Member
</h2>
{#if teamLimit === 0 || remainingSlots > 0}
<form
method="POST"
action="?/invite"
use:enhance={() => {
isInviting = true;
return async ({ update }) => {
await update();
isInviting = false;
if (form?.success) {
inviteEmail = '';
}
};
}}
>
<div class="flex gap-3">
<input
type="email"
name="email"
bind:value={inviteEmail}
placeholder="colleague@example.com"
required
class="focus:ring-theme-primary/20 flex-1 rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2"
/>
<button
type="submit"
disabled={isInviting || !inviteEmail}
class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 font-medium text-white hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Mail class="h-4 w-4" />
Send Invite
</button>
</div>
</form>
{#if form?.error}
<div class="mt-3 text-sm text-red-600 dark:text-red-400">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mt-3">
<p class="text-sm text-green-600 dark:text-green-400">
✓ Invitation created successfully!
</p>
<p class="mt-1 text-xs text-yellow-600 dark:text-yellow-400">
⚠️ E-Mail-Versand ist möglicherweise nicht konfiguriert. Du findest den Einladungslink
unten zum manuellen Teilen.
</p>
</div>
{/if}
{:else}
<div class="rounded-lg bg-theme-surface p-4">
<p class="text-sm text-theme-text-muted">
You've reached your team member limit. Upgrade to Team Plus for more members.
</p>
</div>
{/if}
</div>
<!-- Team Members List -->
{#if data.teamMembers && data.teamMembers.length > 0}
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800">
<div class="border-b border-theme-border px-6 py-4">
<h2 class="flex items-center gap-2 text-lg font-semibold text-theme-text">
<Users class="h-5 w-5" />
Team Members ({data.teamMembers.length})
</h2>
</div>
<div class="divide-y divide-theme-border">
{#each data.teamMembers as member}
<div class="flex items-center justify-between px-6 py-4">
<div class="flex items-center gap-4">
<div
class="bg-theme-primary/10 flex h-10 w-10 items-center justify-center rounded-full text-theme-primary"
>
{member.user?.email?.[0]?.toUpperCase() || 'U'}
</div>
<div>
<p class="font-medium text-theme-text">
{member.user?.name || member.user?.email}
</p>
<p class="text-sm text-theme-text-muted">
{member.user?.email}
</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if member.invitation_status === 'pending'}
<span
class="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
>
Pending
</span>
{:else if member.invitation_status === 'accepted'}
<span
class="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
Active
</span>
{/if}
{#if member.permissions?.manage_team}
<Shield class="h-4 w-4 text-theme-text-muted" title="Team Admin" />
{/if}
<form method="POST" action="?/remove" use:enhance>
<input type="hidden" name="memberId" value={member.id} />
<button
type="submit"
class="rounded p-1 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
title="Remove team member"
>
<Trash2 class="h-4 w-4" />
</button>
</form>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="rounded-lg bg-theme-surface p-8 text-center">
<Users class="mx-auto mb-3 h-12 w-12 text-theme-text-muted" />
<p class="text-theme-text-muted">No team members yet. Invite someone to get started!</p>
</div>
{/if}
<!-- Pending Invitations -->
{#if (data.pendingInvites && data.pendingInvites.length > 0) || (data.pendingNewUserInvites && data.pendingNewUserInvites.length > 0)}
<div class="mt-6 rounded-lg bg-white shadow-sm dark:bg-gray-800">
<div class="border-b border-theme-border px-6 py-4">
<h2 class="text-lg font-semibold text-theme-text">Pending Invitations</h2>
</div>
<div class="divide-y divide-theme-border">
{#each data.pendingInvites as invite}
<div class="flex items-center justify-between px-6 py-4">
<div>
<p class="font-medium text-theme-text">
{invite.expand?.user?.email || 'Unknown'}
</p>
<p class="text-sm text-theme-text-muted">
Invited {new Date(invite.invited_at).toLocaleDateString()} · Existing user
</p>
</div>
<div class="flex gap-2">
<form method="POST" action="?/resend" use:enhance>
<input type="hidden" name="inviteId" value={invite.id} />
<button type="submit" class="text-sm text-theme-primary hover:underline">
Resend
</button>
</form>
<form method="POST" action="?/cancelInvite" use:enhance>
<input type="hidden" name="inviteId" value={invite.id} />
<button
type="submit"
class="text-sm text-red-600 hover:underline dark:text-red-400"
>
Cancel
</button>
</form>
</div>
</div>
{/each}
{#each data.pendingNewUserInvites || [] as invite}
<div class="px-6 py-4">
<div class="mb-2 flex items-center justify-between">
<div>
<p class="font-medium text-theme-text">
{invite.email}
</p>
<p class="text-sm text-theme-text-muted">
Invited {new Date(invite.created).toLocaleDateString()} · New user (needs to sign up)
</p>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => {
const url = `${window.location.origin}/register?invite=${invite.token}`;
navigator.clipboard.writeText(url);
// Simple feedback
event.target.textContent = 'Copied!';
setTimeout(() => {
event.target.textContent = 'Copy invite link';
}, 2000);
}}
class="text-sm text-theme-primary hover:underline"
>
Copy invite link
</button>
</div>
</div>
<!-- Show invite URL for manual sharing -->
<div
class="mt-2 break-all rounded bg-gray-50 p-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
<span class="font-medium">Invite link:</span>
{window.location.origin}/register?invite={invite.token}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,98 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { Workspace, WorkspaceMember } from '$lib/stores/workspaces';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
try {
// Get personal workspace
const personalWorkspaces = await locals.pb.collection('workspaces').getList<Workspace>(1, 1, {
filter: `owner="${locals.user.id}" && type="personal"`,
sort: 'created',
});
// Get team workspaces (owned and member)
const ownedWorkspaces = await locals.pb.collection('workspaces').getList<Workspace>(1, 50, {
filter: `owner="${locals.user.id}" && type="team"`,
sort: '-created',
});
// Get workspaces where user is a member
const memberships = await locals.pb
.collection('workspace_members')
.getList<WorkspaceMember>(1, 50, {
filter: `user="${locals.user.id}" && invitation_status="accepted"`,
expand: 'workspace',
sort: '-created',
});
const memberWorkspaces = memberships.items
.filter((m) => m.expand?.workspace && m.expand.workspace.owner !== locals.user.id)
.map((m) => m.expand!.workspace as Workspace);
// Get pending invitations
const invitations = await locals.pb
.collection('workspace_members')
.getList<WorkspaceMember>(1, 50, {
filter: `user="${locals.user.id}" && invitation_status="pending"`,
expand: 'workspace',
sort: '-invited_at',
});
// Get stats for personal workspace
let personalStats = { links: 0, clicks: 0 };
if (personalWorkspaces.items[0]) {
try {
const links = await locals.pb.collection('links').getList(1, 1, {
filter: `workspace_id="${personalWorkspaces.items[0].id}"`,
});
personalStats.links = links.totalItems;
// Note: Would need to aggregate clicks through links
// For now, keeping it simple
} catch (error) {
console.error('Error fetching stats:', error);
}
}
// Add member counts to team workspaces
const teamWorkspacesWithCounts = await Promise.all(
[...ownedWorkspaces.items, ...memberWorkspaces].map(async (workspace) => {
try {
const members = await locals.pb.collection('workspace_members').getList(1, 1, {
filter: `workspace="${workspace.id}"`,
});
return {
...workspace,
memberCount: members.totalItems,
};
} catch {
return {
...workspace,
memberCount: 0,
};
}
})
);
return {
user: locals.user,
personalWorkspace: personalWorkspaces.items[0] || null,
teamWorkspaces: teamWorkspacesWithCounts,
invitations: invitations.items,
personalStats,
};
} catch (error) {
console.error('Error loading workspaces:', error);
return {
user: locals.user,
personalWorkspace: null,
teamWorkspaces: [],
invitations: [],
personalStats: { links: 0, clicks: 0 },
};
}
};

View file

@ -0,0 +1,224 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Building2, Plus, Users, User, Settings, ExternalLink } from 'lucide-svelte';
import type { PageData } from './$types';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
let { data }: { data: PageData } = $props();
function openWorkspace(workspace: any) {
// Set the active workspace in the store
activeWorkspace.set(workspace);
// Navigate using the store's navigation helper
activeWorkspace.goto('/my');
}
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-theme-text">Workspaces</h1>
<p class="mt-2 text-theme-text-muted">
Manage your workspaces and switch between different contexts
</p>
<!-- Workspace count display -->
<p class="mt-1 text-sm text-theme-text-muted">
{data.teamWorkspaces.length} von {data.user?.subscription_tier === 'pro'
? '10'
: data.user?.subscription_tier === 'business'
? 'unbegrenzt'
: '1'} Team-Workspaces erstellt
</p>
</div>
<button
onclick={() => goto('/settings/workspaces/new')}
class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
<Plus class="h-4 w-4" />
Create Workspace
</button>
</div>
<!-- Workspace Grid -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Personal Workspace -->
{#if data.personalWorkspace}
<div
class="rounded-lg border border-theme-border bg-white p-6 shadow-sm transition-all hover:shadow-md dark:bg-gray-800"
>
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="bg-theme-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<User class="h-5 w-5 text-theme-primary" />
</div>
<div>
<h3 class="font-semibold text-theme-text">
{data.personalWorkspace.name}
</h3>
<p class="text-sm text-theme-text-muted">Personal Workspace</p>
</div>
</div>
<span
class="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400"
>
Active
</span>
</div>
{#if data.personalWorkspace.description}
<p class="mb-4 text-sm text-theme-text-muted">
{data.personalWorkspace.description}
</p>
{/if}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm text-theme-text-muted">
<span>{data.personalStats?.links || 0} links</span>
<span>{data.personalStats?.clicks || 0} clicks</span>
</div>
<div class="flex gap-2">
<button
onclick={() => openWorkspace(data.personalWorkspace)}
class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
title="Open workspace"
>
<ExternalLink class="h-4 w-4" />
</button>
<button
onclick={() => goto(`/settings/workspaces/${data.personalWorkspace?.id}`)}
class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
title="Settings"
>
<Settings class="h-4 w-4" />
</button>
</div>
</div>
</div>
{/if}
<!-- Team Workspaces -->
{#each data.teamWorkspaces as workspace}
<div
class="rounded-lg border border-theme-border bg-white p-6 shadow-sm transition-all hover:shadow-md dark:bg-gray-800"
>
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/20"
>
<Users class="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="font-semibold text-theme-text">
{workspace.name}
</h3>
<p class="text-sm text-theme-text-muted">Team Workspace</p>
</div>
</div>
{#if workspace.owner === data.user?.id}
<span
class="rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
>
Owner
</span>
{:else}
<span
class="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
Member
</span>
{/if}
</div>
{#if workspace.description}
<p class="mb-4 text-sm text-theme-text-muted">
{workspace.description}
</p>
{/if}
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 text-sm text-theme-text-muted">
<span>{workspace.memberCount || 0} members</span>
</div>
<div class="flex gap-2">
<button
onclick={() => openWorkspace(workspace)}
class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
title="Open workspace"
>
<ExternalLink class="h-4 w-4" />
</button>
<button
onclick={() => goto(`/settings/workspaces/${workspace.id}`)}
class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
title="Settings"
>
<Settings class="h-4 w-4" />
</button>
</div>
</div>
</div>
{/each}
<!-- Workspace Invitations -->
{#each data.invitations as invitation}
<div class="bg-theme-surface/50 rounded-lg border-2 border-dashed border-theme-border p-6">
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100 dark:bg-yellow-900/20"
>
<Users class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<h3 class="font-semibold text-theme-text">
{invitation.expand?.workspace?.name || 'Workspace Invitation'}
</h3>
<p class="text-sm text-theme-text-muted">Pending Invitation</p>
</div>
</div>
<span
class="rounded-full bg-yellow-100 px-2 py-1 text-xs font-medium text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400"
>
Pending
</span>
</div>
<p class="mb-4 text-sm text-theme-text-muted">You've been invited to join this workspace</p>
<div class="flex gap-2">
<button
onclick={() => goto(`/team/accept-invite?token=${invitation.invitation_token}`)}
class="flex-1 rounded-lg bg-theme-primary px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Accept Invitation
</button>
<button
class="flex-1 rounded-lg border border-theme-border px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Decline
</button>
</div>
</div>
{/each}
</div>
<!-- Empty State -->
{#if !data.personalWorkspace && data.teamWorkspaces.length === 0 && data.invitations.length === 0}
<div class="rounded-lg border-2 border-dashed border-theme-border p-12 text-center">
<Building2 class="mx-auto mb-4 h-12 w-12 text-theme-text-muted" />
<h3 class="mb-2 text-lg font-medium text-theme-text">No workspaces yet</h3>
<p class="mb-6 text-sm text-theme-text-muted">
Create your first workspace to start organizing your links
</p>
<button
onclick={() => goto('/settings/workspaces/new')}
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
<Plus class="h-4 w-4" />
Create Workspace
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,242 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import type { Workspace, WorkspaceMember } from '$lib/stores/workspaces';
export const load: PageServerLoad = async ({ params, locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
try {
// Get workspace details
const workspace = await locals.pb.collection('workspaces').getOne<Workspace>(params.id);
// Check if user has access
const currentMember = await locals.pb
.collection('workspace_members')
.getList<WorkspaceMember>(1, 1, {
filter: `workspace="${params.id}" && user="${locals.user.id}"`,
});
if (workspace.owner !== locals.user.id && currentMember.items.length === 0) {
redirect(303, '/settings');
}
// Get all members
const members = await locals.pb
.collection('workspace_members')
.getList<WorkspaceMember>(1, 100, {
filter: `workspace="${params.id}"`,
expand: 'user',
sort: 'role,created',
});
return {
workspace,
members: members.items,
currentMember: currentMember.items[0] || null,
user: locals.user,
};
} catch (error) {
console.error('Error loading workspace:', error);
redirect(303, '/settings');
}
};
export const actions = {
update: async ({ request, params, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const name = data.get('name') as string;
const description = data.get('description') as string;
const slug = data.get('slug') as string;
try {
// Check permissions
const workspace = await locals.pb.collection('workspaces').getOne(params.id);
const member = await locals.pb.collection('workspace_members').getList(1, 1, {
filter: `workspace="${params.id}" && user="${locals.user.id}"`,
});
const isOwner = workspace.owner === locals.user.id;
const isAdmin = member.items[0]?.role === 'admin';
if (!isOwner && !isAdmin) {
return fail(403, { error: 'You do not have permission to edit this workspace' });
}
// Validate slug if provided
if (slug) {
const slugPattern = /^[a-z0-9-]+$/;
if (!slugPattern.test(slug)) {
return fail(400, {
error: 'Slug can only contain lowercase letters, numbers, and hyphens',
});
}
// Check if slug is unique (excluding current workspace)
try {
const existing = await locals.pb
.collection('workspaces')
.getFirstListItem(`slug="${slug}" && id!="${params.id}"`);
if (existing) {
return fail(400, { error: 'This slug is already taken' });
}
} catch (err) {
// No existing workspace with this slug, which is good
}
}
// Update workspace
await locals.pb.collection('workspaces').update(params.id, {
name: name.trim(),
description: description?.trim() || '',
slug: slug?.trim() || null,
});
return { success: true, message: 'Workspace settings updated' };
} catch (error) {
console.error('Error updating workspace:', error);
return fail(400, { error: 'Failed to update workspace' });
}
},
invite: async ({ request, params, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const email = data.get('email') as string;
const role = data.get('role') as 'admin' | 'member';
try {
// Check permissions
const workspace = await locals.pb.collection('workspaces').getOne(params.id);
const member = await locals.pb.collection('workspace_members').getList(1, 1, {
filter: `workspace="${params.id}" && user="${locals.user.id}"`,
});
const canInvite = workspace.owner === locals.user.id || member.items[0]?.role === 'admin';
if (!canInvite) {
return fail(403, { error: 'You do not have permission to invite members' });
}
// Find user by email
const users = await locals.pb.collection('users').getList(1, 1, {
filter: `email="${email}"`,
});
if (users.items.length === 0) {
return fail(400, { error: 'User not found' });
}
const invitedUser = users.items[0];
// Check if already a member
const existingMember = await locals.pb.collection('workspace_members').getList(1, 1, {
filter: `workspace="${params.id}" && user="${invitedUser.id}"`,
});
if (existingMember.items.length > 0) {
return fail(400, { error: 'User is already a member of this workspace' });
}
// Create invitation
const invitationToken = crypto.randomUUID();
await locals.pb.collection('workspace_members').create({
workspace: params.id,
user: invitedUser.id,
role: role || 'member',
invitation_status: 'pending',
invitation_token: invitationToken,
invited_at: new Date().toISOString(),
});
// TODO: Send invitation email
return { success: true, message: 'Invitation sent successfully' };
} catch (error) {
console.error('Error inviting member:', error);
return fail(400, { error: 'Failed to send invitation' });
}
},
removeMember: async ({ request, params, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const memberId = data.get('memberId') as string;
try {
// Check permissions
const workspace = await locals.pb.collection('workspaces').getOne(params.id);
const isOwner = workspace.owner === locals.user.id;
if (!isOwner) {
return fail(403, { error: 'Only workspace owner can remove members' });
}
// Delete member
await locals.pb.collection('workspace_members').delete(memberId);
return { success: true, message: 'Member removed successfully' };
} catch (error) {
console.error('Error removing member:', error);
return fail(400, { error: 'Failed to remove member' });
}
},
delete: async ({ params, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
try {
// Check if owner
const workspace = await locals.pb.collection('workspaces').getOne(params.id);
if (workspace.owner !== locals.user.id) {
return fail(403, { error: 'Only workspace owner can delete the workspace' });
}
// Don't allow deleting personal workspaces
if (workspace.type === 'personal') {
return fail(403, { error: 'Personal workspaces cannot be deleted' });
}
// Delete all workspace members first
const members = await locals.pb.collection('workspace_members').getList(1, 100, {
filter: `workspace="${params.id}"`,
});
for (const member of members.items) {
await locals.pb.collection('workspace_members').delete(member.id);
}
// Delete all links in this workspace
const links = await locals.pb.collection('links').getList(1, 100, {
filter: `workspace_id="${params.id}"`,
});
for (const link of links.items) {
await locals.pb.collection('links').delete(link.id);
}
// Delete workspace
await locals.pb.collection('workspaces').delete(params.id);
// Don't use redirect here, let the client handle it
return { success: true, deleted: true };
} catch (error) {
console.error('Error deleting workspace:', error);
return fail(400, { error: 'Failed to delete workspace: ' + (error as any)?.message });
}
},
} satisfies Actions;

View file

@ -0,0 +1,420 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import {
Building2,
Users,
Settings,
Trash2,
ArrowLeft,
Mail,
UserPlus,
Shield,
Check,
X,
} from 'lucide-svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let activeTab = $state<'general' | 'members' | 'danger'>('general');
let isEditing = $state(false);
let isSaving = $state(false);
let inviteEmail = $state('');
let inviteRole = $state<'admin' | 'member'>('member');
let isInviting = $state(false);
let workspaceName = $state(data.workspace?.name || '');
let workspaceDescription = $state(data.workspace?.description || '');
let workspaceSlug = $state(data.workspace?.slug || '');
const isOwner = $derived(data.workspace?.owner === data.user?.id);
const canManage = $derived(isOwner || data.currentMember?.role === 'admin');
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8">
<button
onclick={() => goto('/settings')}
class="mb-4 flex items-center gap-2 text-sm text-theme-text-muted hover:text-theme-text"
>
<ArrowLeft class="h-4 w-4" />
Back to Settings
</button>
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-theme-text">{data.workspace?.name}</h1>
<p class="mt-2 text-theme-text-muted">Manage your workspace settings and team members</p>
</div>
{#if data.workspace?.type === 'team'}
<div class="rounded-lg bg-purple-100 px-3 py-1 dark:bg-purple-900/20">
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
Team Workspace
</span>
</div>
{/if}
</div>
</div>
<!-- Tabs -->
<div class="mb-6 border-b border-theme-border">
<nav class="flex gap-6">
<button
onclick={() => (activeTab = 'general')}
class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'general'
? 'border-theme-primary text-theme-primary'
: 'border-transparent text-theme-text-muted hover:text-theme-text'}"
>
<div class="flex items-center gap-2">
<Settings class="h-4 w-4" />
General
</div>
</button>
<button
onclick={() => (activeTab = 'members')}
class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'members'
? 'border-theme-primary text-theme-primary'
: 'border-transparent text-theme-text-muted hover:text-theme-text'}"
>
<div class="flex items-center gap-2">
<Users class="h-4 w-4" />
Members ({data.members?.length || 0})
</div>
</button>
{#if isOwner}
<button
onclick={() => (activeTab = 'danger')}
class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'danger'
? 'border-red-500 text-red-500'
: 'border-transparent text-theme-text-muted hover:text-red-500'}"
>
<div class="flex items-center gap-2">
<Trash2 class="h-4 w-4" />
Danger Zone
</div>
</button>
{/if}
</nav>
</div>
<!-- Tab Content -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800">
{#if activeTab === 'general'}
<!-- General Settings -->
<div class="p-6">
<h2 class="mb-4 text-lg font-semibold text-theme-text">General Settings</h2>
<form
method="POST"
action="?/update"
use:enhance={() => {
isSaving = true;
return async ({ update }) => {
await update();
isSaving = false;
isEditing = false;
};
}}
class="space-y-4"
>
<div>
<label for="name" class="mb-2 block text-sm font-medium text-theme-text">
Workspace Name
</label>
<input
id="name"
name="name"
type="text"
bind:value={workspaceName}
disabled={!isEditing || !canManage}
required
class="focus:ring-theme-primary/20 w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2 disabled:opacity-50"
/>
</div>
<div>
<label for="slug" class="mb-2 block text-sm font-medium text-theme-text">
Workspace URL Slug
</label>
<div class="flex items-center gap-2">
<span class="text-sm text-theme-text-muted">/w/</span>
<input
id="slug"
name="slug"
type="text"
bind:value={workspaceSlug}
disabled={!isEditing || !canManage}
pattern="[a-z0-9-]+"
placeholder="workspace-slug"
class="focus:ring-theme-primary/20 flex-1 rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2 disabled:opacity-50"
/>
</div>
<p class="mt-1 text-xs text-theme-text-muted">
Used for workspace links: /w/{workspaceSlug || 'workspace-slug'}/shortcode
</p>
</div>
<div>
<label for="description" class="mb-2 block text-sm font-medium text-theme-text">
Description
</label>
<textarea
id="description"
name="description"
bind:value={workspaceDescription}
disabled={!isEditing || !canManage}
rows="3"
class="focus:ring-theme-primary/20 w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2 disabled:opacity-50"
></textarea>
</div>
{#if canManage}
<div class="flex justify-end gap-3 pt-4">
{#if isEditing}
<button
type="button"
onclick={() => {
isEditing = false;
workspaceName = data.workspace?.name || '';
workspaceDescription = data.workspace?.description || '';
workspaceSlug = data.workspace?.slug || '';
}}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
{:else}
<button
type="button"
onclick={() => (isEditing = true)}
class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Edit Settings
</button>
{/if}
</div>
{/if}
</form>
</div>
{:else if activeTab === 'members'}
<!-- Members -->
<div class="p-6">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-lg font-semibold text-theme-text">Team Members</h2>
{#if canManage}
<button
onclick={() => {
/* Open invite modal */
}}
class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
<UserPlus class="h-4 w-4" />
Invite Member
</button>
{/if}
</div>
{#if canManage}
<!-- Invite Form -->
<div class="mb-6 rounded-lg bg-theme-surface p-4">
<form
method="POST"
action="?/invite"
use:enhance={() => {
isInviting = true;
return async ({ update }) => {
await update();
isInviting = false;
inviteEmail = '';
};
}}
class="flex gap-3"
>
<input
type="email"
name="email"
bind:value={inviteEmail}
placeholder="colleague@example.com"
required
class="focus:ring-theme-primary/20 flex-1 rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2"
/>
<select
name="role"
bind:value={inviteRole}
class="focus:ring-theme-primary/20 rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text focus:border-theme-primary focus:outline-none focus:ring-2"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
type="submit"
disabled={isInviting || !inviteEmail}
class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 font-medium text-white hover:bg-theme-primary-hover disabled:opacity-50"
>
<Mail class="h-4 w-4" />
Send Invite
</button>
</form>
</div>
{/if}
<!-- Members List -->
<div class="space-y-3">
{#each data.members || [] as member}
<div
class="flex items-center justify-between rounded-lg border border-theme-border p-4"
>
<div class="flex items-center gap-4">
<div
class="bg-theme-primary/10 flex h-10 w-10 items-center justify-center rounded-full text-theme-primary"
>
{member.expand?.user?.email?.[0]?.toUpperCase() || 'U'}
</div>
<div>
<p class="font-medium text-theme-text">
{member.expand?.user?.name || member.expand?.user?.email}
</p>
<p class="text-sm text-theme-text-muted">
{member.expand?.user?.email}
</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if member.role === 'owner'}
<span
class="rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
>
Owner
</span>
{:else if member.role === 'admin'}
<span
class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
Admin
</span>
{:else}
<span
class="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-900/20 dark:text-gray-400"
>
Member
</span>
{/if}
{#if member.invitation_status === 'pending'}
<span
class="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400"
>
Pending
</span>
{/if}
{#if canManage && member.role !== 'owner'}
<form method="POST" action="?/removeMember" use:enhance>
<input type="hidden" name="memberId" value={member.id} />
<button
type="submit"
class="rounded p-1 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
title="Remove member"
>
<Trash2 class="h-4 w-4" />
</button>
</form>
{/if}
</div>
</div>
{/each}
</div>
</div>
{:else if activeTab === 'danger'}
<!-- Danger Zone -->
<div class="p-6">
<h2 class="mb-4 text-lg font-semibold text-red-600">Danger Zone</h2>
<div
class="rounded-lg border-2 border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"
>
<h3 class="mb-2 font-medium text-red-800 dark:text-red-400">Delete Workspace</h3>
{#if data.workspace?.type === 'personal'}
<p class="mb-4 text-sm text-red-700 dark:text-red-300">
Personal workspaces cannot be deleted. They are permanently associated with your
account.
</p>
<button
disabled
class="cursor-not-allowed rounded-lg bg-gray-400 px-4 py-2 text-sm font-medium text-white opacity-50"
>
Cannot Delete Personal Workspace
</button>
{:else}
<p class="mb-4 text-sm text-red-700 dark:text-red-300">
Once you delete a workspace, there is no going back. All links, settings, and team
access will be permanently removed.
</p>
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.deleted) {
// Workspace was deleted successfully, navigate and refresh
await goto('/settings/workspaces', { invalidateAll: true });
} else if (result.type === 'redirect') {
// Handle redirect (shouldn't happen now but keep as fallback)
await goto('/settings/workspaces', { invalidateAll: true });
} else {
// Handle errors
await update();
}
};
}}
onsubmit={(e) => {
if (
!confirm(
'Are you sure you want to delete this workspace? This action cannot be undone.'
)
) {
e.preventDefault();
}
}}
>
<button
type="submit"
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
>
Delete Workspace
</button>
</form>
{/if}
</div>
</div>
{/if}
</div>
<!-- Success/Error Messages -->
{#if form?.success}
<div class="mt-4 rounded-lg border border-green-200 bg-green-50 p-4">
<p class="text-sm text-green-700 dark:text-green-400">
{form.message || 'Changes saved successfully'}
</p>
</div>
{/if}
{#if form?.error}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-700 dark:text-red-400">
{form.error}
</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,130 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { validateWorkspaceSlug, isSlugReserved } from '$lib/utils/reserved-slugs';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(303, '/login');
}
// Count existing team workspaces for this user
const teamWorkspaces = await locals.pb.collection('workspaces').getList(1, 100, {
filter: `owner="${locals.user.id}" && type="team"`,
});
return {
workspaceCount: teamWorkspaces.totalItems,
workspaceLimit:
locals.user?.subscription_tier === 'pro'
? 10
: locals.user?.subscription_tier === 'business'
? 999
: 1,
};
};
export const actions = {
default: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Unauthorized' });
}
const data = await request.formData();
const name = data.get('name') as string;
const description = data.get('description') as string;
const type = data.get('type') as 'team' | 'personal';
const slug = data.get('slug') as string;
if (!name) {
return fail(400, { error: 'Workspace name is required' });
}
// Check workspace limits for team workspaces
if (type === 'team') {
const teamWorkspaces = await locals.pb.collection('workspaces').getList(1, 100, {
filter: `owner="${locals.user.id}" && type="team"`,
});
const limit =
locals.user?.subscription_tier === 'pro'
? 10
: locals.user?.subscription_tier === 'business'
? 999
: 1;
if (teamWorkspaces.totalItems >= limit) {
return fail(403, {
error: `Workspace limit reached. You can have maximum ${limit} team workspace(s) with your current plan.`,
});
}
}
// Validate slug format
if (slug) {
const slugError = validateWorkspaceSlug(slug);
if (slugError) {
return fail(400, { error: slugError });
}
}
try {
// Check for slug conflicts across collections
if (slug) {
// Check if username exists with this slug (but allow if it's the current user)
try {
const existingUser = await locals.pb
.collection('users')
.getFirstListItem(`username="${slug}"`);
if (existingUser && existingUser.id !== locals.user.id) {
return fail(400, {
error: 'This workspace URL conflicts with an existing user profile',
});
}
} catch (e) {
// No user found, which is good
}
// Check if workspace slug already exists
try {
const existingWorkspace = await locals.pb
.collection('workspaces')
.getFirstListItem(`slug="${slug}"`);
if (existingWorkspace) {
return fail(400, { error: 'This workspace URL is already taken' });
}
} catch (e) {
// No workspace found, which is good
}
}
// Create the workspace
const workspace = await locals.pb.collection('workspaces').create({
name: name.trim(),
owner: locals.user.id,
type: type || 'team',
description: description?.trim() || '',
slug: slug?.trim() || null,
subscription_status: locals.user.subscription_status || 'free',
});
// Add the owner as a member with owner role
await locals.pb.collection('workspace_members').create({
workspace: workspace.id,
user: locals.user.id,
role: 'owner',
invitation_status: 'accepted',
accepted_at: new Date().toISOString(),
});
// Redirect to the new workspace
redirect(303, `/settings/workspaces/${workspace.id}`);
} catch (error: any) {
console.error('Error creating workspace:', error);
if (error?.data?.data?.slug?.code === 'validation_not_unique') {
return fail(400, { error: 'This workspace URL is already taken' });
}
return fail(400, { error: 'Failed to create workspace' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import {
Building2,
ArrowLeft,
Users,
Globe,
Lock,
AlertCircle,
CheckCircle,
} from 'lucide-svelte';
import type { ActionData, PageData } from './$types';
import { validateWorkspaceSlug } from '$lib/utils/reserved-slugs';
let { form, data }: { form: ActionData; data: PageData } = $props();
let isSubmitting = $state(false);
let workspaceName = $state('');
let workspaceDescription = $state('');
let workspaceType = $state<'team' | 'personal'>('team');
let workspaceSlug = $state('');
// Client-side slug validation
let slugValidation = $derived.by(() => {
if (!workspaceSlug) return null;
return validateWorkspaceSlug(workspaceSlug);
});
let isSlugValid = $derived(workspaceSlug === '' || slugValidation === null);
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8">
<button
onclick={() => goto('/settings')}
class="mb-4 flex items-center gap-2 text-sm text-theme-text-muted hover:text-theme-text"
>
<ArrowLeft class="h-4 w-4" />
Back to Settings
</button>
<h1 class="text-3xl font-bold text-theme-text">Create New Workspace</h1>
<p class="mt-2 text-theme-text-muted">Set up a new workspace for your team or project</p>
{#if data?.workspaceCount !== undefined && data?.workspaceLimit !== undefined}
<p class="mt-1 text-sm text-theme-text-muted">
You have created {data.workspaceCount} of {data.workspaceLimit} team workspaces
</p>
{/if}
</div>
<!-- Creation Form -->
<div class="rounded-lg bg-white shadow-sm dark:bg-gray-800">
<form
method="POST"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
await update();
isSubmitting = false;
if (result.type === 'redirect') {
// Handle redirect
}
};
}}
class="space-y-6 p-6"
>
<!-- Workspace Type -->
<div>
<label class="mb-3 block text-sm font-medium text-theme-text"> Workspace Type </label>
<div class="grid grid-cols-2 gap-4">
<button
type="button"
onclick={() => (workspaceType = 'team')}
class="relative rounded-lg border-2 p-4 transition-all {workspaceType === 'team'
? 'bg-theme-primary/5 border-theme-primary'
: 'hover:border-theme-primary/50 border-theme-border'}"
>
<div class="flex items-start gap-3">
<Users
class="h-5 w-5 {workspaceType === 'team'
? 'text-theme-primary'
: 'text-theme-text-muted'}"
/>
<div class="text-left">
<h3 class="font-medium text-theme-text">Team Workspace</h3>
<p class="mt-1 text-sm text-theme-text-muted">Collaborate with team members</p>
</div>
</div>
{#if workspaceType === 'team'}
<div class="absolute right-2 top-2">
<div class="h-2 w-2 rounded-full bg-theme-primary"></div>
</div>
{/if}
</button>
<button
type="button"
onclick={() => (workspaceType = 'personal')}
class="relative rounded-lg border-2 p-4 transition-all {workspaceType === 'personal'
? 'bg-theme-primary/5 border-theme-primary'
: 'hover:border-theme-primary/50 border-theme-border'}"
disabled
>
<div class="flex items-start gap-3">
<Lock class="h-5 w-5 text-theme-text-muted" />
<div class="text-left">
<h3 class="font-medium text-theme-text-muted">Personal Workspace</h3>
<p class="mt-1 text-sm text-theme-text-muted">
You already have a personal workspace
</p>
</div>
</div>
</button>
</div>
<input type="hidden" name="type" value={workspaceType} />
</div>
<!-- Workspace Name -->
<div>
<label for="name" class="mb-2 block text-sm font-medium text-theme-text">
Workspace Name *
</label>
<input
id="name"
name="name"
type="text"
bind:value={workspaceName}
required
placeholder="e.g., Marketing Team, Design Projects"
class="focus:ring-theme-primary/20 w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-2 block text-sm font-medium text-theme-text">
Description
</label>
<textarea
id="description"
name="description"
bind:value={workspaceDescription}
rows="3"
placeholder="What is this workspace for?"
class="focus:ring-theme-primary/20 w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:border-theme-primary focus:outline-none focus:ring-2"
></textarea>
</div>
<!-- Workspace URL -->
<div>
<label for="slug" class="mb-2 block text-sm font-medium text-theme-text">
Workspace URL (optional)
</label>
<div class="flex items-center">
<span
class="rounded-l-lg border border-r-0 border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text-muted"
>
ulo.ad/w/
</span>
<input
id="slug"
name="slug"
type="text"
bind:value={workspaceSlug}
placeholder="marketing-team"
pattern="[a-z0-9\-]+"
class="flex-1 rounded-r-lg border bg-theme-background px-4 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 {workspaceSlug &&
!isSlugValid
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: workspaceSlug && isSlugValid
? 'border-green-500 focus:border-green-500 focus:ring-green-500/20'
: 'focus:ring-theme-primary/20 border-theme-border focus:border-theme-primary'}"
/>
</div>
<!-- Slug validation feedback -->
{#if workspaceSlug}
<div class="mt-2 flex items-center gap-2 text-xs">
{#if isSlugValid}
<CheckCircle class="h-3 w-3 text-green-500" />
<span class="text-green-600 dark:text-green-400">Available workspace URL</span>
{:else}
<AlertCircle class="h-3 w-3 text-red-500" />
<span class="text-red-600 dark:text-red-400">{slugValidation}</span>
{/if}
</div>
{:else}
<p class="mt-1 text-xs text-theme-text-muted">
Only lowercase letters, numbers, and hyphens. Leave empty for auto-generated.
</p>
{/if}
</div>
<!-- Security Notice -->
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<AlertCircle class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">
🔒 Workspace URL Protection
</h4>
<p class="mt-1 text-xs text-amber-700 dark:text-amber-400">
Some workspace URLs are reserved to prevent conflicts with existing user profiles,
system routes, and well-known brands. This protects against confusion and potential
phishing attempts.
</p>
</div>
</div>
</div>
<!-- Features Preview -->
<div class="rounded-lg bg-theme-surface p-4">
<h3 class="mb-3 text-sm font-medium text-theme-text">What you'll get:</h3>
<div class="space-y-2">
<div class="flex items-center gap-2 text-sm text-theme-text-muted">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Separate link collection for this workspace
</div>
<div class="flex items-center gap-2 text-sm text-theme-text-muted">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Invite team members with specific permissions
</div>
<div class="flex items-center gap-2 text-sm text-theme-text-muted">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Workspace analytics and statistics
</div>
<div class="flex items-center gap-2 text-sm text-theme-text-muted">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Quick workspace switching
</div>
</div>
</div>
<!-- Error Message -->
{#if form?.error}
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-700 dark:text-red-400">
{form.error}
</p>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
onclick={() => goto('/settings')}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !workspaceName || (workspaceSlug && !isSlugValid)}
class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
Creating...
{:else}
<Building2 class="h-4 w-4" />
Create Workspace
{/if}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,145 @@
import { redirect, fail } from '@sveltejs/kit';
import { validateUsername } from '$lib/username';
import type { PageServerLoad, Actions } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw redirect(302, '/login');
}
// Check if username is already set (and not temporary)
const username = locals.user.username;
if (
username &&
username !== '' &&
!username.startsWith('temp_') &&
!username.startsWith('user_')
) {
throw redirect(302, '/my');
}
return {
user: locals.user,
};
};
export const actions = {
setUsername: async ({ request, locals }) => {
console.log('[SETUP-USERNAME] Action started');
console.log('[SETUP-USERNAME] User:', locals.user?.id, locals.user?.email);
console.log('[SETUP-USERNAME] Current username:', locals.user?.username);
console.log('[SETUP-USERNAME] PB auth valid:', locals.pb?.authStore?.isValid);
console.log('[SETUP-USERNAME] PB auth user:', locals.pb?.authStore?.model?.id);
if (!locals.user) {
console.error('[SETUP-USERNAME] No user in locals');
throw redirect(302, '/login');
}
// Double-check username not already set (and not temporary)
const currentUsername = locals.user.username;
if (
currentUsername &&
currentUsername !== '' &&
!currentUsername.startsWith('temp_') &&
!currentUsername.startsWith('user_')
) {
console.log('[SETUP-USERNAME] Username already set, redirecting');
throw redirect(302, '/my');
}
const data = await request.formData();
const username = (data.get('username') as string)?.trim();
console.log('[SETUP-USERNAME] New username to set:', username);
// Validate username format
const validation = validateUsername(username);
console.log('[SETUP-USERNAME] Validation result:', validation);
if (!validation.valid) {
return fail(400, { error: validation.error });
}
// Check if username is already taken
try {
console.log('[SETUP-USERNAME] Checking if username exists...');
const existingUser = await locals.pb
.collection('users')
.getFirstListItem(`username="${username}"`);
console.log('[SETUP-USERNAME] Found existing user:', existingUser.id);
// If we get here, username exists (and it's not ours)
if (existingUser.id !== locals.user.id) {
console.log('[SETUP-USERNAME] Username taken by another user');
return fail(400, { error: 'Dieser Username ist bereits vergeben' });
}
console.log('[SETUP-USERNAME] Username belongs to current user');
} catch (err) {
// Username doesn't exist, we can use it
console.log('[SETUP-USERNAME] Username is available');
}
// Update user with new username
try {
console.log('[SETUP-USERNAME] Attempting to update username...');
console.log('[SETUP-USERNAME] User ID:', locals.user.id);
console.log('[SETUP-USERNAME] New username:', username);
console.log('[SETUP-USERNAME] PB instance exists:', !!locals.pb);
console.log('[SETUP-USERNAME] PB auth valid before update:', locals.pb?.authStore?.isValid);
const updatedUser = await locals.pb.collection('users').update(locals.user.id, {
username: username,
});
console.log('[SETUP-USERNAME] Update successful:', updatedUser.id, updatedUser.username);
// Update locals
locals.user = updatedUser;
// Return success instead of redirect - handle redirect on client
return { success: true, username: updatedUser.username };
} catch (err: any) {
// Check if it's a redirect (which is not an error)
console.log('[SETUP-USERNAME] Caught error type:', typeof err);
console.log('[SETUP-USERNAME] Error constructor:', err?.constructor?.name);
console.log('[SETUP-USERNAME] Error status:', err?.status);
console.log('[SETUP-USERNAME] Is Response?', err instanceof Response);
// Check for SvelteKit redirect
if (err?.status >= 300 && err?.status < 400 && err?.location) {
console.log('[SETUP-USERNAME] SvelteKit redirect detected, re-throwing...');
throw err;
}
// Also check if it's a Response object
if (err instanceof Response && err.status >= 300 && err.status < 400) {
console.log('[SETUP-USERNAME] Response redirect detected, re-throwing...');
throw err;
}
console.error('[SETUP-USERNAME] Failed to update username:', err);
// Log more details about the error
if (err instanceof Error) {
console.error('[SETUP-USERNAME] Error details:', {
message: err.message,
stack: err.stack,
response: (err as any).response,
data: (err as any).data,
status: (err as any).status,
originalError: (err as any).originalError,
});
// Return more specific error message if available
const errorMessage =
(err as any).response?.message ||
(err as any).data?.message ||
err.message ||
'Fehler beim Setzen des Usernamens';
console.error('[SETUP-USERNAME] Returning error:', errorMessage);
return fail(500, { error: errorMessage });
}
return fail(500, { error: 'Fehler beim Setzen des Usernamens' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,217 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { validateUsername, RESERVED_USERNAMES } from '$lib/username';
import { toastMessages } from '$lib/services/toast';
let { data, form }: { data: PageData; form: ActionData } = $props();
let isSubmitting = $state(false);
let username = $state('');
let isChecking = $state(false);
let validationMessage = $state('');
let isValid = $state(false);
// Debounced username validation
let debounceTimer: ReturnType<typeof setTimeout>;
function checkUsername(value: string) {
clearTimeout(debounceTimer);
validationMessage = '';
isValid = false;
if (!value) {
return;
}
// Local validation first
const validation = validateUsername(value);
if (!validation.valid) {
validationMessage = validation.error || '';
return;
}
// Check availability after debounce
isChecking = true;
debounceTimer = setTimeout(async () => {
try {
const response = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`);
const result = await response.json();
if (result.available) {
validationMessage = '✓ Username verfügbar';
isValid = true;
} else {
validationMessage = 'Dieser Username ist bereits vergeben';
isValid = false;
}
} catch (error) {
validationMessage = 'Fehler beim Überprüfen';
isValid = false;
} finally {
isChecking = false;
}
}, 500);
}
$effect(() => {
checkUsername(username);
});
</script>
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-2xl px-4 py-16">
<div class="rounded-xl bg-white p-8 shadow-xl dark:bg-theme-surface">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-theme-text dark:text-white">Wähle deinen Username</h1>
<p class="mt-3 text-theme-text-muted">
Dies ist deine einmalige Chance, deinen Username zu wählen. Nach der Bestätigung kann er
nicht mehr geändert werden.
</p>
</div>
<form
method="POST"
action="?/setUsername"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
if (result.type === 'failure') {
// Show error toast
if (result.data?.error) {
toastMessages.genericError(result.data.error);
}
await update();
isSubmitting = false;
} else if (result.type === 'redirect') {
// Let the redirect happen
} else if (result.type === 'success' && result.data?.success) {
// Show success toast and redirect
toastMessages.usernameSet(username);
setTimeout(() => {
window.location.href = '/my';
}, 1500);
} else {
await update();
isSubmitting = false;
}
};
}}
>
<div class="space-y-6">
<div>
<label
for="username"
class="mb-2 block text-sm font-medium text-theme-text dark:text-theme-text"
>
Username
</label>
<div class="relative">
<input
type="text"
id="username"
name="username"
bind:value={username}
required
minlength="3"
maxlength="30"
pattern="[a-zA-Z0-9_\-]+"
placeholder="dein-username"
class="w-full rounded-lg border border-theme-border bg-theme-surface px-4 py-3 text-lg text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
{#if isChecking}
<div class="absolute right-3 top-1/2 -translate-y-1/2">
<svg
class="h-5 w-5 animate-spin text-theme-primary"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
{/if}
</div>
{#if validationMessage}
<p class="mt-2 text-sm {isValid ? 'text-green-600' : 'text-red-600'}">
{validationMessage}
</p>
{/if}
<div class="mt-3 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-800 dark:text-blue-300">
<strong>Deine Profil-URL wird sein:</strong><br />
<code class="mt-1 block text-blue-900 dark:text-blue-200">
{typeof window !== 'undefined' ? window.location.origin : ''}/p/{username ||
'dein-username'}
</code>
</p>
</div>
</div>
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div class="flex">
<svg
class="h-5 w-5 text-amber-600 dark:text-amber-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Wichtiger Hinweis
</h3>
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300">
Nach der Bestätigung kann dein Username <strong>nie wieder geändert</strong> werden.
Alle deine Links werden unter diesem Username erreichbar sein.
</p>
</div>
</div>
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium text-theme-text dark:text-theme-text">
Username-Regeln:
</h3>
<ul class="space-y-1 text-sm text-theme-text-muted">
<li>• Mindestens 3, maximal 30 Zeichen</li>
<li>• Nur Buchstaben, Zahlen, Unterstriche (_) und Bindestriche (-)</li>
<li>• Muss mit einem Buchstaben oder einer Zahl beginnen</li>
<li>• Keine Leerzeichen oder Sonderzeichen</li>
</ul>
</div>
<button
type="submit"
disabled={isSubmitting || !isValid || isChecking}
class="w-full rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition-colors hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
Username wird gesetzt...
{:else}
Username bestätigen
{/if}
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
import type { PageServerLoad } from './$types';
import type { Card } from '$lib/components/cards/types';
export const load: PageServerLoad = async ({ locals }) => {
// Fetch templates on the server
let templates: Card[] = [];
try {
// Fetch all public templates
const records = await locals.pb.collection('cards').getList(1, 100, {
filter: `type="template" && visibility="public"`,
sort: '-created',
});
templates = records.items as unknown as Card[];
} catch (error) {
console.error('Failed to fetch templates on server:', error);
// Return empty array on error, client will try to fetch again
}
return {
user: locals.user,
templates,
};
};

View file

@ -0,0 +1,314 @@
<script lang="ts">
import type { PageData } from './$types';
import { unifiedCardService } from '$lib/services/unifiedCardService';
import type { Card } from '$lib/components/cards/types';
import TemplateCard from '$lib/components/templates/TemplateCard.svelte';
import TemplatePreviewModal from '$lib/components/templates/TemplatePreviewModal.svelte';
import { pb } from '$lib/pocketbase';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
let { data }: { data: PageData } = $props();
// State - initialize with server data
let templates = $state<Card[]>(data.templates || []);
let loading = $state(false); // Start as false since we have initial data
let selectedCategory = $state<string>('all');
let searchQuery = $state('');
let sortBy = $state<'popular' | 'recent' | 'rating'>('popular');
let selectedTemplate = $state<Card | null>(null);
let showPreview = $state(false);
// Available categories from the cards collection
const categories = [
{ value: 'all', label: 'All Templates' },
{ value: 'personal', label: 'Personal' },
{ value: 'creative', label: 'Creative' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'social', label: 'Social' },
{ value: 'portfolio', label: 'Portfolio' },
{ value: 'other', label: 'Other' },
];
// Load templates
async function loadTemplates() {
loading = true;
try {
templates = await unifiedCardService.getTemplates(
selectedCategory === 'all' ? undefined : selectedCategory
);
} catch (error) {
console.error('Error loading templates:', error);
} finally {
loading = false;
}
}
// Filter and sort templates
let filteredTemplates = $derived(() => {
let filtered = templates;
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((template) => {
const name = template.metadata?.name?.toLowerCase() || '';
const description = template.metadata?.description?.toLowerCase() || '';
const tags = template.tags || [];
return (
name.includes(query) ||
description.includes(query) ||
tags.some((tag: string) => tag.toLowerCase().includes(query))
);
});
}
// Sort
filtered = [...filtered].sort((a, b) => {
switch (sortBy) {
case 'popular':
return (b.usage_count || 0) - (a.usage_count || 0);
case 'recent':
return new Date(b.created || 0).getTime() - new Date(a.created || 0).getTime();
case 'rating':
return (b.likes_count || 0) - (a.likes_count || 0);
default:
return 0;
}
});
return filtered;
});
// Use template - creates a new card from template
async function useTemplate(template: Card) {
if (!pb.authStore.model) {
goto('/login');
return;
}
try {
const newCard = await unifiedCardService.createFromTemplate(template.id!);
if (newCard) {
goto('/my/cards');
}
} catch (error) {
console.error('Error using template:', error);
alert('Failed to use template');
}
}
// Duplicate template to user's collection
async function duplicateTemplate(template: Card) {
if (!pb.authStore.model) {
goto('/login');
return;
}
try {
const duplicated = await unifiedCardService.duplicateCard(template.id!);
if (duplicated) {
alert('Template added to your collection!');
}
} catch (error) {
console.error('Error duplicating template:', error);
alert('Failed to duplicate template');
}
}
// Like/unlike template
async function toggleLike(template: Card) {
if (!pb.authStore.model) {
goto('/login');
return;
}
try {
await unifiedCardService.toggleLike(template.id!);
// Refresh templates to show updated like count
await loadTemplates();
} catch (error) {
console.error('Error toggling like:', error);
}
}
// Share template
async function shareTemplate(template: Card) {
const url = `${window.location.origin}/template-store?template=${template.id}`;
const name = template.metadata?.name || 'Card Template';
const description = template.metadata?.description || '';
if (navigator.share) {
try {
await navigator.share({
title: name,
text: description || `Check out this card template: ${name}`,
url,
});
} catch (error) {
console.error('Error sharing:', error);
}
} else {
// Fallback to clipboard
navigator.clipboard.writeText(url);
alert('Template link copied to clipboard!');
}
}
// Preview template
function previewTemplate(template: Card) {
selectedTemplate = template;
showPreview = true;
}
// Load on mount
onMount(() => {
loadTemplates();
});
// Reload when category changes
$effect(() => {
// Only load templates on client side to avoid SSR issues
if (typeof window !== 'undefined') {
loadTemplates();
}
});
</script>
<svelte:head>
<title>Template Store - uload</title>
<meta name="description" content="Discover and use community-created card templates" />
</svelte:head>
<div class="min-h-screen bg-theme-background">
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-theme-text">Template Store</h1>
<p class="mt-2 text-theme-text-muted">Discover and use community-created card templates</p>
</div>
<!-- Filters and Search -->
<div class="mb-6 space-y-4">
<!-- Search Bar -->
<div class="relative max-w-xl">
<input
type="text"
bind:value={searchQuery}
placeholder="Search templates by name, description, or tags..."
class="w-full rounded-lg border border-theme-border bg-theme-surface px-4 py-2 pl-10 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
<svg
class="absolute left-3 top-2.5 h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<!-- Filters Row -->
<div class="flex flex-wrap items-center justify-between gap-4">
<!-- Category Filter -->
<div class="flex gap-2">
{#each categories as category}
<button
onclick={() => (selectedCategory = category.value)}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
category.value
? 'bg-theme-primary text-white'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
{category.label}
</button>
{/each}
</div>
<!-- Sort Dropdown -->
<select
bind:value={sortBy}
class="rounded-lg border border-theme-border bg-theme-surface px-4 py-2 text-sm text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
>
<option value="popular">Most Popular</option>
<option value="recent">Most Recent</option>
<option value="rating">Most Liked</option>
</select>
</div>
</div>
<!-- Templates Grid -->
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-theme-border border-t-theme-primary"
></div>
<p class="text-theme-text-muted">Loading templates...</p>
</div>
</div>
{:else if filteredTemplates().length > 0}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each filteredTemplates() as template}
<TemplateCard
{template}
onUse={useTemplate}
onPreview={previewTemplate}
onLike={toggleLike}
onDuplicate={duplicateTemplate}
onShare={shareTemplate}
/>
{/each}
</div>
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-12 text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-theme-text-muted">No templates found</p>
<p class="mt-2 text-sm text-theme-text-muted">Try adjusting your search or filters</p>
</div>
{/if}
<!-- Create Template CTA -->
{#if pb.authStore.model}
<div
class="mt-12 rounded-lg bg-gradient-to-r from-theme-primary to-theme-accent p-8 text-center text-white"
>
<h2 class="mb-2 text-2xl font-bold">Share Your Creations</h2>
<p class="mb-4">Create and share your own card templates with the community</p>
<a
href="/my/cards"
class="inline-block rounded-lg bg-white px-6 py-3 font-medium text-theme-primary hover:bg-gray-100"
>
Create Template
</a>
</div>
{/if}
</div>
</div>
<!-- Template Preview Modal -->
<TemplatePreviewModal
template={selectedTemplate}
show={showPreview}
onClose={() => (showPreview = false)}
onUse={useTemplate}
onDuplicate={duplicateTemplate}
onLike={toggleLike}
/>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
let { data, children }: { data: any; children: Snippet } = $props();
$effect(() => {
// Redirect to dashboard if already logged in
if (data.user) {
goto('/my');
}
});
</script>
{@render children()}

View file

@ -0,0 +1,52 @@
import { fail } from '@sveltejs/kit';
import { pb } from '$lib/pocketbase';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// If user is already logged in, they don't need password reset
if (locals.user) {
return {
user: locals.user,
};
}
};
export const actions = {
requestReset: async ({ request }) => {
const formData = await request.formData();
const email = (formData.get('email') as string)?.toLowerCase().trim();
// Basic validation
if (!email) {
return fail(400, { error: 'Email is required' });
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
try {
// Request password reset from PocketBase
// This will send an email if the user exists
await pb.collection('users').requestPasswordReset(email);
console.log('Password reset requested for:', email);
// Always return success to prevent email enumeration
// Even if the email doesn't exist, we show success
return {
success: true,
};
} catch (err: any) {
console.error('Password reset request error:', err);
// Don't expose specific errors to prevent email enumeration
// Always show generic success message
return {
success: true,
};
}
},
} satisfies Actions;

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
async function handleForgotPassword(email: string) {
try {
await pb.collection('users').requestPasswordReset(email);
return { success: true };
} catch (err: any) {
// PocketBase doesn't reveal if email exists for security
// So we always show success message
return { success: true };
}
}
</script>
<ForgotPasswordPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
titleForm: 'Passwort zurücksetzen',
titleSuccess: 'E-Mail gesendet',
description:
'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.',
emailPlaceholder: 'E-Mail',
sendResetLinkButton: 'Link senden',
sending: 'Wird gesendet...',
backToLogin: 'Zurück zum Login',
resendEmail: 'E-Mail erneut senden',
successMessage:
'Wir haben einen Link zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang.',
emailRequired: 'E-Mail ist erforderlich',
sendFailed: 'Senden der E-Mail fehlgeschlagen',
}}
/>

View file

@ -0,0 +1,54 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
const isAdditional = url.searchParams.get('additional') === 'true';
// Only redirect if user is logged in AND not trying to add additional account
if (locals.user && !isAdditional) {
redirect(303, '/my/links');
}
return {
isAdditional,
};
};
export const actions = {
login: async ({ request, locals, url }) => {
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
const isAdditional = url.searchParams.get('additional') === 'true';
if (!email || !password) {
return fail(400, { error: 'Email and password are required' });
}
try {
await locals.pb.collection('users').authWithPassword(email, password);
// Set the user in locals so it's available immediately
locals.user = locals.pb.authStore.model;
} catch (err) {
// Login error occurred
return fail(400, { error: 'Invalid email or password' });
}
// Handle redirect based on login type
if (isAdditional) {
// For additional accounts, show success message
redirect(
303,
`/my?message=${encodeURIComponent('Account erfolgreich hinzugefügt! Du kannst nun zwischen deinen Accounts wechseln.')}&type=success`
);
} else {
// Normal login flow
redirect(303, '/my');
}
},
logout: async ({ locals }) => {
locals.pb.authStore.clear();
redirect(303, '/');
},
} satisfies Actions;

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
async function handleSignIn(email: string, password: string) {
try {
await pb.collection('users').authWithPassword(email, password);
// Invalidate all data to refresh server-side auth state
await invalidateAll();
return { success: true };
} catch (err: any) {
return {
success: false,
error: err?.message || 'Ungültige E-Mail oder Passwort',
};
}
}
</script>
<LoginPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/my"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
title: 'Anmelden',
subtitle: 'Melde dich mit deinem uLoad Account an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolg!',
orDivider: 'oder',
noAccount: 'Noch kein Account?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...',
}}
/>

View file

@ -0,0 +1,260 @@
import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { generateUsernameFromEmail } from '$lib/username';
// Removed cardTemplateService import - using unified card system now
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
// Allow creating additional accounts - check if this is an explicit additional account creation
const createAdditional = url.searchParams.get('additional') === 'true';
// Only redirect if user is logged in AND not explicitly creating an additional account
if (locals.user && !createAdditional) {
redirect(303, '/my');
}
// Check for invitation token
const inviteToken = url.searchParams.get('invite');
if (inviteToken) {
try {
// Verify the invitation exists and is valid
const invitation = await locals.pb
.collection('pending_invitations')
.getFirstListItem(
`token="${inviteToken}" && expires_at > "${new Date().toISOString()}" && accepted_at = null`,
{ expand: 'owner' }
);
return {
invitation: {
token: inviteToken,
email: invitation.email,
inviterName:
invitation.expand?.owner?.name || invitation.expand?.owner?.email || 'Someone',
},
};
} catch (err) {
// Invalid or expired invitation
console.error('Invalid invitation:', err);
return {
invitation: null,
};
}
}
return {
invitation: null,
};
};
export const actions = {
register: async ({ request, locals, url }) => {
console.log('[REGISTER] Registration attempt started');
console.log('[REGISTER] PocketBase instance exists:', !!locals.pb);
console.log('[REGISTER] PocketBase URL:', locals.pb?.baseUrl);
const formData = await request.formData();
const email = (formData.get('email') as string)?.toLowerCase().trim();
const password = formData.get('password') as string;
const passwordConfirm = formData.get('passwordConfirm') as string;
const inviteToken = formData.get('inviteToken') as string;
const isAdditionalAccount = url.searchParams.get('additional') === 'true';
console.log('[REGISTER] Form data received:', {
email,
hasPassword: !!password,
isAdditionalAccount,
});
// Basic validation
if (!email || !password || !passwordConfirm) {
return fail(400, { error: 'Email and password are required' });
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
if (password !== passwordConfirm) {
return fail(400, { error: 'Passwords do not match' });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters' });
}
// Don't set username during registration
// User will set their username after first login
// Registering user
let newUser;
try {
// Create user with ONLY required fields
// Username will be set later by the user
// PocketBase now auto-generates IDs with the pattern [a-z0-9]{15}
const userData: any = {
email,
password,
passwordConfirm,
emailVisibility: true,
};
console.log('[REGISTER] Creating user with data:', { email, emailVisibility: true });
console.log('[REGISTER] Using locals.pb:', !!locals.pb);
console.log('[REGISTER] PocketBase URL:', locals.pb?.baseUrl);
console.log('[REGISTER] Auth store valid:', locals.pb?.authStore?.isValid);
console.log('[REGISTER] Password length:', password?.length);
console.log('[REGISTER] Passwords match:', password === passwordConfirm);
newUser = await locals.pb.collection('users').create(userData);
console.log('[REGISTER] User created successfully:', newUser.id);
// User created successfully
} catch (err: any) {
console.error('[REGISTER] Registration error:', err);
console.error('[REGISTER] Error message:', err?.message);
console.error('[REGISTER] Error response:', err?.response);
console.error('[REGISTER] Error data:', err?.data);
console.error('[REGISTER] Error stack:', err?.stack);
// Check if locals.pb exists
if (!locals.pb) {
console.error('[REGISTER] CRITICAL: locals.pb is undefined!');
return fail(500, { error: 'Server configuration error. Please try again later.' });
}
// Parse error response - PocketBase returns errors in different formats
const errorData = err?.response?.data || err?.data || {};
console.error('[REGISTER] Parsed error data:', JSON.stringify(errorData, null, 2));
// Check if it's the nested data.id error (shouldn't happen anymore)
if (errorData?.data?.id) {
console.error('[REGISTER] ID validation error detected:', errorData.data.id);
return fail(400, {
error: 'Registration configuration error. Please contact support.',
});
}
// Handle specific field errors
if (!newUser && errorData.email?.message) {
if (errorData.email.message.includes('unique')) {
return fail(400, { error: 'This email is already registered. Please login instead.' });
}
return fail(400, { error: errorData.email.message });
}
// Check if it's a validation error
if (errorData.password?.message) {
return fail(400, { error: errorData.password.message });
}
// Handle missing fields error
if (err?.message?.includes('validation_required')) {
return fail(400, { error: 'Please fill in all required fields.' });
}
// Check for general validation errors
if (err?.response?.message || err?.message) {
const message = err?.response?.message || err?.message;
console.error('[REGISTER] Error message:', message);
// In development, show the actual error
if (dev) {
return fail(400, {
error: `Debug: ${message}`,
details: err?.response?.data || err?.data,
});
}
// Don't expose internal errors to user in production
if (message.includes('Failed to create record') || message.includes('validation')) {
// But let's log the actual error for debugging
console.error('[REGISTER] Actual PocketBase error:', message);
console.error('[REGISTER] Full error object:', JSON.stringify(err, null, 2));
return fail(400, {
error: 'Registration failed. Please check your information and try again.',
});
}
return fail(400, { error: message });
}
// Generic error handling
console.error('[REGISTER] Unknown error type:', typeof err);
console.error('[REGISTER] Full error:', JSON.stringify(err, null, 2));
return fail(400, { error: 'Registration failed. Please try again.' });
}
// User created successfully
// Handle invitation if present
if (inviteToken && newUser) {
try {
// Find the pending invitation
const invitation = await locals.pb
.collection('pending_invitations')
.getFirstListItem(
`token="${inviteToken}" && email="${email}" && expires_at > "${new Date().toISOString()}" && accepted_at = null`,
{ expand: 'owner' }
);
// Create shared_access record
await locals.pb.collection('shared_access').create({
owner: invitation.owner,
user: newUser.id,
permissions: {
can_create_links: true,
can_edit_own_links: true,
can_delete_own_links: true,
can_view_analytics: false,
can_manage_tags: false,
can_export_data: false,
},
invitation_status: 'accepted',
accepted_at: new Date().toISOString(),
});
// Mark invitation as accepted
await locals.pb.collection('pending_invitations').update(invitation.id, {
accepted_at: new Date().toISOString(),
accepted_by: newUser.id,
});
// Notification email will be sent automatically by PocketBase hook
console.log('[REGISTER] Invitation accepted for user:', newUser.id);
} catch (inviteErr) {
console.error('[REGISTER] Failed to process invitation:', inviteErr);
// Continue anyway - user is registered
}
}
// Send verification email
let verificationSent = false;
try {
await locals.pb.collection('users').requestVerification(email);
verificationSent = true;
console.log('[REGISTER] Verification email sent to:', email);
} catch (emailErr) {
console.error('[REGISTER] Failed to send verification email:', emailErr);
// Continue anyway - user can request verification later
}
// Handle redirect based on registration type
if (isAdditionalAccount) {
// For additional accounts, redirect back to /my with message
const message = verificationSent
? 'Account created! Please check your email to verify your new account.'
: 'Account created! You can request a verification email from the settings page.';
redirect(303, `/my?message=${encodeURIComponent(message)}&type=success`);
} else {
// For normal registration, redirect to login
const redirectUrl = inviteToken
? '/login?registered=true&invited=true&email=' + encodeURIComponent(email)
: '/login?registered=true&email=' + encodeURIComponent(email);
redirect(303, redirectUrl);
}
},
} satisfies Actions;

View file

@ -0,0 +1,89 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
async function handleSignUp(email: string, password: string) {
try {
// Create user
await pb.collection('users').create({
email: email.toLowerCase().trim(),
password,
passwordConfirm: password,
emailVisibility: true,
});
// Request verification email
try {
await pb.collection('users').requestVerification(email);
} catch (emailErr) {
console.error('Failed to send verification email:', emailErr);
}
return {
success: true,
needsVerification: true,
};
} catch (err: any) {
const errorData = err?.response?.data || err?.data || {};
if (errorData.email?.message?.includes('unique')) {
return {
success: false,
error: 'Diese E-Mail ist bereits registriert. Bitte melde dich an.',
};
}
if (errorData.email?.message) {
return { success: false, error: errorData.email.message };
}
if (errorData.password?.message) {
return { success: false, error: errorData.password.message };
}
return {
success: false,
error: err?.message || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.',
};
}
}
</script>
<RegisterPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onSignUp={handleSignUp}
{goto}
successRedirect="/login?registered=true"
loginPath="/login"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
title: 'Account erstellen',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
confirmPasswordPlaceholder: 'Passwort bestätigen',
passwordRequirements:
'Passwort muss mindestens 8 Zeichen mit Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten.',
createAccountButton: 'Account erstellen',
creatingAccount: 'Wird erstellt...',
backToLogin: 'Zurück zum Login',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
passwordRequired: 'Passwort ist erforderlich',
confirmPasswordRequired: 'Bitte bestätige dein Passwort',
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
passwordTooShort: 'Passwort muss mindestens 8 Zeichen haben',
passwordStrengthError:
'Passwort muss Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten',
registrationFailed: 'Registrierung fehlgeschlagen',
accountCreated: 'Account erstellt! Bitte überprüfe deine E-Mail zur Verifizierung.',
}}
/>

View file

@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { registerUser } from '$lib/auth-helper';
describe('User Registration', () => {
const testEmail = () => `test${Date.now()}@example.com`;
it('should register user with email and password only', async () => {
const email = testEmail();
const password = 'TestPassword123!';
const result = await registerUser({
email,
password,
passwordConfirm: password,
});
// May fail in test environment without PocketBase
if (process.env.PUBLIC_POCKETBASE_URL) {
expect(result.success).toBe(true);
expect(result.user).toBeDefined();
expect(result.user?.email).toBe(email.toLowerCase());
expect(result.user?.username).toBeDefined();
}
});
it('should validate email format', async () => {
const result = await registerUser({
email: 'invalid-email',
password: 'TestPassword123!',
passwordConfirm: 'TestPassword123!',
});
expect(result.success).toBe(false);
expect(result.error).toContain('email');
});
it('should validate password match', async () => {
const result = await registerUser({
email: testEmail(),
password: 'Password123!',
passwordConfirm: 'DifferentPassword123!',
});
expect(result.success).toBe(false);
expect(result.error).toContain('match');
});
it('should generate unique username from email', async () => {
const email = 'john.doe+test@example.com';
const password = 'TestPassword123!';
const result = await registerUser({
email,
password,
passwordConfirm: password,
});
if (process.env.PUBLIC_POCKETBASE_URL && result.success) {
expect(result.user?.username).toBeDefined();
expect(result.user?.username).toMatch(/^[a-zA-Z0-9_-]+$/);
}
});
it('should handle duplicate email gracefully', async () => {
const email = testEmail();
const password = 'TestPassword123!';
// First registration
await registerUser({
email,
password,
passwordConfirm: password,
});
// Second registration with same email
const result = await registerUser({
email,
password,
passwordConfirm: password,
});
if (process.env.PUBLIC_POCKETBASE_URL) {
expect(result.success).toBe(false);
expect(result.error).toContain('already');
}
});
});

View file

@ -0,0 +1,57 @@
import { fail } from '@sveltejs/kit';
import { pb } from '$lib/pocketbase';
import type { Actions } from './$types';
export const actions = {
resetPassword: async ({ request }) => {
const formData = await request.formData();
const token = formData.get('token') as string;
const password = formData.get('password') as string;
const passwordConfirm = formData.get('passwordConfirm') as string;
// Basic validation
if (!token) {
return fail(400, { error: 'Invalid reset token' });
}
if (!password || !passwordConfirm) {
return fail(400, { error: 'Password is required' });
}
if (password !== passwordConfirm) {
return fail(400, { error: 'Passwords do not match' });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters' });
}
try {
// Confirm password reset with PocketBase
await pb.collection('users').confirmPasswordReset(token, password, passwordConfirm);
console.log('Password reset successful');
return {
success: true,
};
} catch (err: any) {
console.error('Password reset error:', err);
// Parse error response
const errorData = err?.response?.data || err?.data || {};
if (errorData.token) {
return fail(400, { error: 'Invalid or expired reset token. Please request a new one.' });
}
if (errorData.password) {
return fail(400, { error: errorData.password.message || 'Invalid password' });
}
// Generic error
const message = err?.message || 'Failed to reset password. Please try again.';
return fail(400, { error: message });
}
},
} satisfies Actions;

View file

@ -0,0 +1,186 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import * as m from '$paraglide/messages';
let { form }: { form: ActionData } = $props();
let isSubmitting = $state(false);
// Get token from URL
const token = $page.url.searchParams.get('token');
</script>
<Navigation user={null} currentPath={$page.url.pathname} />
<div class="flex min-h-screen items-center justify-center bg-theme-background p-4">
<div class="w-full max-w-md">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
{#if !token}
<div class="text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h1 class="text-2xl font-bold text-theme-text">{m.auth_invalid_reset_link()}</h1>
<p class="mt-2 text-theme-text-muted">
{m.auth_invalid_reset_link_message()}
</p>
<div class="mt-6">
<a href="/forgot-password" class="text-theme-accent hover:text-theme-accent-hover">
{m.auth_request_new_reset_link()}
</a>
</div>
</div>
{:else if form?.success}
<div class="text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 class="text-2xl font-bold text-theme-text">{m.auth_password_reset_success()}</h1>
<p class="mt-2 text-theme-text-muted">
{m.auth_password_reset_success_message()}
</p>
<div class="mt-6">
<a
href="/login"
class="inline-flex items-center justify-center rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition duration-200 hover:bg-theme-primary-hover"
>
{m.auth_go_to_login()}
</a>
</div>
</div>
{:else}
<div class="mb-6 text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<h1 class="text-2xl font-bold text-theme-text">{m.auth_set_new_password_title()}</h1>
<p class="mt-2 text-theme-text-muted">
{m.auth_set_new_password_subtitle()}
</p>
</div>
<form
method="POST"
action="?/resetPassword"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
await update();
isSubmitting = false;
};
}}
>
<input type="hidden" name="token" value={token} />
<div class="space-y-4">
<div>
<label for="password" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_new_password_label()}
</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
placeholder={m.auth_new_password_placeholder()}
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div>
<label for="passwordConfirm" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_confirm_new_password_label()}
</label>
<input
type="password"
id="passwordConfirm"
name="passwordConfirm"
required
minlength="8"
placeholder={m.auth_confirm_new_password_placeholder()}
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
{#if form?.error}
<div
class="animate-fade-in rounded-lg border border-red-400 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
⚠️ {form.error}
</div>
{/if}
<button
type="submit"
disabled={isSubmitting}
class="flex w-full items-center justify-center rounded-lg bg-theme-primary px-4 py-3 font-medium text-white transition duration-200 hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<svg class="mr-2 h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{m.auth_reset_password_button_loading()}
{:else}
{m.auth_reset_password_button()}
{/if}
</button>
</div>
</form>
<div class="mt-6 border-t border-theme-border pt-6 text-center">
<p class="text-sm text-theme-text-muted">
{m.auth_remember_password()}
<a href="/login" class="font-medium text-theme-accent hover:text-theme-accent-hover">
{m.auth_back_to_login()}
</a>
</p>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,84 @@
import { redirect } from '@sveltejs/kit';
import { pb } from '$lib/pocketbase';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const token = url.searchParams.get('token');
if (!token) {
// No token provided
redirect(303, '/login?error=missing-token');
}
console.log('Processing verification token:', token.substring(0, 20) + '...');
try {
// Try to verify the email with the token
const result = await pb.collection('users').confirmVerification(token);
console.log('Email verification successful:', result);
// Redirect to login with success message (first-time verification)
redirect(303, '/login?verified=true');
} catch (error: any) {
console.error('Email verification error:', error);
console.error('Error details:', {
message: error?.message,
status: error?.status,
response: error?.response?.data || error?.response,
data: error?.data,
originalError: error?.originalError,
});
// Get the error message and code from various possible locations
const errorMessage =
error?.message ||
error?.response?.message ||
error?.response?.data?.message ||
error?.data?.message ||
'Verification failed';
const errorCode =
error?.status || error?.response?.status || error?.response?.code || error?.data?.code;
console.log('Error message:', errorMessage);
console.log('Error code:', errorCode);
// PocketBase returns 400 for invalid/used tokens
// But the user might already be verified, which is OK
// Check if the error message indicates the token was already used
const isAlreadyVerified =
errorMessage.toLowerCase().includes('already') ||
errorMessage.toLowerCase().includes('verified') ||
errorMessage.toLowerCase().includes('used');
const isExpired = errorMessage.toLowerCase().includes('expired');
// Since we know from your test that the user IS getting verified,
// and you see the error AFTER the user is already verified in PocketBase,
// we should treat most verification errors as success
// The PocketBase SDK might throw an error even when verification succeeds
// This is a known issue with how PocketBase handles verification
if (isExpired) {
// Token expired
console.log('Token expired');
redirect(303, '/login?error=token-expired');
} else {
// For ALL other errors, since the user confirmed that verification
// actually works in PocketBase, treat it as successful
// The error might be thrown even on first successful verification
console.log(
'Verification completed (despite error). Error code:',
errorCode,
', message:',
errorMessage
);
// Don't show "already verified" message on what might be the first verification
// Just show generic success
redirect(303, '/login?verified=true');
}
}
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { page } from '$app/stores';
import Navigation from '$lib/components/Navigation.svelte';
</script>
<Navigation user={null} currentPath={$page.url.pathname} />
<div class="flex min-h-screen items-center justify-center bg-theme-background p-4">
<div class="w-full max-w-md">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 text-center shadow-xl">
<svg
class="mx-auto mb-4 h-12 w-12 animate-spin text-theme-primary"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<h1 class="mb-2 text-2xl font-bold text-theme-text">
Verifying Email / E-Mail wird verifiziert
</h1>
<p class="text-theme-text-muted">Please wait... / Bitte warten...</p>
</div>
</div>
</div>

View file

@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
};
};

View file

@ -0,0 +1,69 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { themeStore } from '$lib/theme.svelte';
import { initLocale } from '$lib/locale';
import { onMount } from 'svelte';
import { Toaster } from 'svelte-sonner';
import { accountsStore } from '$lib/stores/accounts';
import { page } from '$app/stores';
import { pb } from '$lib/pocketbase';
import { initializePWA } from '$lib/pwa';
import CookieBanner from '$lib/components/gdpr/CookieBanner.svelte';
import InstallPWABanner from '$lib/components/mobile/InstallPWABanner.svelte';
let { children, data } = $props();
// Initialize accounts store when user data changes
$effect(() => {
if (data?.user) {
accountsStore.init(data.user);
} else {
accountsStore.clear();
}
});
onMount(() => {
initLocale();
// Initialize PocketBase auth from cookie on client-side
if (typeof document !== 'undefined') {
const cookie = document.cookie;
pb.authStore.loadFromCookie(cookie);
console.log('[ROOT LAYOUT] PocketBase auth initialized:', {
isValid: pb.authStore.isValid,
userId: pb.authStore.model?.id,
email: pb.authStore.model?.email,
});
}
// Initialize PWA
initializePWA();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
<!-- Toast Notifications -->
<Toaster
position="bottom-right"
expand={false}
richColors
closeButton
duration={4000}
visibleToasts={3}
toastOptions={{
className: 'sonner-toast',
descriptionClassName: 'sonner-description',
}}
/>
<!-- GDPR Cookie Banner - Disabled (only using Umami which doesn't use cookies) -->
<!-- <CookieBanner /> -->
<!-- PWA Install Banner -->
<InstallPWABanner />

View file

@ -0,0 +1,172 @@
import { fail } from '@sveltejs/kit';
import { pb, generateShortCode, type Link, type Click, type User } from '$lib/pocketbase';
import { getCollection } from '$lib/content';
import type { BlogPostWithMeta } from '../content/config';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
try {
const filter = locals.user ? `user_id="${locals.user.id}"` : '';
const links = await locals.pb.collection('links').getList<Link>(1, locals.user ? 50 : 10, {
filter,
sort: '-created',
expand: 'user',
});
const linksWithClicks = await Promise.all(
links.items.map(async (link) => {
const clicks = await locals.pb.collection('clicks').getList(1, 1, {
filter: `link_id="${link.id}"`,
});
return {
...link,
clicks: clicks.totalItems,
};
})
);
// Fetch global statistics
const [usersStats, linksStats, foldersStats, clicksStats] = await Promise.all([
locals.pb.collection('users').getList<User>(1, 1),
locals.pb.collection('links').getList<Link>(1, 1),
locals.pb.collection('folders').getList(1, 1),
locals.pb.collection('clicks').getList<Click>(1, 1),
]).catch(() => [{ totalItems: 0 }, { totalItems: 0 }, { totalItems: 0 }, { totalItems: 0 }]);
// Fetch latest blog posts
const blogPosts = await getCollection<BlogPostWithMeta>('blog').catch(() => []);
return {
links: linksWithClicks,
globalStats: {
totalUsers: usersStats.totalItems || 0,
totalLinks: linksStats.totalItems || 0,
totalFolders: foldersStats.totalItems || 0,
totalClicks: clicksStats.totalItems || 0,
},
blogPosts: blogPosts.slice(0, 3),
};
} catch (err) {
return {
links: [],
globalStats: {
totalUsers: 0,
totalLinks: 0,
totalFolders: 0,
totalClicks: 0,
},
};
}
};
export const actions = {
create: async ({ request, url, locals }) => {
console.log('🎯 Home page: Create action called');
console.log('User:', locals.user?.id || 'Anonymous');
const data = await request.formData();
const urlToShorten = data.get('url') as string;
const title = data.get('title') as string;
const description = data.get('description') as string;
const expiresIn = data.get('expires_in') as string;
const maxClicks = data.get('max_clicks') as string;
const password = data.get('password') as string;
console.log('📦 Form data:', {
url: urlToShorten,
title,
expiresIn,
maxClicks,
hasPassword: !!password,
});
if (!urlToShorten) {
console.error('❌ No URL provided');
return fail(400, { error: 'URL is required' });
}
// Get user's personal workspace if logged in
let workspaceId = null;
if (locals.user?.id) {
try {
const workspaces = await locals.pb.collection('workspaces').getList(1, 1, {
filter: `owner="${locals.user.id}" && type="personal"`,
});
if (workspaces.items.length > 0) {
workspaceId = workspaces.items[0].id;
}
} catch (err) {
console.error('Error fetching workspace:', err);
}
}
let shortCode = generateShortCode();
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
try {
let expiresAt = null;
if (expiresIn) {
const days = parseInt(expiresIn);
if (!isNaN(days) && days > 0) {
const date = new Date();
date.setDate(date.getDate() + days);
expiresAt = date.toISOString();
}
}
console.log('🔄 Attempting to create link with code:', shortCode);
const link = await locals.pb.collection('links').create({
workspace_id: workspaceId,
user_id: locals.user?.id || null,
original_url: urlToShorten,
short_code: shortCode,
title: title || '',
description: description || '',
is_active: true,
expires_at: expiresAt,
max_clicks: maxClicks ? parseInt(maxClicks) : null,
password: password || null,
});
console.log('✅ Link created successfully:', link);
return {
success: true,
shortUrl: `${url.origin}/${link.short_code}`,
link,
};
} catch (err: any) {
console.error('❌ Error creating link:', err);
console.error('Error details:', {
message: err?.message,
data: err?.data,
response: err?.response,
});
if (err?.data?.data?.short_code?.code === 'validation_not_unique') {
shortCode = generateShortCode();
attempts++;
console.log('🔄 Code collision, retrying with:', shortCode);
} else {
console.error('🔥 Fatal error, returning failure');
return fail(400, { error: err?.message || 'Failed to create short link' });
}
}
}
return fail(400, { error: 'Could not generate unique short code' });
},
delete: async ({ request, locals }) => {
const data = await request.formData();
const id = data.get('id') as string;
try {
await locals.pb.collection('links').delete(id);
return { deleted: true };
} catch (err) {
return fail(400, { error: 'Failed to delete link' });
}
},
} satisfies Actions;

View file

@ -0,0 +1,612 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import StatsBar from '$lib/components/StatsBar.svelte';
import HeroSection from '$lib/components/landing/HeroSection.svelte';
import TargetAudience from '$lib/components/landing/TargetAudience.svelte';
import FeatureShowcase from '$lib/components/landing/FeatureShowcase.svelte';
import PricingSection from '$lib/components/landing/PricingSection.svelte';
import Testimonials from '$lib/components/landing/Testimonials.svelte';
import TrustSignals from '$lib/components/landing/TrustSignals.svelte';
import BlogSection from '$lib/components/landing/BlogSection.svelte';
import { page } from '$app/stores';
import {
generateQRCodeURL,
downloadQRCode,
type QRCodeColor,
type QRCodeFormat,
} from '$lib/qrcode';
import * as m from '$paraglide/messages';
let { data, form }: { data: PageData; form: ActionData } = $props();
let copiedStates = $state<Record<string, boolean>>({});
let isSubmitting = $state(false);
let successMessageVisible = $state(false);
let showQRCode = $state<string | null>(null);
let qrColor = $state<QRCodeColor>('black');
let qrFormat = $state<QRCodeFormat>('png');
let showAuthModal = $state(false);
let inputUrl = $state('');
let showQROnly = $state(false);
function copyToClipboard(text: string, id: string = 'main') {
navigator.clipboard.writeText(text);
copiedStates[id] = true;
setTimeout(() => (copiedStates[id] = false), 2000);
}
function formatUrl(url: string) {
if (typeof window === 'undefined') return url;
return `${window.location.origin}/${url}`;
}
function toggleQRCode(shortCode: string) {
if (showQRCode === shortCode) {
showQRCode = null;
} else {
showQRCode = shortCode;
qrColor = 'black';
qrFormat = 'png';
}
}
function downloadQR(shortCode: string) {
const url = formatUrl(shortCode);
downloadQRCode(url, `qrcode-${shortCode}`, 400, qrColor, qrFormat);
}
function handleLockedFieldClick() {
if (!data.user) {
showAuthModal = true;
}
}
function handleSubmitForUnauthenticated(e: Event) {
if (!data.user) {
e.preventDefault();
if (inputUrl) {
showQROnly = true;
}
}
}
$effect(() => {
if (form?.success) {
successMessageVisible = true;
setTimeout(() => (successMessageVisible = false), 5000);
}
});
</script>
{#if showAuthModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Sign in to unlock all features
</h3>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
This feature is only available for registered users. Sign in to:
</p>
<ul class="mb-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-center gap-2">
<span class="text-green-500"></span> Create short links
</li>
<li class="flex items-center gap-2">
<span class="text-green-500"></span> Set custom titles and descriptions
</li>
<li class="flex items-center gap-2">
<span class="text-green-500"></span> Add expiration dates
</li>
<li class="flex items-center gap-2">
<span class="text-green-500"></span> Password protect links
</li>
<li class="flex items-center gap-2">
<span class="text-green-500"></span> Track analytics
</li>
</ul>
<div class="flex gap-3">
<a
href="/login"
class="flex-1 rounded-lg bg-theme-primary px-4 py-2 text-center font-medium text-white hover:bg-theme-primary-hover"
>
Sign In
</a>
<button
onclick={() => (showAuthModal = false)}
class="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<!-- New Hero Section -->
<HeroSection {data} {form} />
<!-- Target Audience Section -->
<TargetAudience />
<!-- Feature Showcase -->
<FeatureShowcase />
<!-- Global Statistics Bar -->
{#if data.globalStats}
<div class="mx-auto max-w-4xl px-4 pt-4 sm:px-6 lg:px-8">
<StatsBar stats={data.globalStats} />
</div>
{/if}
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div
id="url-form"
class="mb-8 rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl sm:p-8"
>
<form
method="POST"
action="?/create"
onsubmit={handleSubmitForUnauthenticated}
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
>
<div class="space-y-4">
<div>
<label for="url" class="mb-1 block text-sm font-medium text-theme-text">
{!data.user ? m.home_url_label_qr() : m.home_url_label()}
</label>
<input
type="url"
id="url"
name="url"
required
bind:value={inputUrl}
placeholder="https://example.com"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
</div>
<div class="relative">
<label
for="title"
class="mb-1 block text-sm font-medium text-theme-text {!data.user
? 'opacity-50'
: ''}"
>
{m.home_title_label()}
{!data.user ? '🔒' : ''}
</label>
<div class="relative">
<input
type="text"
id="title"
name="title"
placeholder={m.home_title_placeholder()}
disabled={!data.user}
onclick={handleLockedFieldClick}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted {!data.user
? 'cursor-not-allowed bg-gray-100 opacity-50 dark:bg-gray-800'
: 'focus:outline-none focus:ring-2 focus:ring-theme-accent'}"
/>
{#if !data.user}
<button
type="button"
class="absolute inset-0 cursor-not-allowed rounded-md"
onclick={handleLockedFieldClick}
aria-label="Field locked for guests"
></button>
{/if}
</div>
</div>
<div class="relative">
<label
for="description"
class="mb-1 block text-sm font-medium text-theme-text {!data.user
? 'opacity-50'
: ''}"
>
{m.home_description_label()}
{!data.user ? '🔒' : ''}
</label>
<div class="relative">
<textarea
id="description"
name="description"
rows="2"
placeholder={m.home_description_placeholder()}
disabled={!data.user}
onclick={handleLockedFieldClick}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted {!data.user
? 'cursor-not-allowed bg-gray-100 opacity-50 dark:bg-gray-800'
: 'focus:outline-none focus:ring-2 focus:ring-theme-accent'}"
></textarea>
{#if !data.user}
<button
type="button"
class="absolute inset-0 cursor-not-allowed rounded-md"
onclick={handleLockedFieldClick}
aria-label="Field locked for guests"
></button>
{/if}
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="relative">
<label
for="expires_in"
class="mb-1 block text-sm font-medium text-theme-text {!data.user
? 'opacity-50'
: ''}"
>
{m.home_expires_label()}
{!data.user ? '🔒' : ''}
</label>
<div class="relative">
<input
type="number"
id="expires_in"
name="expires_in"
min="1"
placeholder={m.home_expires_placeholder()}
disabled={!data.user}
onclick={handleLockedFieldClick}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted {!data.user
? 'cursor-not-allowed bg-gray-100 opacity-50 dark:bg-gray-800'
: 'focus:outline-none focus:ring-2 focus:ring-theme-accent'}"
/>
{#if !data.user}
<button
type="button"
class="absolute inset-0 cursor-not-allowed rounded-md"
onclick={handleLockedFieldClick}
aria-label="Field locked for guests"
></button>
{/if}
</div>
</div>
<div class="relative">
<label
for="max_clicks"
class="mb-1 block text-sm font-medium text-theme-text {!data.user
? 'opacity-50'
: ''}"
>
{m.home_max_clicks_label()}
{!data.user ? '🔒' : ''}
</label>
<div class="relative">
<input
type="number"
id="max_clicks"
name="max_clicks"
min="1"
placeholder={m.home_max_clicks_placeholder()}
disabled={!data.user}
onclick={handleLockedFieldClick}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted {!data.user
? 'cursor-not-allowed bg-gray-100 opacity-50 dark:bg-gray-800'
: 'focus:outline-none focus:ring-2 focus:ring-theme-accent'}"
/>
{#if !data.user}
<button
type="button"
class="absolute inset-0 cursor-not-allowed rounded-md"
onclick={handleLockedFieldClick}
aria-label="Field locked for guests"
></button>
{/if}
</div>
</div>
<div class="relative">
<label
for="password"
class="mb-1 block text-sm font-medium text-theme-text {!data.user
? 'opacity-50'
: ''}"
>
{m.home_password_label()}
{!data.user ? '🔒' : ''}
</label>
<div class="relative">
<input
type="password"
id="password"
name="password"
placeholder={m.home_password_placeholder()}
disabled={!data.user}
onclick={handleLockedFieldClick}
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted {!data.user
? 'cursor-not-allowed bg-gray-100 opacity-50 dark:bg-gray-800'
: 'focus:outline-none focus:ring-2 focus:ring-theme-accent'}"
/>
{#if !data.user}
<button
type="button"
class="absolute inset-0 cursor-not-allowed rounded-md"
onclick={handleLockedFieldClick}
aria-label="Field locked for guests"
></button>
{/if}
</div>
</div>
</div>
{#if !data.user}
<div
class="rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
<p class="font-medium">👋 {m.home_guest_info()}</p>
<p class="mt-1">
<a href="/login" class="underline hover:no-underline">{m.auth_modal_signin()}</a>
{m.home_guest_signin_hint()}
</p>
</div>
{/if}
<button
type="submit"
disabled={isSubmitting || (!data.user && !inputUrl)}
class="flex w-full items-center justify-center rounded-lg {data.user
? 'bg-theme-primary hover:bg-theme-primary-hover'
: 'bg-purple-600 hover:bg-purple-700'} px-4 py-3 font-medium text-white transition duration-200 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<svg class="mr-2 h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{m.home_processing()}
{:else if !data.user}
{m.home_submit_button_qr()}
{:else}
{m.home_submit_button()}
{/if}
</button>
</div>
</form>
{#if form?.error}
<div
class="mt-4 rounded border border-red-400 bg-red-100 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{form.error}
</div>
{/if}
{#if showQROnly && inputUrl && !data.user}
<div
class="animate-fade-in mt-4 rounded-lg border border-purple-400 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-900/20"
>
<p class="mb-4 font-medium text-purple-700 dark:text-purple-400">
◳ QR Code for your URL:
</p>
<div class="flex flex-col items-center gap-4">
<img
src={generateQRCodeURL(inputUrl, 200, qrColor, 'png')}
alt="QR Code"
class="rounded border-2 border-gray-300 bg-white p-2 dark:border-gray-600"
/>
<div class="flex gap-4">
<div>
<span class="mb-1 block text-xs text-gray-600 dark:text-gray-400">Color</span>
<div class="flex gap-2" role="group" aria-label="QR Code Color">
<button
type="button"
onclick={() => (qrColor = 'black')}
class="h-8 w-8 rounded border-2 bg-black {qrColor === 'black'
? 'border-blue-500'
: 'border-gray-300'}"
aria-label="Black"
></button>
<button
type="button"
onclick={() => (qrColor = 'white')}
class="h-8 w-8 rounded border-2 bg-white {qrColor === 'white'
? 'border-blue-500'
: 'border-gray-300'}"
aria-label="White"
></button>
<button
type="button"
onclick={() => (qrColor = 'gold')}
class="h-8 w-8 rounded border-2 {qrColor === 'gold'
? 'border-blue-500'
: 'border-gray-300'}"
style="background: #f8d62b"
aria-label="Gold"
></button>
</div>
</div>
<div>
<label
for="qr-format-unauthenticated"
class="mb-1 block text-xs text-gray-600 dark:text-gray-400"
>
Format
</label>
<select
id="qr-format-unauthenticated"
bind:value={qrFormat}
class="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="png">PNG</option>
<option value="svg">SVG</option>
<option value="jpg">JPG</option>
</select>
</div>
</div>
<button
type="button"
onclick={() => {
const fileName = inputUrl
.replace(/^https?:\/\//, '')
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase();
downloadQRCode(inputUrl, `qrcode-${fileName}`, 400, qrColor, qrFormat);
}}
class="rounded bg-purple-600 px-4 py-2 text-sm text-white hover:bg-purple-700"
>
Download as {qrFormat.toUpperCase()}
</button>
<div
class="mt-2 w-full rounded-lg bg-blue-50 p-3 text-center text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
<p>
<a href="/login" class="font-medium underline hover:no-underline">Sign in</a> to create
a short link for this URL
</p>
</div>
</div>
</div>
{/if}
{#if form?.success && form?.shortUrl && successMessageVisible}
<div
class="animate-fade-in mt-4 rounded-lg border border-green-400 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
>
<p class="mb-2 font-medium text-green-700 dark:text-green-400">
✅ Success! Your short URL is ready:
</p>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<input
type="text"
readonly
value={form.shortUrl}
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
onclick={() => copyToClipboard(form.shortUrl)}
class="whitespace-nowrap rounded-lg bg-neutral-900 px-6 py-2 text-white transition duration-200 hover:bg-neutral-800 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
{copiedStates['main'] ? '✓ Copied!' : '📋 Copy'}
</button>
<button
onclick={() => {
const shortCode = form.shortUrl.split('/').pop();
if (shortCode) {
toggleQRCode(shortCode);
}
}}
class="whitespace-nowrap rounded-lg bg-purple-600 px-6 py-2 text-white transition duration-200 hover:bg-purple-700"
>
{showQRCode === form.shortUrl.split('/').pop() ? '✕ Close QR' : '◳ QR Code'}
</button>
</div>
{#if showQRCode === form.shortUrl.split('/').pop()}
<div class="mt-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
<div class="flex flex-col items-center gap-4">
<img
src={generateQRCodeURL(form.shortUrl, 200, qrColor, 'png')}
alt="QR Code"
class="rounded border-2 border-gray-300 bg-white p-2 dark:border-gray-600"
/>
<div class="flex gap-4">
<div>
<span class="mb-1 block text-xs text-gray-600 dark:text-gray-400">Color</span>
<div class="flex gap-2" role="group" aria-label="QR Code Color">
<button
onclick={() => (qrColor = 'black')}
class="h-8 w-8 rounded border-2 bg-black {qrColor === 'black'
? 'border-blue-500'
: 'border-gray-300'}"
aria-label="Black"
></button>
<button
onclick={() => (qrColor = 'white')}
class="h-8 w-8 rounded border-2 bg-white {qrColor === 'white'
? 'border-blue-500'
: 'border-gray-300'}"
aria-label="White"
></button>
<button
onclick={() => (qrColor = 'gold')}
class="h-8 w-8 rounded border-2 {qrColor === 'gold'
? 'border-blue-500'
: 'border-gray-300'}"
style="background: #f8d62b"
aria-label="Gold"
></button>
</div>
</div>
<div>
<label
for="qr-format-success"
class="mb-1 block text-xs text-gray-600 dark:text-gray-400"
>
Format
</label>
<select
id="qr-format-success"
bind:value={qrFormat}
class="rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="png">PNG</option>
<option value="svg">SVG</option>
<option value="jpg">JPG</option>
</select>
</div>
</div>
<button
onclick={() => {
const shortCode = form.shortUrl.split('/').pop();
if (shortCode) {
downloadQR(shortCode);
}
}}
class="rounded bg-purple-600 px-4 py-2 text-sm text-white hover:bg-purple-700"
>
Download as {qrFormat.toUpperCase()}
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Pricing Section -->
<PricingSection />
<!-- Testimonials -->
<Testimonials />
<!-- Blog Section -->
<BlogSection posts={data.blogPosts || []} />
<!-- Trust Signals -->
<TrustSignals />
</div>
<Footer />

View file

@ -0,0 +1,195 @@
import { redirect, error, fail } from '@sveltejs/kit';
import { parseUserAgent, type Link } from '$lib/pocketbase';
import { linkCache } from '$lib/server/linkCache';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({
params,
request,
getClientAddress,
cookies,
locals,
}) => {
const { slug } = params;
// Parse slug - could be either:
// 1. Random code: "abc123"
// 2. Username/custom code: "u/username/project" -> stored as "username/project"
let shortCode: string;
if (typeof slug === 'string') {
shortCode = slug;
} else if (Array.isArray(slug)) {
// Join array elements with slash (SvelteKit [...slug] behavior)
shortCode = slug.join('/');
} else {
error(404, 'Invalid URL format');
}
// Handle /u/username/code format - strip the 'u/' prefix
// Links are stored as "username/code" in the database
if (shortCode.startsWith('u/')) {
shortCode = shortCode.substring(2); // Remove 'u/' prefix
console.log('Stripped u/ prefix, now looking for:', shortCode);
}
console.log('Looking for link with short_code:', shortCode);
// First, try to get from Redis cache (SUPER FAST!)
const cachedUrl = await linkCache.getRedirectUrl(shortCode);
if (cachedUrl) {
console.log('Cache HIT! Redirecting from cache');
// For cached redirects, we skip all checks for maximum speed
// The cache is only populated with valid, active links
throw redirect(302, cachedUrl);
}
console.log('Cache MISS - fetching from PocketBase');
console.log('PocketBase URL:', locals.pb.baseUrl);
// Try to get the link directly by short_code
let link;
try {
link = await locals.pb.collection('links').getFirstListItem<Link>(`short_code="${shortCode}"`);
console.log('Found link:', link);
// Cache the link for future requests (if it's valid)
if (
link.is_active &&
!link.password &&
(!link.expires_at || new Date(link.expires_at) > new Date())
) {
await linkCache.cacheRedirect(shortCode, link.original_url);
console.log('Cached redirect for future use');
}
} catch (fetchErr: any) {
console.error('Failed to fetch link:', fetchErr);
console.error('Error details:', fetchErr.response || fetchErr.message);
// Try to see if any links exist for debugging
try {
const testList = await locals.pb.collection('links').getList(1, 5);
console.log('Total links in database:', testList.totalItems);
console.log(
'Sample links:',
testList.items.map((l) => l.short_code)
);
} catch (e) {
console.error('Cannot list links:', e);
}
throw error(404, 'Link not found');
}
// Check if link is active
if (!link.is_active) {
throw error(410, 'This link is no longer active');
}
// Check if link has expired
if (link.expires_at && new Date(link.expires_at) < new Date()) {
throw error(410, 'This link has expired');
}
// Check click count
try {
const clicks = await locals.pb.collection('clicks').getList(1, 1, {
filter: `link_id="${link.id}"`,
sort: '-created',
});
const totalClicks = clicks.totalItems;
if (link.max_clicks && totalClicks >= link.max_clicks) {
throw error(410, 'This link has reached its maximum click limit');
}
} catch (clickErr: any) {
// If it's our error, re-throw it
if (clickErr?.status === 410) {
throw clickErr;
}
// Otherwise, log and continue (don't fail on click count check)
console.error('Failed to check click count:', clickErr);
}
// Check if password is required
if (link.password) {
const sessionKey = `link_auth_${link.id}`;
const isAuthenticated = cookies.get(sessionKey) === 'true';
if (!isAuthenticated) {
return {
requiresPassword: true,
linkId: link.id,
};
}
}
// Record the click (but don't fail if it doesn't work)
const userAgent = request.headers.get('user-agent') || '';
const referer = request.headers.get('referer') || '';
const ipAddress = getClientAddress();
const { browser, deviceType, os } = parseUserAgent(userAgent);
// Simple location detection (no external services needed)
let country = 'Unknown';
let city = 'Unknown';
// For local/development IPs, use mock data
if (ipAddress === '::1' || ipAddress === '127.0.0.1') {
country = 'Germany';
city = 'Munich';
}
try {
await locals.pb.collection('clicks').create({
link_id: link.id,
ip_hash: ipAddress,
user_agent: userAgent,
referer: referer,
browser: browser,
device_type: deviceType,
os: os,
country: country,
city: city,
clicked_at: new Date().toISOString(),
});
console.log('Click recorded successfully');
} catch (clickErr) {
console.error('Failed to record click:', clickErr);
// Don't fail the redirect if click recording fails
}
// Perform the redirect
console.log('Redirecting to:', link.original_url);
throw redirect(302, link.original_url);
};
export const actions = {
unlock: async ({ request, params, cookies, locals }) => {
const data = await request.formData();
const password = data.get('password') as string;
const { code } = params;
let link;
try {
link = await locals.pb.collection('links').getFirstListItem<Link>(`short_code="${code}"`);
} catch (err) {
return fail(404, { error: 'Link not found' });
}
if (link.password !== password) {
return fail(400, { error: 'Incorrect password' });
}
const sessionKey = `link_auth_${link.id}`;
cookies.set(sessionKey, 'true', {
path: '/',
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: 'lax',
});
// Redirect back to the same URL to trigger the load function again
throw redirect(303, `/${code}`);
},
} satisfies Actions;

View file

@ -0,0 +1,67 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let { data, form }: { data: PageData; form: ActionData } = $props();
// If no password required and we're in the browser, something went wrong with the redirect
onMount(() => {
if (!data.requiresPassword && browser) {
// This shouldn't happen - the server should have redirected
console.error('Redirect failed - no password required but still on page');
}
});
</script>
{#if data.requiresPassword}
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div class="w-full max-w-md">
<div class="rounded-lg bg-white p-8 shadow-md">
<h1 class="mb-6 text-center text-2xl font-bold text-gray-900">Password Protected Link</h1>
<p class="mb-4 text-center text-gray-600">This link requires a password to access.</p>
<form method="POST" action="?/unlock" use:enhance>
<div class="space-y-4">
<div>
<label for="password" class="mb-1 block text-sm font-medium text-gray-700">
Password
</label>
<input
type="password"
id="password"
name="password"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{#if form?.error}
<div class="rounded border border-red-400 bg-red-100 p-3 text-red-700">
{form.error}
</div>
{/if}
<button
type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition duration-200 hover:bg-blue-700"
>
Continue
</button>
</div>
</form>
</div>
</div>
</div>
{:else}
<!-- Fallback content if no redirect happened -->
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div class="w-full max-w-md text-center">
<p class="text-gray-600">Redirecting...</p>
<p class="mt-2 text-sm text-gray-500">
If you are not redirected, there may be an issue with this link.
</p>
</div>
</div>
{/if}

View file

@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import { validateUsername } from '$lib/username';
import type { RequestHandler } from './$types';
import { users } from '$lib/db/schema';
import { eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url, locals }) => {
const username = url.searchParams.get('username');
if (!username) {
return json({ available: false, error: 'Username required' });
}
// Validate format first
const validation = validateUsername(username);
if (!validation.valid) {
return json({ available: false, error: validation.error });
}
try {
// Try to find a user with this username using Drizzle ORM
const [existingUser] = await locals.db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
// If no user found, username is available
if (!existingUser) {
return json({ available: true });
}
// Check if it's the current user (they're checking their temp username)
if (locals.user && existingUser.id === locals.user.id) {
// It's their own temporary username, so it's "available" for them
return json({ available: true });
}
// Username taken by someone else
return json({ available: false });
} catch (err) {
console.error('Error checking username:', err);
return json({ available: false, error: 'Database error' }, { status: 500 });
}
};

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
};

View file

@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { redis, ensureRedisConnection, redisAvailable } from '$lib/server/redis';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const status = {
connected: false,
host: process.env.REDIS_HOST || 'not configured',
enabled: !!redis,
available: redisAvailable,
cachedLinks: 0,
error: null as string | null,
};
try {
// Try to connect
const connected = await ensureRedisConnection();
status.connected = connected;
if (connected && redis) {
// Count cached redirects
const keys = await redis.keys('redirect:*');
status.cachedLinks = keys.length;
// Test basic operation
await redis.setex('test:ping', 10, 'pong');
const test = await redis.get('test:ping');
if (test !== 'pong') {
status.error = 'Read/write test failed';
}
} else if (!redis) {
status.error = 'Redis is not configured (check environment variables)';
} else {
status.error = 'Could not establish connection';
}
} catch (error: any) {
status.error = error.message;
status.connected = false;
}
return json(status);
};

View file

@ -0,0 +1,123 @@
import { json } from '@sveltejs/kit';
import { getStripe } from '$lib/server/stripe';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
// Map our locale codes to Stripe's expected format
function mapLocaleToStripe(locale: string): any {
const stripeLocales: Record<string, any> = {
en: 'en',
de: 'de',
it: 'it',
fr: 'fr',
es: 'es',
};
return stripeLocales[locale] || 'auto';
}
export const POST: RequestHandler = async ({ request, locals, url }) => {
try {
// Check if user is authenticated
if (!locals.pb.authStore.isValid || !locals.user) {
return json({ error: 'Bitte erst einloggen' }, { status: 401 });
}
const user = locals.user;
const { priceType = 'monthly', locale = 'en' } = await request.json();
// Check if user already has active subscription
if (user.subscription_status === 'pro') {
return json({ error: 'Du hast bereits ein aktives Abo' }, { status: 400 });
}
// Select the correct price ID
let priceId: string;
let mode: 'subscription' | 'payment' = 'subscription';
switch (priceType) {
case 'yearly':
priceId = env.STRIPE_PRICE_YEARLY;
break;
case 'lifetime':
priceId = env.STRIPE_PRICE_LIFETIME;
mode = 'payment'; // One-time payment for lifetime
break;
case 'monthly':
default:
priceId = env.STRIPE_PRICE_MONTHLY;
break;
}
if (!priceId) {
throw new Error(`Price ID not found for type: ${priceType}`);
}
// Initialize Stripe
const stripe = getStripe();
// Create or get Stripe customer
let stripeCustomerId = user.stripe_customer_id;
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: {
pocketbase_id: user.id,
username: user.username || '',
},
});
stripeCustomerId = customer.id;
// Save customer ID to PocketBase
await locals.pb.collection('users').update(user.id, {
stripe_customer_id: stripeCustomerId,
});
}
// Create Stripe Checkout session
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
payment_method_types: ['card', 'sepa_debit'],
billing_address_collection: 'required',
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode,
allow_promotion_codes: true,
success_url: `${url.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${url.origin}/pricing?cancelled=true`,
locale: mapLocaleToStripe(locale),
metadata: {
user_id: user.id,
user_email: user.email,
price_type: priceType,
},
...(mode === 'subscription' && {
subscription_data: {
metadata: {
pocketbase_user_id: user.id,
},
},
}),
});
return json({
sessionId: session.id,
url: session.url,
});
} catch (error) {
console.error('Stripe checkout error:', error);
return json(
{
error:
error instanceof Error ? error.message : 'Fehler beim Erstellen der Checkout-Session',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,244 @@
import { getStripe } from '$lib/server/stripe';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
import type { RequestHandler } from './$types';
import PocketBase from 'pocketbase';
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
// Initialize Stripe
const stripe = getStripe();
const STRIPE_WEBHOOK_SECRET = env.STRIPE_WEBHOOK_SECRET;
// For development without webhook secret
if (!STRIPE_WEBHOOK_SECRET || STRIPE_WEBHOOK_SECRET === 'undefined') {
console.warn('⚠️ No webhook secret configured - running in test mode');
const event = JSON.parse(body);
return handleWebhookEvent(event, locals);
}
if (!signature) {
return new Response('No signature', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err: any) {
// Webhook signature verification failed
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
return handleWebhookEvent(event, locals);
};
async function handleWebhookEvent(event: any, locals?: any) {
// Create admin PocketBase client for webhooks
const pb = new PocketBase(publicEnv.PUBLIC_POCKETBASE_URL);
// Admin auth for updating users
try {
if (!env.POCKETBASE_ADMIN_EMAIL || !env.POCKETBASE_ADMIN_PASSWORD) {
throw new Error(
'Admin credentials not configured. Please set POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD environment variables.'
);
}
await pb.admins.authWithPassword(env.POCKETBASE_ADMIN_EMAIL, env.POCKETBASE_ADMIN_PASSWORD);
// Admin authenticated successfully
} catch (error) {
// Admin authentication failed
// Return error response for missing credentials
return new Response(
JSON.stringify({
error: 'Webhook processing failed due to configuration error',
details: 'Admin authentication failed. Please check server configuration.',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Checkout completed
const userId = session.metadata?.user_id;
// Processing user_id from metadata
if (!userId) {
// No user_id in session metadata
break;
}
const priceType = session.metadata?.price_type || 'monthly';
// Handle lifetime purchase differently
if (priceType === 'lifetime') {
await pb.collection('users').update(userId, {
subscription_status: 'pro',
stripe_customer_id: session.customer,
stripe_subscription_id: 'lifetime_' + session.id,
current_period_end: new Date('2099-12-31').toISOString(), // Far future date
links_created_this_month: 0,
});
// User purchased lifetime access
} else {
// Handle subscription
if (session.subscription) {
const stripe = getStripe();
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await pb.collection('users').update(userId, {
subscription_status: 'pro',
stripe_customer_id: session.customer,
stripe_subscription_id: subscription.id,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
links_created_this_month: 0,
});
// User upgraded to Pro
}
}
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
// Subscription updated
// Get user by stripe_subscription_id
try {
const users = await pb.collection('users').getList(1, 1, {
filter: `stripe_subscription_id = "${subscription.id}"`,
});
if (users.items.length > 0) {
const user = users.items[0];
// Map Stripe status to our status
let status = 'free';
switch (subscription.status) {
case 'active':
status = 'pro';
break;
case 'past_due':
status = 'past_due';
break;
case 'canceled':
case 'unpaid':
status = 'cancelled';
break;
}
await pb.collection('users').update(user.id, {
subscription_status: status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
});
// User subscription status updated
}
} catch (error) {
// Error finding user for subscription
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// Subscription cancelled
try {
const users = await pb.collection('users').getList(1, 1, {
filter: `stripe_subscription_id = "${subscription.id}"`,
});
if (users.items.length > 0) {
const user = users.items[0];
await pb.collection('users').update(user.id, {
subscription_status: 'free',
stripe_subscription_id: null,
current_period_end: null,
});
// User downgraded to Free
}
} catch (error) {
// Error finding user for cancelled subscription
}
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
// Payment failed
if (invoice.subscription) {
try {
const users = await pb.collection('users').getList(1, 1, {
filter: `stripe_subscription_id = "${invoice.subscription}"`,
});
if (users.items.length > 0) {
const user = users.items[0];
await pb.collection('users').update(user.id, {
subscription_status: 'past_due',
});
// User payment failed - marked as past_due
}
} catch (error) {
// Error handling payment failure
}
}
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
// Payment succeeded
if (invoice.subscription) {
try {
const users = await pb.collection('users').getList(1, 1, {
filter: `stripe_subscription_id = "${invoice.subscription}"`,
});
if (users.items.length > 0) {
const user = users.items[0];
// Reactivate if was past_due
if (user.subscription_status === 'past_due') {
await pb.collection('users').update(user.id, {
subscription_status: 'pro',
});
// User reactivated after payment
}
}
} catch (error) {
// Error handling payment success
}
}
break;
}
default:
// Unhandled event type
}
return new Response('Webhook processed', { status: 200 });
} catch (error) {
// Webhook processing error
return new Response('Webhook processing failed', { status: 500 });
} finally {
pb.authStore.clear();
}
}

View file

@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
try {
console.log('[TEST-PB] Testing PocketBase connection');
console.log('[TEST-PB] PocketBase URL:', locals.pb?.baseUrl);
console.log('[TEST-PB] PocketBase instance exists:', !!locals.pb);
// Try to fetch health status
const healthCheck = await fetch(`${locals.pb.baseUrl}/api/health`);
const healthData = await healthCheck.json();
console.log('[TEST-PB] Health check response:', healthData);
// Try to list collections (public endpoint)
try {
const collections = await locals.pb.collections.getList(1, 1);
console.log('[TEST-PB] Can access collections:', !!collections);
} catch (e) {
console.log('[TEST-PB] Cannot access collections (might be normal):', e.message);
}
return json({
success: true,
pocketbaseUrl: locals.pb?.baseUrl,
health: healthData,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[TEST-PB] Error:', error);
return json(
{
success: false,
error: error.message,
pocketbaseUrl: locals.pb?.baseUrl,
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { dev } from '$app/environment';
// Alternative verification endpoint that redirects to PocketBase's built-in verification
export const GET: RequestHandler = async ({ url }) => {
const token = url.searchParams.get('token');
if (!token) {
// No token - redirect to login with error
redirect(303, '/login?error=missing-token');
}
// Get the correct PocketBase URL based on environment
const pbUrl = env.PUBLIC_POCKETBASE_URL || (dev ? 'http://localhost:8090' : 'https://pb.ulo.ad');
// Redirect to PocketBase's built-in verification endpoint
// PocketBase will handle the verification and show its own success/error page
redirect(303, `${pbUrl}/_/#/auth/confirm-verification/${token}`);
};

View file

@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pb } from '$lib/pocketbase';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { featureRequestId, action } = await request.json();
if (!featureRequestId || !action) {
return json({ error: 'Invalid request' }, { status: 400 });
}
try {
// Use admin client for updating vote counts
const adminPb = new (await import('pocketbase')).default(pb.baseUrl);
// Try to authenticate as admin using environment variables
try {
await adminPb.admins.authWithPassword(
process.env.POCKETBASE_ADMIN_EMAIL || 'admin@example.com',
process.env.POCKETBASE_ADMIN_PASSWORD || 'admin123456'
);
} catch (authError) {
console.error('Admin auth failed, trying alternative approach');
// If admin auth fails, we'll use the regular pb instance
}
if (action === 'add') {
// Check if vote already exists
const existingVotes = await pb.collection('featurevotes').getList(1, 1, {
filter: `user_id = "${locals.user.id}" && feature_request_id = "${featureRequestId}"`,
});
if (existingVotes.items.length === 0) {
// Create vote
await pb.collection('featurevotes').create({
user_id: locals.user.id,
feature_request_id: featureRequestId,
});
// Get current vote count and increment
const featureRequest = await pb.collection('featurerequests').getOne(featureRequestId);
const newCount = (featureRequest.vote_count || 0) + 1;
// Update using admin client if available, otherwise try regular
const client = adminPb.authStore.isValid ? adminPb : pb;
await client.collection('featurerequests').update(featureRequestId, {
vote_count: newCount,
});
return json({ success: true, voteCount: newCount });
}
return json({ success: false, message: 'Already voted' });
} else if (action === 'remove') {
// Find and delete vote
const existingVotes = await pb.collection('featurevotes').getList(1, 1, {
filter: `user_id = "${locals.user.id}" && feature_request_id = "${featureRequestId}"`,
});
if (existingVotes.items.length > 0) {
await pb.collection('featurevotes').delete(existingVotes.items[0].id);
// Get current vote count and decrement
const featureRequest = await pb.collection('featurerequests').getOne(featureRequestId);
const newCount = Math.max(0, (featureRequest.vote_count || 0) - 1);
// Update using admin client if available, otherwise try regular
const client = adminPb.authStore.isValid ? adminPb : pb;
await client.collection('featurerequests').update(featureRequestId, {
vote_count: newCount,
});
return json({ success: true, voteCount: newCount });
}
return json({ success: false, message: 'No vote found' });
}
return json({ error: 'Invalid action' }, { status: 400 });
} catch (error) {
console.error('Vote error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
let verifying = true;
onMount(async () => {
// Verify the session with backend
const sessionId = $page.url.searchParams.get('session_id');
if (sessionId) {
console.log('Payment session completed:', sessionId);
}
// Refresh user data
await invalidateAll();
verifying = false;
});
</script>
<svelte:head>
<title>Zahlung erfolgreich - ulo.ad</title>
</svelte:head>
<div
class="flex min-h-screen items-center justify-center bg-theme-background px-4 dark:bg-neutral-950"
>
<div class="max-w-md text-center">
{#if verifying}
<div
class="border-theme-primary/20 mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-theme-primary"
></div>
<p class="text-lg text-theme-text">Verifiziere deine Zahlung...</p>
{:else}
<div class="mb-8">
<div
class="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<svg
class="h-12 w-12 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
</div>
</div>
<h1 class="mb-4 text-3xl font-bold text-theme-text">Willkommen bei ulo.ad Pro! 🎉</h1>
<p class="mb-8 text-lg text-theme-text-muted">
Dein Upgrade war erfolgreich. Du hast jetzt Zugriff auf alle Pro Features!
</p>
<div class="mb-8 rounded-lg border border-theme-border bg-theme-surface p-6">
<h2 class="mb-3 font-semibold text-theme-text">Was du jetzt kannst:</h2>
<ul class="space-y-2 text-left">
<li class="flex items-start gap-2">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-theme-text">Unbegrenzt Links erstellen</span>
</li>
<li class="flex items-start gap-2">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-theme-text">Erweiterte Analytics nutzen</span>
</li>
<li class="flex items-start gap-2">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-theme-text">Custom QR Codes erstellen</span>
</li>
<li class="flex items-start gap-2">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span class="text-theme-text">Priority Support erhalten</span>
</li>
</ul>
</div>
<div class="flex justify-center gap-4">
<a
href="/dashboard"
class="rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Zum Dashboard
</a>
<a
href="/account"
class="rounded-lg border border-theme-border bg-theme-surface px-6 py-3 font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Account verwalten
</a>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,39 @@
import { json } from '@sveltejs/kit';
import { building } from '$app/environment';
import type { RequestHandler } from './$types';
import { sql } from 'drizzle-orm';
export const GET: RequestHandler = async ({ locals }) => {
const checks: Record<string, { status: string; message?: string }> = {};
// Database health check
try {
const result = await locals.db.execute(sql`SELECT 1 as health`);
checks.database = {
status: result ? 'healthy' : 'unhealthy',
message: 'PostgreSQL connection successful',
};
} catch (error) {
checks.database = {
status: 'unhealthy',
message: error instanceof Error ? error.message : 'Database connection failed',
};
}
// Overall health status
const isHealthy = Object.values(checks).every((check) => check.status === 'healthy');
const health = {
status: isHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
environment: building ? 'build' : 'runtime',
services: {
sveltekit: 'running',
...checks,
},
};
return json(health, {
status: isHealthy ? 200 : 503,
});
};

View file

@ -0,0 +1,130 @@
<script>
import { onMount } from 'svelte';
let isOnline = $state(true);
onMount(() => {
isOnline = navigator.onLine;
const updateOnlineStatus = () => {
isOnline = navigator.onLine;
};
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
};
});
function retry() {
if (isOnline) {
window.location.reload();
}
}
</script>
<svelte:head>
<title>Offline - uLoad</title>
<meta name="description" content="You are currently offline. Please check your connection." />
<meta name="robots" content="noindex" />
</svelte:head>
<div
class="flex min-h-screen items-center justify-center bg-gradient-to-br from-indigo-50 to-blue-100 p-4"
>
<div class="w-full max-w-md text-center">
<!-- Offline Icon -->
<div class="mb-8">
<div class="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gray-200">
<svg class="h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636l-12.728 12.728m0-12.728l12.728 12.728M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z"
/>
</svg>
</div>
</div>
<!-- Content -->
<h1 class="mb-4 text-3xl font-bold text-gray-900">You're offline</h1>
<p class="mb-8 text-gray-600">
It looks like you've lost your internet connection. Don't worry, you can still browse
previously visited pages that have been cached.
</p>
<!-- Status Indicator -->
<div class="mb-8">
<div class="flex items-center justify-center space-x-2">
<div class="h-3 w-3 rounded-full {isOnline ? 'bg-green-400' : 'bg-red-400'}"></div>
<span class="text-sm font-medium {isOnline ? 'text-green-600' : 'text-red-600'}">
{isOnline ? 'Back online!' : 'Offline'}
</span>
</div>
</div>
<!-- Actions -->
<div class="space-y-4">
<button
onclick={retry}
disabled={!isOnline}
class="w-full rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300"
>
{isOnline ? 'Retry' : 'Waiting for connection...'}
</button>
<a
href="/"
class="block w-full rounded-lg bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-200"
>
Go to Homepage
</a>
</div>
<!-- Tips -->
<div class="mt-8 text-left">
<h3 class="mb-3 font-semibold text-gray-900">While you're offline, you can:</h3>
<ul class="space-y-2 text-sm text-gray-600">
<li class="flex items-start space-x-2">
<span class="mt-1 text-blue-500"></span>
<span>Browse previously visited pages</span>
</li>
<li class="flex items-start space-x-2">
<span class="mt-1 text-blue-500"></span>
<span>View cached link analytics</span>
</li>
<li class="flex items-start space-x-2">
<span class="mt-1 text-blue-500"></span>
<span>Check your profile information</span>
</li>
</ul>
</div>
<!-- Footer -->
<div class="mt-8 border-t border-gray-200 pt-6">
<p class="text-xs text-gray-500">uLoad works best with a stable internet connection</p>
</div>
</div>
</div>
<style>
/* Custom animations for offline state */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.offline-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>

View file

@ -0,0 +1,108 @@
import { error } from '@sveltejs/kit';
import { RESERVED_USERNAMES } from '$lib/username';
import type { PageServerLoad } from './$types';
import { users, links, clicks } from '$lib/db/schema';
import { eq, and, desc, count, gt } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, locals }) => {
const { username } = params;
// Check if it's a reserved username (route to 404)
if (RESERVED_USERNAMES.includes(username.toLowerCase())) {
error(404, 'Page not found');
}
try {
// Find user by username
const [user] = await locals.db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (!user) {
error(404, 'User not found');
}
// Get all active links for this user with click counts
const userLinks = await locals.db
.select({
id: links.id,
shortCode: links.shortCode,
customCode: links.customCode,
originalUrl: links.originalUrl,
title: links.title,
description: links.description,
isActive: links.isActive,
expiresAt: links.expiresAt,
clickCount: links.clickCount,
qrCodeUrl: links.qrCodeUrl,
tags: links.tags,
createdAt: links.createdAt,
})
.from(links)
.where(and(eq(links.userId, user.id), eq(links.isActive, true)))
.orderBy(desc(links.createdAt))
.limit(100);
// Get actual click counts from clicks table for more accuracy
const linksWithStats = await Promise.all(
userLinks.map(async (link) => {
const [clickResult] = await locals.db
.select({ count: count() })
.from(clicks)
.where(eq(clicks.linkId, link.id));
return {
...link,
clicks: clickResult?.count || 0,
};
})
);
// Filter out expired links
const now = new Date();
const activeLinks = linksWithStats.filter((link) => {
if (link.expiresAt && new Date(link.expiresAt) < now) {
return false;
}
return true;
});
// All links go to the main list (no folders)
const linksByFolder = new Map<string | null, typeof activeLinks>();
linksByFolder.set(null, activeLinks);
// TODO: Load cards when cards table is added to schema
// For now, return empty cards array
const cards: any[] = [];
return {
profileUser: {
id: user.id,
username: user.username,
name: user.name,
avatar: null, // File handling will be added with R2
avatarUrl: user.avatarUrl,
bio: user.bio,
location: user.location,
website: user.website,
github: user.github,
twitter: user.twitter,
linkedin: user.linkedin,
instagram: user.instagram,
showClickStats: user.showClickStats,
profileBackground: user.profileBackground || '#f9fafb',
created: user.createdAt,
},
folders: [],
linksByFolder,
links: activeLinks,
totalClicks: activeLinks.reduce((sum, link) => sum + link.clicks, 0),
cards: cards,
};
} catch (err) {
console.error('[PROFILE] Error loading profile:', err);
error(404, 'User not found');
}
};

View file

@ -0,0 +1,277 @@
<script lang="ts">
import type { PageData } from './$types';
import { page } from '$app/stores';
const { data }: { data: PageData } = $props();
const baseUrl = 'https://ulo.ad';
// Simple card renderer without complex dependencies
function renderSimpleCard(card: any) {
if (!card?.config) return null;
// Handle different card modes
if (card.config.mode === 'beginner' && card.config.modules) {
// Simple module-based rendering
return card.config.modules;
} else if (card.config.mode === 'advanced') {
// Template-based card
return {
template: card.config.template,
values: card.config.values,
};
} else if (card.config.mode === 'expert') {
// Custom HTML card
return {
html: card.config.html,
css: card.config.css,
};
}
return null;
}
</script>
<svelte:head>
<title>{data.profileUser.name || data.profileUser.username} | Uload</title>
<meta
name="description"
content={data.profileUser.bio ||
`Check out ${data.profileUser.name || data.profileUser.username}'s links on Uload`}
/>
</svelte:head>
<div
class="min-h-screen transition-colors duration-300"
style="background: {data.profileUser.profileBackground || '#f9fafb'};
{data.profileUser.profileBackground && !data.profileUser.profileBackground.startsWith('#f')
? 'background: linear-gradient(135deg, ' +
data.profileUser.profileBackground +
' 0%, ' +
data.profileUser.profileBackground +
'dd 100%);'
: ''}"
>
<div class="mx-auto max-w-2xl px-4 py-8 sm:py-12">
<!-- Profile Header Card -->
<div class="mb-6 rounded-2xl bg-white p-6 shadow-sm sm:p-8">
<div class="text-center">
<!-- Avatar -->
{#if data.profileUser.avatarUrl}
<img
src={data.profileUser.avatarUrl}
alt={data.profileUser.name || data.profileUser.username}
class="mx-auto mb-5 h-28 w-28 rounded-full object-cover shadow-lg sm:h-36 sm:w-36"
/>
{:else}
<div
class="mx-auto mb-5 flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg sm:h-36 sm:w-36"
>
<span class="text-4xl font-bold text-white sm:text-5xl">
{(data.profileUser.name || data.profileUser.username).charAt(0).toUpperCase()}
</span>
</div>
{/if}
<!-- Name and Username -->
<h1 class="mb-2 text-3xl font-bold text-gray-900 sm:text-4xl">
{data.profileUser.name || data.profileUser.username}
</h1>
<p class="mb-4 text-lg text-gray-500">@{data.profileUser.username}</p>
<!-- Bio -->
{#if data.profileUser.bio}
<p class="mx-auto mb-6 max-w-md text-lg text-gray-700">{data.profileUser.bio}</p>
{/if}
<!-- Social Links - Larger and more prominent -->
<div class="flex flex-wrap justify-center gap-3">
{#if data.profileUser.website}
<a
href={data.profileUser.website}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-gray-100 px-5 py-2.5 transition-all hover:scale-105 hover:bg-gray-200"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM2 10a8 8 0 1116 0 8 8 0 01-16 0z" />
<path d="M7.5 7.5A.5.5 0 018 7h4a.5.5 0 010 1H8.5v3.5a.5.5 0 01-1 0v-4z" />
</svg>
<span class="font-medium">Website</span>
</a>
{/if}
{#if data.profileUser.twitter}
<a
href={`https://twitter.com/${data.profileUser.twitter}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-sky-100 px-5 py-2.5 transition-all hover:scale-105 hover:bg-sky-200"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
/>
</svg>
<span class="font-medium">Twitter</span>
</a>
{/if}
{#if data.profileUser.github}
<a
href={`https://github.com/${data.profileUser.github}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-gray-900 px-5 py-2.5 text-white transition-all hover:scale-105 hover:bg-gray-800"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
/>
</svg>
<span class="font-medium">GitHub</span>
</a>
{/if}
{#if data.profileUser.linkedin}
<a
href={`https://linkedin.com/in/${data.profileUser.linkedin}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-blue-100 px-5 py-2.5 transition-all hover:scale-105 hover:bg-blue-200"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</svg>
<span class="font-medium">LinkedIn</span>
</a>
{/if}
{#if data.profileUser.instagram}
<a
href={`https://instagram.com/${data.profileUser.instagram}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-gradient-to-r from-purple-500 to-pink-500 px-5 py-2.5 text-white transition-all hover:scale-105 hover:from-purple-600 hover:to-pink-600"
>
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zM5.838 12a6.162 6.162 0 1112.324 0 6.162 6.162 0 01-12.324 0zM12 16a4 4 0 110-8 4 4 0 010 8zm4.965-10.405a1.44 1.44 0 112.881.001 1.44 1.44 0 01-2.881-.001z"
/>
</svg>
<span class="font-medium">Instagram</span>
</a>
{/if}
</div>
</div>
</div>
<!-- Main Content Section -->
{#if data.cards && data.cards.length > 0}
<!-- User Cards Section (shown when cards exist) -->
<div class="mb-6">
<h2 class="mb-4 text-xl font-semibold text-gray-900">Featured Cards</h2>
<div class="grid gap-4 sm:grid-cols-2">
{#each data.cards as card}
<div
class="overflow-hidden rounded-2xl bg-white shadow-sm transition-all duration-200 hover:shadow-md"
>
{#if card.config?.mode === 'beginner' && card.config.modules}
<!-- Simple module-based card -->
<div class="p-5">
{#each card.config.modules as module}
{#if module.type === 'header'}
<div class="mb-4">
{#if module.props?.title}
<h3 class="text-xl font-semibold text-gray-900">{module.props.title}</h3>
{/if}
{#if module.props?.subtitle}
<p class="mt-1 text-base text-gray-600">{module.props.subtitle}</p>
{/if}
</div>
{:else if module.type === 'content'}
<div class="mb-4">
<p class="text-base leading-relaxed text-gray-700">
{module.props?.text || module.props?.html || ''}
</p>
</div>
{:else if module.type === 'media' && module.props?.type === 'image'}
<div class="-mx-5 -mt-5 mb-4">
<img
src={module.props.src}
alt={module.props.alt || ''}
class="h-auto w-full"
/>
</div>
{:else if module.type === 'links' && module.props?.links}
<div class="mt-4 flex flex-wrap gap-3">
{#each module.props.links as link}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="inline-flex transform items-center rounded-full bg-indigo-600 px-5 py-2.5 font-medium text-white transition-all hover:scale-105 hover:bg-indigo-700"
>
{link.label}
</a>
{/each}
</div>
{/if}
{/each}
</div>
{:else if card.config?.mode === 'advanced'}
<!-- Template-based card -->
<div class="p-5">
<div class="text-gray-700">
{#if card.config.template}
<!-- Simple template display without variable replacement for now -->
<p class="mb-2 text-sm uppercase tracking-wide text-gray-500">
Template Card
</p>
{#if card.metadata?.name}
<h3 class="mb-2 text-xl font-semibold text-gray-900">
{card.metadata.name}
</h3>
{/if}
{#if card.metadata?.description}
<p class="text-base text-gray-600">{card.metadata.description}</p>
{/if}
{/if}
</div>
</div>
{:else if card.config?.mode === 'expert'}
<!-- Custom HTML card -->
<div class="p-5">
<div class="text-gray-700">
<p class="mb-2 text-sm uppercase tracking-wide text-gray-500">Custom Card</p>
{#if card.metadata?.name}
<h3 class="mb-2 text-xl font-semibold text-gray-900">{card.metadata.name}</h3>
{/if}
{#if card.metadata?.description}
<p class="text-base text-gray-600">{card.metadata.description}</p>
{/if}
</div>
</div>
{:else}
<!-- Fallback for unknown card types -->
<div class="p-5">
<p class="text-base text-gray-500">Card content unavailable</p>
</div>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<!-- No cards - show basic info message -->
<div class="rounded-xl bg-white p-8 shadow-sm">
<p class="text-center text-gray-500">
Welcome to {data.profileUser.name || data.profileUser.username}'s profile.
</p>
<p class="mt-2 text-center text-sm text-gray-400">No content available yet.</p>
</div>
{/if}
<!-- Footer -->
<div class="mt-12 text-center text-sm text-gray-500">
<a href="/" class="transition-colors hover:text-gray-700"> Powered by Uload </a>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import CardRenderer from '$lib/components/cards/CardRenderer.svelte';
import type { Card } from '$lib/components/cards/types';
let card = $state<Card | null>(null);
onMount(() => {
if (browser) {
const cardData = sessionStorage.getItem('previewCard');
if (cardData) {
try {
card = JSON.parse(cardData);
} catch (error) {
console.error('Error parsing preview card:', error);
}
}
}
});
</script>
<svelte:head>
<title>Card Preview - uload</title>
</svelte:head>
<div class="min-h-screen bg-gray-100 p-8">
<div class="mx-auto max-w-2xl">
<div class="mb-6 text-center">
<h1 class="text-2xl font-bold text-gray-900">Card Preview</h1>
<p class="mt-2 text-gray-600">This is how your card will look</p>
</div>
{#if card}
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<CardRenderer {card} readonly={true} />
</div>
{:else}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<p class="text-gray-600">No card data found for preview</p>
<button
onclick={() => window.close()}
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Close Preview
</button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,54 @@
import { getCollection } from '$lib/content';
import type { BlogPostWithMeta } from '../../content/config';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const posts = await getCollection<BlogPostWithMeta>('blog');
const site = 'https://ulo.ad';
// Static pages
const staticPages = [
'',
'/features',
'/pricing',
'/about',
'/blog',
'/datenschutz',
'/impressum',
'/agb',
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticPages
.map(
(page) => `
<url>
<loc>${site}${page}</loc>
<changefreq>weekly</changefreq>
<priority>${page === '' ? '1.0' : '0.8'}</priority>
</url>`
)
.join('')}
${posts
.map(
(post) => `
<url>
<loc>${site}/blog/${post.slug}</loc>
<lastmod>${new Date(post.date).toISOString()}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>`
)
.join('')}
</urlset>`;
return new Response(xml.trim(), {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=0, s-maxage=3600',
},
});
};
export const prerender = false;

View file

@ -0,0 +1,72 @@
import { redirect, error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, locals }) => {
const token = url.searchParams.get('token');
if (!token) {
return {
result: {
success: false,
error: 'Invalid invitation link - no token provided',
},
};
}
if (!locals.user) {
redirect(303, `/login?redirect=/team/accept-invite?token=${token}`);
}
try {
// Find the invitation by token
const invitation = await locals.pb
.collection('shared_access')
.getFirstListItem(`invitation_token="${token}" && invitation_status="pending"`, {
expand: 'owner',
});
// Check if the invitation is for this user
if (invitation.user !== locals.user.id) {
return {
result: {
success: false,
error: 'This invitation is not for your account',
},
};
}
// Accept the invitation
await locals.pb.collection('shared_access').update(invitation.id, {
invitation_status: 'accepted',
accepted_at: new Date().toISOString(),
});
// Notification email will be sent automatically by PocketBase hook
return {
result: {
success: true,
message: 'Invitation accepted successfully',
},
};
} catch (err: any) {
console.error('Error accepting invitation:', err);
// Check if invitation not found
if (err?.status === 404) {
return {
result: {
success: false,
error: 'Invitation not found or already used',
},
};
}
return {
result: {
success: false,
error: 'Failed to accept invitation. Please try again.',
},
};
}
};

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { PageData } from './$types';
export let data: PageData;
let loading = true;
let error = '';
let success = false;
onMount(async () => {
const token = $page.url.searchParams.get('token');
if (!token) {
error = 'Invalid invitation link';
loading = false;
return;
}
if (data.result?.success) {
success = true;
loading = false;
setTimeout(() => {
goto('/dashboard');
}, 3000);
} else if (data.result?.error) {
error = data.result.error;
loading = false;
}
});
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="mx-auto w-full max-w-md p-6">
<div class="rounded-2xl bg-white p-8 shadow-xl">
<!-- Logo -->
<div class="mb-8 text-center">
<h1 class="text-4xl font-bold text-sky-500">ulo.ad</h1>
</div>
{#if loading}
<div class="text-center">
<div class="mb-4 inline-flex h-16 w-16 items-center justify-center">
<div class="h-12 w-12 animate-spin rounded-full border-b-2 border-sky-500"></div>
</div>
<p class="text-gray-600">Processing invitation...</p>
</div>
{:else if success}
<div class="text-center">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-green-100"
>
<svg
class="h-8 w-8 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h2 class="mb-2 text-2xl font-semibold text-gray-800">Invitation Accepted!</h2>
<p class="mb-4 text-gray-600">You've successfully joined the team.</p>
<p class="text-sm text-gray-500">Redirecting to dashboard...</p>
</div>
{:else if error}
<div class="text-center">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-100"
>
<svg class="h-8 w-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<h2 class="mb-2 text-2xl font-semibold text-gray-800">Invitation Error</h2>
<p class="mb-6 text-red-600">{error}</p>
<div class="space-y-3">
<a
href="/login"
class="block w-full rounded-lg bg-sky-500 px-4 py-2 text-center text-white transition-colors hover:bg-sky-600"
>
Go to Login
</a>
<a
href="/"
class="block w-full rounded-lg bg-gray-200 px-4 py-2 text-center text-gray-700 transition-colors hover:bg-gray-300"
>
Back to Home
</a>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,192 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
// Dynamischer Import von ioredis (falls nicht installiert)
let Redis;
try {
Redis = (await import('ioredis')).default;
} catch (importError) {
return json(
{
success: false,
error: 'ioredis package not installed. Run: npm install ioredis',
message: 'Redis dependency missing',
},
{ status: 500 }
);
}
const connectionTests = [];
// Environment Variables checken
const envRedisUrl = process.env.REDIS_URL;
const envRedisHost = process.env.REDIS_HOST || 'redis-database-ycsoowwsc84s0s8gc8oooosk';
const envRedisPassword = process.env.REDIS_PASSWORD;
const envRedisUsername = process.env.REDIS_USERNAME || 'default';
const hosts = [
'localhost',
'127.0.0.1',
'redis',
'redis-database-ycsoowwsc84s0s8gc8oooosk',
envRedisHost,
];
// Erst probieren ob REDIS_URL gesetzt ist
if (envRedisUrl) {
try {
const redis = new Redis(envRedisUrl, {
connectTimeout: 5000,
lazyConnect: true,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 1,
});
await redis.connect();
await redis.ping();
await redis.set('test-key', 'Hello from uLoad via REDIS_URL!');
const value = await redis.get('test-key');
const info = await redis.info('server');
await redis.del('test-key');
redis.disconnect();
return json({
success: true,
redis_value: value,
connection_method: 'REDIS_URL',
redis_url: envRedisUrl.replace(/:([^:@]*?)@/, ':***@'), // Password verstecken
redis_server_info: info
.split('\n')
.slice(0, 5)
.filter((line) => line.trim()),
message: 'Redis connection working via REDIS_URL!',
timestamp: new Date().toISOString(),
});
} catch (error) {
connectionTests.push({
method: 'REDIS_URL',
url: envRedisUrl ? envRedisUrl.replace(/:([^:@]*?)@/, ':***@') : 'not set',
error: error.message,
error_type: error.constructor.name,
});
}
}
// Test verschiedene Hosts mit Authentication
for (const host of hosts) {
// Test ohne Auth
try {
const redis = new Redis({
host,
port: 6379,
connectTimeout: 5000,
lazyConnect: true,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 1,
});
await redis.connect();
await redis.ping();
await redis.set('test-key', `Hello from uLoad via ${host}!`);
const value = await redis.get('test-key');
const info = await redis.info('server');
await redis.del('test-key');
redis.disconnect();
return json({
success: true,
redis_value: value,
working_host: host,
connection_method: 'no_auth',
redis_server_info: info
.split('\n')
.slice(0, 5)
.filter((line) => line.trim()),
message: `Redis connection working via ${host} (no auth)!`,
timestamp: new Date().toISOString(),
});
} catch (error) {
connectionTests.push({
host,
method: 'no_auth',
error: error.message,
error_type: error.constructor.name,
});
}
// Test mit Auth (falls Environment Variables gesetzt)
if (envRedisPassword) {
try {
const redis = new Redis({
host,
port: 6379,
username: envRedisUsername,
password: envRedisPassword,
connectTimeout: 5000,
lazyConnect: true,
retryDelayOnFailover: 100,
maxRetriesPerRequest: 1,
});
await redis.connect();
await redis.ping();
await redis.set('test-key', `Hello from uLoad via ${host} with auth!`);
const value = await redis.get('test-key');
const info = await redis.info('server');
await redis.del('test-key');
redis.disconnect();
return json({
success: true,
redis_value: value,
working_host: host,
connection_method: 'with_auth',
username: envRedisUsername,
redis_server_info: info
.split('\n')
.slice(0, 5)
.filter((line) => line.trim()),
message: `Redis connection working via ${host} with auth!`,
timestamp: new Date().toISOString(),
});
} catch (error) {
connectionTests.push({
host,
method: 'with_auth',
username: envRedisUsername,
error: error.message,
error_type: error.constructor.name,
});
}
}
}
// Kein Host funktioniert
return json(
{
success: false,
message: 'Redis connection failed on all hosts and methods',
connection_tests: connectionTests,
environment: {
NODE_ENV: process.env.NODE_ENV,
REDIS_URL: envRedisUrl ? 'set' : 'not set',
REDIS_HOST: envRedisHost,
REDIS_USERNAME: envRedisUsername,
REDIS_PASSWORD: envRedisPassword ? 'set' : 'not set',
container_info: 'Running in container',
redis_logs_hint: 'Check Redis logs for bind address and auth requirements',
},
next_steps: [
'1. Add REDIS_URL environment variable to your main app',
'2. Format: redis://username:password@host:port',
'3. Use the exact Redis service name from Coolify',
'4. Copy credentials from Redis service configuration',
],
},
{ status: 500 }
);
};

View file

@ -0,0 +1,39 @@
import { error } from '@sveltejs/kit';
import { pb, type User, type Link } from '$lib/pocketbase';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const { username } = params;
try {
// Find user by username
const user = await pb.collection('users').getFirstListItem<User>(`username="${username}"`);
// Get user's public links
const links = await pb.collection('links').getList<Link>(1, 50, {
filter: `user_id="${user.id}" && is_active=true`,
sort: '-created',
});
return {
user: {
id: user.id,
username: user.username,
name: user.name,
bio: user.bio,
avatar: user.avatar,
avatarUrl: user.avatar ? pb.getFileUrl(user, user.avatar) : null,
created: user.created,
},
links: links.items.map((link) => ({
id: link.id,
title: link.title,
short_code: link.short_code,
clicks: link.clicks || 0,
created: link.created,
})),
};
} catch (err) {
error(404, 'User not found');
}
};

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { PageData } from './$types';
const { data }: { data: PageData } = $props();
</script>
<div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Profile Header -->
<div class="mb-6 rounded-lg bg-white p-6 shadow-sm">
<div class="flex items-center space-x-4">
{#if data.user.avatarUrl}
<img
src={data.user.avatarUrl}
alt={data.user.name || data.user.username}
class="h-20 w-20 rounded-full object-cover"
/>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-indigo-100">
<span class="text-2xl font-semibold text-indigo-600">
{(data.user.name || data.user.username).charAt(0).toUpperCase()}
</span>
</div>
{/if}
<div>
<h1 class="text-2xl font-bold text-gray-900">
{data.user.name || data.user.username}
</h1>
<p class="text-gray-500">@{data.user.username}</p>
{#if data.user.bio}
<p class="mt-2 text-gray-700">{data.user.bio}</p>
{/if}
</div>
</div>
</div>
<!-- Links Section -->
<div class="rounded-lg bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Public Links</h2>
{#if data.links.length > 0}
<div class="space-y-3">
{#each data.links as link}
<a
href="/u/{data.user.username}/{link.short_code}"
class="block rounded-lg bg-gray-50 p-4 transition-colors hover:bg-gray-100"
>
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900">
{link.title || `Link ${link.short_code}`}
</h3>
<p class="mt-1 text-sm text-gray-500">
uload.de/u/{data.user.username}/{link.short_code}
</p>
</div>
<div class="text-sm text-gray-500">
{link.clicks} clicks
</div>
</div>
</a>
{/each}
</div>
{:else}
<p class="py-8 text-center text-gray-500">No public links available</p>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,163 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { workspaces, links, clicks } from '$lib/db/schema';
import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, locals, url, request }) => {
const workspaceSlug = params.workspace;
const shortCode = params.code;
// Reconstruct the full short code with workspace prefix
const fullShortCode = `w/${workspaceSlug}/${shortCode}`;
console.log('[W_ROUTE] Workspace slug:', workspaceSlug);
console.log('[W_ROUTE] Short code:', shortCode);
console.log('[W_ROUTE] Full short code:', fullShortCode);
try {
// First, verify the workspace exists
const [workspace] = await locals.db
.select()
.from(workspaces)
.where(eq(workspaces.slug, workspaceSlug))
.limit(1);
if (!workspace) {
console.log('[W_ROUTE] Workspace not found:', workspaceSlug);
throw error(404, 'Workspace not found');
}
console.log('[W_ROUTE] Found workspace:', workspace.id, workspace.name);
// Find the link by short code
const [link] = await locals.db
.select()
.from(links)
.where(and(eq(links.shortCode, fullShortCode), eq(links.workspaceId, workspace.id)))
.limit(1);
if (!link) {
console.log('[W_ROUTE] Link not found with short code:', fullShortCode);
throw error(404, 'Link not found');
}
console.log('[W_ROUTE] Found link:', link.id, link.originalUrl);
// Check if link is active
if (!link.isActive) {
console.log('[W_ROUTE] Link is inactive');
throw error(404, 'Link is inactive');
}
// Check expiration
if (link.expiresAt) {
const expiresAt = new Date(link.expiresAt);
if (expiresAt < new Date()) {
console.log('[W_ROUTE] Link has expired');
throw error(410, 'Link has expired');
}
}
// Check max clicks
if (link.maxClicks && link.clickCount && link.clickCount >= link.maxClicks) {
console.log('[W_ROUTE] Link has reached max clicks');
throw error(410, 'Link has reached maximum clicks');
}
// Check password protection
if (link.password) {
const passwordParam = url.searchParams.get('pwd');
if (passwordParam !== link.password) {
console.log('[W_ROUTE] Link requires password');
// Return link data to show password prompt
return {
link: {
id: link.id,
title: link.title,
shortCode: link.shortCode,
},
requiresPassword: true,
workspace: {
id: workspace.id,
name: workspace.name,
slug: workspace.slug,
},
};
}
}
// Record the click and increment count in a transaction
try {
const userAgent = request.headers.get('user-agent') || 'Unknown';
const referer = request.headers.get('referer') || null;
const ip =
request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'Unknown';
// Parse user agent for browser/os/device
const browser = userAgent.includes('Chrome')
? 'Chrome'
: userAgent.includes('Firefox')
? 'Firefox'
: userAgent.includes('Safari')
? 'Safari'
: 'Other';
const os = userAgent.includes('Windows')
? 'Windows'
: userAgent.includes('Mac')
? 'macOS'
: userAgent.includes('Linux')
? 'Linux'
: userAgent.includes('Android')
? 'Android'
: userAgent.includes('iOS')
? 'iOS'
: 'Other';
const deviceType = userAgent.includes('Mobile') ? 'Mobile' : 'Desktop';
// Use transaction for atomic click recording
await locals.db.transaction(async (tx) => {
// Insert click record
await tx.insert(clicks).values({
linkId: link.id,
ipHash: ip, // Note: Should hash this in production
userAgent: userAgent,
referer: referer,
browser: browser,
os: os,
deviceType: deviceType,
clickedAt: new Date(),
});
// Increment click count
await tx
.update(links)
.set({
clickCount: sql`${links.clickCount} + 1`,
updatedAt: new Date(),
})
.where(eq(links.id, link.id));
});
console.log('[W_ROUTE] Click recorded successfully');
} catch (err) {
console.error('[W_ROUTE] Failed to record click:', err);
// Don't fail the redirect if click recording fails
}
// Redirect to the original URL
console.log('[W_ROUTE] Redirecting to:', link.originalUrl);
throw redirect(302, link.originalUrl);
} catch (err: any) {
console.error('[W_ROUTE] Error:', err);
// Re-throw SvelteKit errors
if (err?.status) {
throw err;
}
// Generic error
throw error(500, 'An error occurred');
}
};

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { page } from '$app/stores';
import { enhance } from '$app/forms';
interface Props {
data: {
link?: any;
requiresPassword?: boolean;
workspace?: any;
};
}
let { data }: Props = $props();
let password = $state('');
let error = $state(false);
function handlePasswordSubmit() {
const url = new URL($page.url);
url.searchParams.set('pwd', password);
window.location.href = url.toString();
}
</script>
{#if data.requiresPassword && data.link}
<div
class="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800"
>
<div class="mx-4 w-full max-w-md">
<div class="rounded-2xl bg-white p-8 shadow-xl dark:bg-gray-800">
<div class="mb-8 text-center">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900"
>
<svg
class="h-8 w-8 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
</div>
<h1 class="mb-2 text-2xl font-bold text-gray-900 dark:text-white">
Passwortgeschützter Link
</h1>
{#if data.workspace}
<p class="text-sm text-gray-600 dark:text-gray-400">
Workspace: {data.workspace.name}
</p>
{/if}
{#if data.link.title}
<p class="mt-2 text-gray-600 dark:text-gray-400">
{data.link.title}
</p>
{/if}
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handlePasswordSubmit();
}}
class="space-y-4"
>
<div>
<label
for="password"
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Passwort eingeben
</label>
<input
type="password"
id="password"
bind:value={password}
required
autofocus
class="w-full rounded-lg border px-4 py-3 {error
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'} bg-white text-gray-900 transition-colors focus:border-transparent focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="••••••••"
/>
{#if error}
<p class="mt-2 text-sm text-red-600 dark:text-red-400">
Falsches Passwort. Bitte versuchen Sie es erneut.
</p>
{/if}
</div>
<button
type="submit"
class="w-full transform rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 px-4 py-3 font-semibold text-white transition-all duration-200 hover:scale-[1.02] hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Link öffnen →
</button>
</form>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-gray-700">
<p class="text-center text-xs text-gray-500 dark:text-gray-400">
Dieser Link ist passwortgeschützt. Geben Sie das korrekte Passwort ein, um fortzufahren.
</p>
</div>
</div>
</div>
</div>
{:else}
<!-- This shouldn't show as we redirect, but just in case -->
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p class="mt-4 text-gray-600 dark:text-gray-400">Weiterleitung...</p>
</div>
</div>
{/if}