mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 17:54:39 +02:00
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:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
147
apps-archived/uload/apps/web/src/routes/(app)/+layout.svelte
Normal file
147
apps-archived/uload/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
<!-- This page redirects to /my/links via +page.server.ts -->
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 />
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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()}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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...',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
};
|
||||
69
apps-archived/uload/apps/web/src/routes/+layout.svelte
Normal file
69
apps-archived/uload/apps/web/src/routes/+layout.svelte
Normal 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 />
|
||||
172
apps-archived/uload/apps/web/src/routes/+page.server.ts
Normal file
172
apps-archived/uload/apps/web/src/routes/+page.server.ts
Normal 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;
|
||||
612
apps-archived/uload/apps/web/src/routes/+page.svelte
Normal file
612
apps-archived/uload/apps/web/src/routes/+page.svelte
Normal 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 />
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
};
|
||||
88
apps-archived/uload/apps/web/src/routes/api/vote/+server.ts
Normal file
88
apps-archived/uload/apps/web/src/routes/api/vote/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
39
apps-archived/uload/apps/web/src/routes/health/+server.ts
Normal file
39
apps-archived/uload/apps/web/src/routes/health/+server.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
130
apps-archived/uload/apps/web/src/routes/offline/+page.svelte
Normal file
130
apps-archived/uload/apps/web/src/routes/offline/+page.svelte
Normal 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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
13
apps-archived/uload/apps/web/src/routes/page.svelte.spec.ts
Normal file
13
apps-archived/uload/apps/web/src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps-archived/uload/apps/web/src/routes/preview/+page.svelte
Normal file
50
apps-archived/uload/apps/web/src/routes/preview/+page.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
192
apps-archived/uload/apps/web/src/routes/test-redis/+server.ts
Normal file
192
apps-archived/uload/apps/web/src/routes/test-redis/+server.ts
Normal 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 }
|
||||
);
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue