refactor(uload): clean up migration, add Stripe/Email stubs, fix 497 type errors

- Delete non-MVP pages (cards, templates, teams, workspaces, public profiles)
- Delete old PocketBase components, stores, services, utils
- Simplify settings, pricing, analytics pages
- Rewrite reset-password, verify-email pages
- Add Stripe checkout/webhook and email stub routes to Hono server
- Add uload to shared-branding (app icon, mana-apps registry)
- Simplify svelte.config.js, vite.config.ts, theme store
- 501 type errors → 4 (vite.config Tailwind v4 compat only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 14:14:17 +02:00
parent 82a4cb4c59
commit 3686926a8e
184 changed files with 530 additions and 38347 deletions

View file

@ -9,6 +9,8 @@ import { AnalyticsService } from './services/analytics';
import { healthRoutes } from './routes/health';
import { createRedirectRoutes } from './routes/redirect';
import { createAnalyticsRoutes } from './routes/analytics';
import { createStripeRoutes } from './routes/stripe';
import { createEmailRoutes } from './routes/email';
const config = loadConfig();
const db = getDb(config.databaseUrl);
@ -30,6 +32,8 @@ app.route('/r', createRedirectRoutes(redirectService));
// Analytics API (auth required)
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService));
app.route('/api/v1/stripe', createStripeRoutes());
app.route('/api/v1/email', createEmailRoutes());
console.log(`uload-server starting on port ${config.port}...`);

View file

@ -0,0 +1,9 @@
import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
export function createEmailRoutes() {
return new Hono<{ Variables: { user: AuthUser } }>().post('/send-invitation', async (c) => {
// TODO: Implement with Resend
return c.json({ error: 'Email not configured yet' }, 501);
});
}

View file

@ -0,0 +1,14 @@
import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
export function createStripeRoutes() {
return new Hono<{ Variables: { user: AuthUser } }>()
.post('/checkout', async (c) => {
// TODO: Implement Stripe checkout session creation
return c.json({ error: 'Stripe not configured yet' }, 501);
})
.post('/webhook', async (c) => {
// TODO: Implement Stripe webhook handling
return c.json({ received: true });
});
}

View file

@ -1,14 +0,0 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url:
process.env.DATABASE_URL ||
'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev',
},
verbose: true,
strict: true,
} satisfies Config;

View file

@ -1,227 +0,0 @@
CREATE TABLE "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"owner" uuid NOT NULL,
"is_active" boolean DEFAULT true,
"plan_type" text DEFAULT 'free',
"settings" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "clicks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"link_id" uuid NOT NULL,
"ip_hash" text,
"user_agent" text,
"referer" text,
"browser" text,
"device_type" text,
"os" text,
"country" text,
"city" text,
"clicked_at" timestamp DEFAULT now() NOT NULL,
"utm_source" text,
"utm_medium" text,
"utm_campaign" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feature_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"user_id" uuid NOT NULL,
"status" text DEFAULT 'pending',
"vote_count" integer DEFAULT 0,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feature_votes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"feature_request_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "folders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "link_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"link_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"short_code" text NOT NULL,
"custom_code" text,
"original_url" text NOT NULL,
"title" text,
"description" text,
"user_id" uuid,
"is_active" boolean DEFAULT true,
"password" text,
"max_clicks" integer,
"expires_at" timestamp,
"click_count" integer DEFAULT 0,
"qr_code_url" text,
"tags" jsonb,
"utm_source" text,
"utm_medium" text,
"utm_campaign" text,
"account_owner" uuid,
"workspace_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "links_short_code_unique" UNIQUE("short_code")
);
--> statement-breakpoint
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"message" text NOT NULL,
"data" jsonb,
"read" boolean DEFAULT false,
"action_url" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pending_invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"token" text NOT NULL,
"owner" uuid NOT NULL,
"expires_at" timestamp NOT NULL,
"accepted_at" timestamp,
"accepted_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "pending_invitations_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "shared_access" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"owner" uuid NOT NULL,
"user_id" uuid,
"permissions" jsonb,
"invitation_status" text DEFAULT 'pending',
"accepted_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"color" text,
"icon" text,
"is_public" boolean DEFAULT false,
"usage_count" integer DEFAULT 0,
"user_id" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"external_auth_id" text,
"email" text NOT NULL,
"username" text NOT NULL,
"name" text,
"avatar_url" text,
"bio" text,
"location" text,
"website" text,
"github" text,
"twitter" text,
"linkedin" text,
"instagram" text,
"public_profile" boolean DEFAULT false,
"show_click_stats" boolean DEFAULT true,
"email_notifications" boolean DEFAULT true,
"default_expiry" integer,
"profile_background" text,
"verified" boolean DEFAULT false,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_external_auth_id_unique" UNIQUE("external_auth_id"),
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"type" text NOT NULL,
"owner" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "workspaces_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "clicks" ADD CONSTRAINT "clicks_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feature_requests" ADD CONSTRAINT "feature_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_feature_request_id_feature_requests_id_fk" FOREIGN KEY ("feature_request_id") REFERENCES "public"."feature_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "links" ADD CONSTRAINT "links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "links" ADD CONSTRAINT "links_account_owner_accounts_id_fk" FOREIGN KEY ("account_owner") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "links" ADD CONSTRAINT "links_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_accepted_by_users_id_fk" FOREIGN KEY ("accepted_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "accounts_owner_idx" ON "accounts" USING btree ("owner");--> statement-breakpoint
CREATE INDEX "clicks_link_id_idx" ON "clicks" USING btree ("link_id");--> statement-breakpoint
CREATE INDEX "clicks_clicked_at_idx" ON "clicks" USING btree ("clicked_at");--> statement-breakpoint
CREATE INDEX "clicks_country_idx" ON "clicks" USING btree ("country");--> statement-breakpoint
CREATE INDEX "feature_requests_user_id_idx" ON "feature_requests" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "feature_requests_status_idx" ON "feature_requests" USING btree ("status");--> statement-breakpoint
CREATE INDEX "feature_requests_vote_count_idx" ON "feature_requests" USING btree ("vote_count");--> statement-breakpoint
CREATE INDEX "feature_votes_feature_request_id_idx" ON "feature_votes" USING btree ("feature_request_id");--> statement-breakpoint
CREATE INDEX "feature_votes_user_id_idx" ON "feature_votes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "feature_votes_unique_idx" ON "feature_votes" USING btree ("feature_request_id","user_id");--> statement-breakpoint
CREATE INDEX "folders_user_id_idx" ON "folders" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "link_tags_link_id_idx" ON "link_tags" USING btree ("link_id");--> statement-breakpoint
CREATE INDEX "link_tags_tag_id_idx" ON "link_tags" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "link_tags_unique_idx" ON "link_tags" USING btree ("link_id","tag_id");--> statement-breakpoint
CREATE INDEX "links_user_id_idx" ON "links" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "links_short_code_idx" ON "links" USING btree ("short_code");--> statement-breakpoint
CREATE INDEX "links_workspace_id_idx" ON "links" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "links_account_owner_idx" ON "links" USING btree ("account_owner");--> statement-breakpoint
CREATE INDEX "links_is_active_idx" ON "links" USING btree ("is_active");--> statement-breakpoint
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "notifications_read_idx" ON "notifications" USING btree ("read");--> statement-breakpoint
CREATE INDEX "pending_invitations_email_idx" ON "pending_invitations" USING btree ("email");--> statement-breakpoint
CREATE INDEX "pending_invitations_token_idx" ON "pending_invitations" USING btree ("token");--> statement-breakpoint
CREATE INDEX "pending_invitations_owner_idx" ON "pending_invitations" USING btree ("owner");--> statement-breakpoint
CREATE INDEX "shared_access_owner_idx" ON "shared_access" USING btree ("owner");--> statement-breakpoint
CREATE INDEX "shared_access_user_id_idx" ON "shared_access" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "shared_access_status_idx" ON "shared_access" USING btree ("invitation_status");--> statement-breakpoint
CREATE INDEX "tags_user_id_idx" ON "tags" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
CREATE INDEX "users_external_auth_id_idx" ON "users" USING btree ("external_auth_id");--> statement-breakpoint
CREATE INDEX "workspaces_slug_idx" ON "workspaces" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner");

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1763571183375,
"tag": "0000_material_puma",
"breakpoints": true
}
]
}

View file

@ -1,6 +0,0 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

View file

@ -28,6 +28,7 @@
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/forms": "^0.5.8",
"@tailwindcss/typography": "^0.5.16",
"@manacore/shared-vite-config": "workspace:*",
"@tailwindcss/vite": "^4.1.11",
"@types/eslint__js": "^8.42.3",
"@types/node": "^24.3.0",

View file

@ -1,9 +0,0 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
},
testDir: 'e2e',
});

View file

@ -1,11 +0,0 @@
{
"id": "till-schneider",
"name": "Till Schneider",
"bio": "Gründer von uload und begeistert von der Psychologie hinter digitalem Marketing.",
"avatar": "/images/authors/till.jpg",
"social": {
"twitter": "https://twitter.com/tillschneider",
"linkedin": "https://linkedin.com/in/tillschneider",
"website": "https://ulo.ad"
}
}

View file

@ -1,157 +0,0 @@
---
title: Der ultimative Link-Tracking Guide für 2024
excerpt: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
date: 2024-01-20
author: till-schneider
category: tutorial
tags: [tracking, analytics, dsgvo, marketing]
featured: false
image: /blog/link-tracking.jpg
---
Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern.
## Was ist Link-Tracking?
Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
- Woher kommen Ihre Besucher?
- Welche Kampagnen funktionieren?
- Wie hoch ist Ihre Conversion-Rate?
- Welche Inhalte performen am besten?
## Die wichtigsten Metriken
### 1. Click-Through-Rate (CTR)
Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
### 2. Conversion Rate
Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen (Kauf, Anmeldung, Download).
### 3. Bounce Rate
Wie viele Nutzer verlassen Ihre Seite sofort wieder? Eine hohe Bounce Rate deutet auf Probleme hin.
### 4. Geographic Distribution
Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
## UTM-Parameter richtig einsetzen
UTM-Parameter sind der Standard für Campaign-Tracking:
```
https://ulo.ad/angebot
?utm_source=newsletter
&utm_medium=email
&utm_campaign=winter-sale
&utm_content=header-cta
```
### Die 5 UTM-Parameter
1. **utm_source**: Woher kommt der Traffic? (newsletter, google, facebook)
2. **utm_medium**: Welches Medium? (email, cpc, social)
3. **utm_campaign**: Welche Kampagne? (winter-sale, black-friday)
4. **utm_content**: Welcher spezifische Link? (header-cta, footer-link)
5. **utm_term**: Welches Keyword? (bei Paid Search)
## DSGVO-konformes Tracking
### Was ist erlaubt?
✅ **Anonymisierte Daten**
- Gerätetyp
- Browser
- Ungefährer Standort (Land/Stadt)
- Referrer
✅ **Aggregierte Metriken**
- Gesamtklicks
- Durchschnittliche Verweildauer
- Conversion-Raten
### Was braucht Zustimmung?
❌ **Personenbezogene Daten**
- Vollständige IP-Adressen
- Device Fingerprinting
- Cross-Site Tracking
- Retargeting-Pixel
## Best Practices für Link-Tracking
### 1. Konsistente Namenskonvention
Entwickeln Sie ein einheitliches Schema:
```
utm_source: [channel]
utm_medium: [type]
utm_campaign: [yyyy-mm]-[campaign-name]
```
### 2. Dokumentation führen
Erstellen Sie eine Tracking-Tabelle:
| Kampagne | Source | Medium | Link | Erstellt |
|----------|--------|--------|------|----------|
| Winter Sale | newsletter | email | /winter | 2024-01-15 |
### 3. Regelmäßige Bereinigung
Löschen Sie alte, inaktive Links und konsolidieren Sie ähnliche Kampagnen.
## A/B-Testing mit Links
Testen Sie verschiedene Varianten:
- Verschiedene Call-to-Actions
- Unterschiedliche Landing Pages
- Alternative Platzierungen
- Timing-Experimente
## Tools und Integration
### Google Analytics 4
- Automatisches UTM-Tracking
- Conversion-Tracking
- Audience-Segmentierung
### Marketing-Automation
- HubSpot
- Mailchimp
- ActiveCampaign
### Social Media Tools
- Buffer
- Hootsuite
- Sprout Social
## Fehler, die Sie vermeiden sollten
1. **Inkonsistente Parameter**: newsletter vs Newsletter vs Email-Newsletter
2. **Zu viele Parameter**: Halten Sie es simpel
3. **Keine Dokumentation**: Nach 6 Monaten weiß niemand mehr, was "camp-x1" war
4. **Ignorieren der Daten**: Tracking ohne Analyse ist nutzlos
## Zukunft des Link-Trackings
- **Privacy-First**: Mehr Fokus auf aggregierte, anonyme Daten
- **Server-Side Tracking**: Umgehung von Ad-Blockern
- **KI-gestützte Analyse**: Automatische Mustererkennung
- **Cross-Device Attribution**: Besseres Verständnis der Customer Journey
## Fazit
Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern und dabei vollständig DSGVO-konform bleiben.
Starten Sie noch heute mit professionellem Link-Tracking Ihre Conversion-Rate wird es Ihnen danken!

View file

@ -1,184 +0,0 @@
---
title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
excerpt: 42% weniger Klicks bei langen URLs diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter.
date: 2024-01-15
author: till-schneider
category: psychology
tags: [urls, psychology, conversion, marketing]
featured: true
image: /blog/psychology-urls.jpg
seo:
title: URL-Psychologie Guide 2024 - Warum kurze Links funktionieren | uload Blog
description: Erfahren Sie, warum kurze URLs 42% mehr Klicks erhalten. Wissenschaftlich fundierte Erkenntnisse zur Cognitive Load Theory und praktische Tipps für bessere Conversion-Rates.
---
**42% weniger Klicks bei langen URLs** diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können.
## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen
Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance.
### Die Spam-Alarm-Reaktion unseres Gehirns
Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen. Diese evolutionäre Schutzreaktion lässt uns instinktiv zurückschrecken.
Vergleichen Sie diese beiden URLs:
**Lange URL (schlecht):**
```
https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024&ref=user789&tracking=enabled
```
**Kurze URL (gut):**
```
https://ulo.ad/summer-sale
```
Der Unterschied ist offensichtlich, oder?
### Mobile Nutzer: Die vergessene Mehrheit
In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen. Was nicht auf den ersten Blick erkennbar ist, wird ignoriert eine simple, aber folgenreiche Wahrheit.
## Die Wissenschaft dahinter: Cognitive Load Theory
### Warum unser Gehirn faul ist (und das gut so ist)
Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen es ist evolutionär faul, aber auf eine intelligente Weise. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
Wenn wir einen kurzen, klaren Link sehen, kann unser Gehirn ihn schnell verarbeiten und kategorisieren. Diese mühelose Verarbeitung erzeugt ein positives Gefühl wir verbinden "einfach" automatisch mit "sicher" und "vertrauenswürdig".
### Der Halo-Effekt kurzer URLs
Psychologen nennen es den Halo-Effekt: Ein positives Merkmal (die Kürze des Links) überträgt sich auf die gesamte Wahrnehmung. Ein kurzer, sauberer Link lässt uns unbewusst annehmen, dass auch die Zielseite professionell, sicher und relevant sein wird.
## Die vier Säulen des Link-Vertrauens
Unsere Analyse von über 10.000 Link-Klicks hat vier Hauptfaktoren identifiziert:
### 1. Erkennbare Domain (60% Wichtigkeit)
Menschen wollen wissen, wo sie landen werden. Eine klare, erkennbare Domain ist der wichtigste Vertrauensfaktor:
- Verwenden Sie Ihre Marken-Domain wenn möglich
- Bei Kurz-URLs: Wählen Sie einen Service mit gutem Ruf
- Vermeiden Sie obskure URL-Shortener
### 2. Keine kryptischen Zeichen (25% Wichtigkeit)
Zufällige Zahlen-Buchstaben-Kombinationen wie "x7h9k2p" schrecken Nutzer ab. Stattdessen:
- Nutzen Sie sprechende Begriffe
- Verwenden Sie relevante Keywords
- Halten Sie es lesbar und merkbar
### 3. Optimale Länge (10% Wichtigkeit)
Die magische Grenze liegt bei etwa 50 Zeichen:
- **15-30 Zeichen**: Optimal für Social Media
- **30-50 Zeichen**: Ideal für E-Mail-Marketing
- **Über 50 Zeichen**: Deutlicher Rückgang der Klickrate
### 4. HTTPS-Verschlüsselung (5% Wichtigkeit)
Das kleine Schloss-Symbol mag nur 5% ausmachen, aber es ist ein Hygienefaktor fehlt es, kann das Vertrauen komplett zerstört werden.
## Praktische Optimierungsstrategien
### 1. Sprechende URLs verwenden
**Schlecht:** `ulo.ad/p47829`
**Gut:** `ulo.ad/sommer-sale`
Der Unterschied? Der zweite Link kommuniziert sofort, was den Nutzer erwartet. Diese Transparenz erhöht die Klickrate um durchschnittlich 39%.
### 2. Die 50-Zeichen-Regel
Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
- Kurz genug für Twitter/X
- Lesbar auf Mobilgeräten
- Merkbar für Nutzer
- Optimal für die Anzeige in E-Mails
### 3. A/B-Testing ist Ihr Freund
Testen Sie verschiedene URL-Varianten:
- Kurz vs. deskriptiv
- Mit Markenname vs. ohne
- Verschiedene Keywords
- Unterschiedliche Strukturen
### 4. Performance-Tracking implementieren
Ohne Daten keine Optimierung. Moderne Link-Management-Tools bieten:
- Detaillierte Klick-Statistiken
- Geografische Verteilung
- Geräteerkennung
- Referrer-Tracking
- Conversion-Tracking
## Case Studies: Erfolgsgeschichten
### E-Commerce: 67% mehr Conversions
Ein großer Online-Händler verkürzte seine Produkt-URLs von durchschnittlich 120 auf 45 Zeichen:
- **67% höhere Conversion Rate**
- **42% mehr Social Shares**
- **31% niedrigere Bounce Rate**
### Newsletter-Marketing: Verdoppelte Klickrate
Ein B2B-Unternehmen wechselte von langen Tracking-URLs zu personalisierten Kurz-URLs:
- **Vorher:** `company.com/newsletter/2024/march/article-5?utm_source=email&utm_medium=newsletter`
- **Nachher:** `co.link/cloud-guide`
- **Resultat:** 2,1x höhere Klickrate
## Die Zukunft kurzer URLs
### KI-optimierte Personalisierung
Moderne Systeme nutzen KI, um für jeden Nutzer die optimale URL-Variante zu generieren basierend auf:
- Demografischen Daten
- Bisherigem Klickverhalten
- Kontext der Interaktion
- Tageszeit und Gerät
### Voice-First Optimization
Mit dem Aufstieg von Sprachassistenten werden "sprechbare" URLs wichtiger:
- Einfache Wörter statt Buchstaben-Zahlen-Kombinationen
- Vermeidung ähnlich klingender Begriffe
- Klare, eindeutige Aussprache
## Fazit: Die Macht der Kürze
Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen.
### Die wichtigsten Takeaways
1. **42% weniger Klicks** bei URLs über 100 Zeichen
2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit
3. **50 Zeichen** ist die magische Grenze
4. **Sprechende URLs** performen 39% besser
5. **Mobile First**: Über 60% surfen mobil
6. **Vertrauen** ist wichtiger als Tracking
### Ihre nächsten Schritte
1. **Audit**: Analysieren Sie Ihre aktuellen URLs
2. **Optimieren**: Kürzen und verbessern Sie systematisch
3. **Testen**: A/B-Tests für verschiedene Varianten
4. **Messen**: Tracking der Performance-Verbesserungen
5. **Iterieren**: Kontinuierliche Optimierung basierend auf Daten
Tools wie [uload](https://ulo.ad) wurden speziell entwickelt, um die Erkenntnisse der URL-Psychologie in die Praxis umzusetzen. Mit Features wie personalisierten Kurz-URLs, detaillierten Analytics und A/B-Testing können Sie sofort damit beginnen, Ihre Link-Performance zu optimieren.

View file

@ -1,64 +0,0 @@
import { z } from 'zod';
// Author Schema
export const authorSchema = z.object({
id: z.string(),
name: z.string(),
bio: z.string().optional(),
avatar: z.string().optional(),
social: z
.object({
twitter: z.string().optional(),
github: z.string().optional(),
linkedin: z.string().optional(),
website: z.string().optional(),
})
.optional(),
});
// Blog Post Schema
export const blogSchema = z.object({
title: z.string(),
excerpt: z.string(),
date: z
.string()
.or(z.date())
.transform((val) => new Date(val)),
author: z.string(), // Author ID
tags: z.array(z.string()).default([]),
category: z.enum(['tutorial', 'psychology', 'feature', 'announcement', 'case-study']),
image: z.string().optional(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
series: z.string().optional(),
layout: z.string().default('blog'),
seo: z
.object({
title: z.string().optional(),
description: z.string().optional(),
canonical: z.string().optional(),
})
.optional(),
});
// Type exports
export type BlogPost = z.infer<typeof blogSchema>;
export type Author = z.infer<typeof authorSchema>;
// Extended types with computed fields
export interface BlogPostWithMeta extends BlogPost {
slug: string;
readingTime: number;
path?: string;
}
export interface BlogCategory {
name: string;
slug: string;
count: number;
}
export interface BlogTag {
name: string;
count: number;
}

View file

@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View file

@ -1,251 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { hashManager } from '../service/HashManager';
import {
getVariantContent,
getTrustBadges,
getFreeText,
type VariantContent,
} from '../config/variants';
import { locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PageData, ActionData } from '../../../routes/$types';
interface Props {
data: PageData;
form: ActionData;
onSubmit?: () => void;
}
let { data, form, onSubmit }: Props = $props();
let variant = $state<string>('control');
let content = $state<VariantContent>(getVariantContent('control'));
let trustBadges = $state(getTrustBadges());
let freeText = $state(getFreeText());
let showDebug = $state(false);
let isLoading = $state(true);
onMount(() => {
// Get variant assignment
variant = hashManager.getVariant();
content = getVariantContent(variant);
trustBadges = getTrustBadges();
freeText = getFreeText();
showDebug = hashManager.isDebugMode();
isLoading = false;
// Track page view with variant
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track(`page_view_${variant}`);
}
// Log for debugging
if (showDebug) {
console.log('A/B Test Variant:', variant, content);
console.log('Current Locale:', get(locale));
}
});
// React to locale changes - use derived state
$effect(() => {
// This will re-run when locale changes
const currentLocale = get(locale);
// Update content based on current locale
content = getVariantContent(variant);
trustBadges = getTrustBadges();
freeText = getFreeText();
if (showDebug) {
console.log('Locale changed to:', currentLocale);
}
});
function handleCtaClick() {
// Track CTA click
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track(`cta_click_${variant}`);
}
onSubmit?.();
// Smooth scroll to form
const form = document.getElementById('url-form');
if (form) {
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
</script>
{#if showDebug}
<div class="fixed right-4 top-20 z-50 rounded-lg bg-black/80 p-4 text-white shadow-lg">
<div class="font-mono text-xs">
<div class="font-bold text-green-400">A/B Test Debug</div>
<div>Variant: <span class="text-yellow-400">{variant}</span></div>
<div>Name: {content.name}</div>
<div>Locale: <span class="text-blue-400">{get(locale)}</span></div>
<div class="mt-2">
<button
onclick={() => {
hashManager.reset();
window.location.reload();
}}
class="rounded bg-red-600 px-2 py-1 text-xs hover:bg-red-700"
>
Reset & Reload
</button>
</div>
</div>
</div>
{/if}
<section
class="relative overflow-hidden bg-gradient-to-br from-purple-50 via-white to-blue-50 dark:from-gray-900 dark:via-gray-900 dark:to-purple-900"
>
<!-- Background decoration -->
<div
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-purple-300 opacity-20 blur-3xl"
></div>
<div
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-blue-300 opacity-20 blur-3xl"
></div>
<div class="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
{#if !isLoading}
<div class="text-center">
<!-- Headline -->
<h1
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl md:text-6xl"
>
{#if variant === 'b2' && content.headline.includes(',')}
<!-- Special formatting for logos variant -->
<span class="block">{content.headline.split(',')[0]},</span>
<span class="block text-3xl sm:text-4xl md:text-5xl"
>{content.headline.split(',').slice(1).join(',')}</span
>
{:else}
{content.headline}
{/if}
</h1>
<!-- Subheadline -->
<p class="mx-auto mt-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300 sm:text-xl">
{content.subheadline}
</p>
<!-- Social Proof (if present) -->
{#if content.socialProof}
<div class="mt-8">
{#if content.socialProof.type === 'numbers'}
<div
class="flex flex-wrap justify-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-400"
>
{#each content.socialProof.content.split('•') as stat}
<span class="flex items-center gap-1">
<span class="text-green-500"></span>
{stat.trim()}
</span>
{/each}
</div>
{:else if content.socialProof.type === 'logos'}
<div class="mt-4 flex flex-wrap justify-center gap-6 opacity-60 grayscale">
{#each content.socialProof.content.split('•') as logo}
<span class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{logo.trim()}
</span>
{/each}
</div>
{:else if content.socialProof.type === 'testimonial'}
<div class="mt-4 text-yellow-500">
{content.socialProof.content}
</div>
{/if}
</div>
{/if}
<!-- Features List (if present) -->
{#if content.features && content.features.length > 0}
<div class="mt-8 flex flex-wrap justify-center gap-4">
{#each content.features.slice(0, 3) as feature}
<div
class="flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm dark:bg-gray-800/80 dark:text-gray-300"
>
<svg
class="h-4 w-4 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"
/>
</svg>
{feature}
</div>
{/each}
{#if content.features.length > 3}
{#each content.features.slice(3) as feature}
<div
class="flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm dark:bg-gray-800/80 dark:text-gray-300"
>
<svg
class="h-4 w-4 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"
/>
</svg>
{feature}
</div>
{/each}
{/if}
</div>
{/if}
<!-- CTA Button -->
<div class="mx-auto mt-10 max-w-xl">
<a
href="#url-form"
onclick={handleCtaClick}
class="inline-block whitespace-nowrap rounded-lg px-8 py-4 font-semibold text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl {content.ctaStyle ||
'bg-theme-primary hover:bg-theme-primary-hover'}"
>
{content.ctaText}
</a>
{#if !data.user}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{freeText}
</p>
{/if}
</div>
<!-- Trust Badges -->
<div
class="mt-12 flex flex-wrap justify-center gap-6 text-sm text-gray-500 dark:text-gray-400"
>
{#each trustBadges as badge}
<span class="flex items-center gap-1">
{badge.icon}
{badge.text}
</span>
{/each}
</div>
</div>
{:else}
<!-- Loading state -->
<div class="flex min-h-[400px] items-center justify-center">
<div class="text-gray-500">Loading...</div>
</div>
{/if}
</div>
</section>

View file

@ -1,208 +0,0 @@
/**
* A/B Testing Variant Configurations
* Defines content and styling for each variant using multilingual messages
*/
import * as m from '$paraglide/messages';
export interface VariantContent {
id: string;
name: string;
headline: string;
subheadline: string;
ctaText: string;
ctaStyle?: string;
features?: string[];
socialProof?: {
type: 'numbers' | 'logos' | 'testimonial';
content: string;
};
layout?: 'standard' | 'split' | 'centered';
}
// Get variant content with multilingual support
export function getVariantContent(variantId: string): VariantContent {
switch (variantId) {
case 'control':
return {
id: 'control',
name: 'Control (Baseline)',
headline: m.hero_control_headline(),
subheadline: m.hero_control_subheadline(),
ctaText: m.hero_control_cta(),
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
layout: 'standard',
};
// Variant A - Value Focused
case 'a1':
return {
id: 'a1',
name: 'Value Generic',
headline: m.hero_a1_headline(),
subheadline: m.hero_a1_subheadline(),
ctaText: m.hero_a1_cta(),
ctaStyle: 'bg-blue-600 hover:bg-blue-700',
features: [m.hero_a1_feature_1(), m.hero_a1_feature_2(), m.hero_a1_feature_3()],
layout: 'standard',
};
case 'a2':
return {
id: 'a2',
name: 'Value Specific',
headline: 'Save 3 Hours Per Week on Link Management',
subheadline: 'Join teams who reduced their link management tasks by 75%',
ctaText: 'Calculate Your Savings',
ctaStyle:
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
features: ['3 hours saved weekly', '75% faster workflows', 'ROI in 2 weeks'],
layout: 'standard',
};
case 'a3':
return {
id: 'a3',
name: 'Value Transform',
headline: 'Your Links, 10x More Powerful',
subheadline: 'Transform every URL into a conversion machine with analytics and automation',
ctaText: 'Unlock Link Power →',
ctaStyle: 'bg-black hover:bg-gray-800',
features: ['10x more clicks', 'Conversion tracking', 'Smart redirects'],
layout: 'centered',
};
// Variant B - Social Proof
case 'b1':
return {
id: 'b1',
name: 'Social Numbers',
headline: m.hero_b1_headline(),
subheadline: m.hero_b1_subheadline(),
ctaText: m.hero_b1_cta(),
ctaStyle: 'bg-purple-600 hover:bg-purple-700',
socialProof: {
type: 'numbers',
content: m.hero_b1_social(),
},
layout: 'standard',
};
case 'b2':
return {
id: 'b2',
name: 'Social Logos',
headline: 'Trusted by Google, Meta, and Microsoft Teams',
subheadline: 'Enterprise-grade URL management for companies of all sizes',
ctaText: 'See Why They Chose Us',
ctaStyle:
'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700',
socialProof: {
type: 'logos',
content: 'Google • Meta • Microsoft • Spotify • Netflix',
},
layout: 'standard',
};
case 'b3':
return {
id: 'b3',
name: 'Social Testimonial',
headline: 'Rated #1 URL Shortener by Marketing Teams',
subheadline: '"uLoad saved us 5 hours per week and increased our CTR by 40%"',
ctaText: 'Read Success Stories',
ctaStyle: 'bg-green-600 hover:bg-green-700',
socialProof: {
type: 'testimonial',
content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews',
},
layout: 'centered',
};
// Variant C - Feature Focused
case 'c1':
return {
id: 'c1',
name: 'Features All-in-One',
headline: m.hero_c1_headline(),
subheadline: m.hero_c1_subheadline(),
ctaText: m.hero_c1_cta(),
ctaStyle: 'bg-indigo-600 hover:bg-indigo-700',
features: [
m.hero_c1_feature_1(),
m.hero_c1_feature_2(),
m.hero_c1_feature_3(),
m.hero_c1_feature_4(),
m.hero_c1_feature_5(),
m.hero_c1_feature_6(),
],
layout: 'standard',
};
case 'c2':
return {
id: 'c2',
name: 'Features QR Focus',
headline: 'QR Codes That Actually Convert',
subheadline: 'Create dynamic QR codes with real-time analytics and custom branding',
ctaText: 'Create Your First QR Code',
ctaStyle: 'bg-orange-600 hover:bg-orange-700',
features: ['Dynamic QR codes', 'Custom designs', 'Scan analytics', 'Bulk generation'],
layout: 'split',
};
case 'c3':
return {
id: 'c3',
name: 'Features Integration',
headline: 'Works With Your Favorite Tools',
subheadline: 'Seamless integration with Zapier, Slack, WordPress & 100+ platforms',
ctaText: 'Connect Your Tools',
ctaStyle: 'bg-teal-600 hover:bg-teal-700',
features: [
'Zapier automation',
'Slack notifications',
'WordPress plugin',
'API & Webhooks',
],
layout: 'standard',
};
// Default to control
default:
return {
id: 'control',
name: 'Control (Baseline)',
headline: m.hero_control_headline(),
subheadline: m.hero_control_subheadline(),
ctaText: m.hero_control_cta(),
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
layout: 'standard',
};
}
}
// Get all active variant IDs
export function getActiveVariantIds(): string[] {
return ['control', 'a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
}
// Check if variant exists
export function isValidVariant(variantId: string): boolean {
return getActiveVariantIds().includes(variantId);
}
// Get trust badges with translations
export function getTrustBadges(): Array<{ icon: string; text: string }> {
return [
{ icon: '🔒', text: m.hero_trust_badge_1() },
{ icon: '🇪🇺', text: m.hero_trust_badge_2() },
{ icon: '⚡', text: m.hero_trust_badge_3() },
{ icon: '🚀', text: m.hero_trust_badge_4() },
];
}
// Get free text
export function getFreeText(): string {
return m.hero_free_text();
}

View file

@ -1,209 +0,0 @@
/**
* Hash-based A/B Testing Manager
* Manages variant assignment and persistence via URL hash
*/
export class HashManager {
// Valid variants with versions
private readonly validVariants = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
// Current traffic distribution (percentages must sum to 100)
private readonly distribution: Record<string, number> = {
control: 40, // Baseline
a1: 20, // Value-focused variant
b1: 20, // Social proof variant
c1: 20, // Feature-focused variant
};
// Storage key for backup
private readonly storageKey = 'uload_ab_variant';
// Debug mode flag
private debugMode = false;
constructor() {
// Check for debug mode
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
this.debugMode = params.get('debug') === 'true';
}
}
/**
* Get the current variant for the user
* Priority: URL hash > localStorage > new assignment
*/
getVariant(): string {
if (typeof window === 'undefined') {
return 'control';
}
// Check for forced variant (testing)
const forced = this.getForcedVariant();
if (forced !== null) {
this.log(`Forced variant: ${forced}`);
return forced;
}
// Check existing hash
const hash = window.location.hash.slice(1);
if (hash && this.isValidVariant(hash)) {
this.log(`Using hash variant: ${hash}`);
this.storeVariant(hash);
return hash;
}
// Check localStorage backup
const stored = this.getStoredVariant();
if (stored && this.isValidVariant(stored)) {
this.log(`Using stored variant: ${stored}`);
this.setHash(stored);
return stored;
}
// Assign new variant
const newVariant = this.assignRandomVariant();
this.log(`Assigned new variant: ${newVariant}`);
this.setHash(newVariant);
this.storeVariant(newVariant);
return newVariant;
}
/**
* Check if a variant is valid
*/
private isValidVariant(variant: string): boolean {
return variant === 'control' || this.validVariants.includes(variant);
}
/**
* Assign a random variant based on distribution weights
*/
private assignRandomVariant(): string {
const random = Math.random() * 100;
let cumulative = 0;
for (const [variant, weight] of Object.entries(this.distribution)) {
cumulative += weight;
if (random <= cumulative) {
return variant;
}
}
// Fallback to control
return 'control';
}
/**
* Set the URL hash
*/
private setHash(variant: string): void {
if (typeof window !== 'undefined') {
// Don't set hash for control to keep URL clean
if (variant === 'control') {
// Remove hash if it exists
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
} else {
window.location.hash = variant;
}
}
}
/**
* Store variant in localStorage
*/
private storeVariant(variant: string): void {
if (typeof window !== 'undefined' && window.localStorage) {
try {
localStorage.setItem(this.storageKey, variant);
// Also store timestamp for analytics
localStorage.setItem(`${this.storageKey}_timestamp`, new Date().toISOString());
} catch (e) {
console.warn('Could not store variant in localStorage:', e);
}
}
}
/**
* Get stored variant from localStorage
*/
private getStoredVariant(): string | null {
if (typeof window !== 'undefined' && window.localStorage) {
try {
return localStorage.getItem(this.storageKey);
} catch (e) {
console.warn('Could not read variant from localStorage:', e);
}
}
return null;
}
/**
* Get forced variant from URL params (for testing)
*/
private getForcedVariant(): string | null {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const forced = params.get('force') || params.get('variant');
if (forced && this.isValidVariant(forced)) {
return forced;
}
}
return null;
}
/**
* Reset variant assignment (for testing)
*/
reset(): void {
if (typeof window !== 'undefined') {
// Clear hash
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
// Clear storage
if (window.localStorage) {
localStorage.removeItem(this.storageKey);
localStorage.removeItem(`${this.storageKey}_timestamp`);
}
this.log('Variant assignment reset');
}
}
/**
* Get all active variants (for debugging)
*/
getActiveVariants(): string[] {
return ['control', ...Object.keys(this.distribution).filter((v) => v !== 'control')];
}
/**
* Get current distribution (for debugging)
*/
getDistribution(): Record<string, number> {
return { ...this.distribution };
}
/**
* Log debug messages
*/
private log(message: string): void {
if (this.debugMode) {
console.log(`[A/B Testing] ${message}`);
}
}
/**
* Check if we should show debug info
*/
isDebugMode(): boolean {
return this.debugMode;
}
}
// Export singleton instance
export const hashManager = new HashManager();

View file

@ -1,16 +0,0 @@
// Click outside action for Svelte components
export function clickOutside(node: HTMLElement, callback: () => void) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
callback();
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
},
};
}

View file

@ -1,202 +0,0 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { isTouchDevice, isOptimalTouchTarget } from './touch';
// Mock DOM APIs für Tests
const mockEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
const createMockElement = (width = 44, height = 44) => ({
addEventListener: mockEventListener,
removeEventListener: mockRemoveEventListener,
getBoundingClientRect: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }),
style: {},
appendChild: vi.fn(),
remove: vi.fn(),
});
// Mock global objects
Object.defineProperty(window, 'navigator', {
value: {
maxTouchPoints: 0,
userAgent: 'Mozilla/5.0 (Test Browser)',
},
writable: true,
});
describe('Touch Utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset touch support
delete (window as any).ontouchstart;
(window.navigator as any).maxTouchPoints = 0;
});
describe('isTouchDevice', () => {
test('should detect touch support via ontouchstart', () => {
(window as any).ontouchstart = true;
expect(isTouchDevice()).toBe(true);
});
test('should detect touch support via maxTouchPoints', () => {
(window.navigator as any).maxTouchPoints = 1;
expect(isTouchDevice()).toBe(true);
});
test('should return false for non-touch devices', () => {
expect(isTouchDevice()).toBe(false);
});
});
describe('isOptimalTouchTarget', () => {
test('should return true for 44x44 elements', () => {
const element = createMockElement(44, 44);
expect(isOptimalTouchTarget(element as any)).toBe(true);
});
test('should return true for larger elements', () => {
const element = createMockElement(50, 60);
expect(isOptimalTouchTarget(element as any)).toBe(true);
});
test('should return false for small width', () => {
const element = createMockElement(30, 44);
expect(isOptimalTouchTarget(element as any)).toBe(false);
});
test('should return false for small height', () => {
const element = createMockElement(44, 30);
expect(isOptimalTouchTarget(element as any)).toBe(false);
});
test('should return false for small elements', () => {
const element = createMockElement(20, 20);
expect(isOptimalTouchTarget(element as any)).toBe(false);
});
});
});
describe('Touch Actions (Integration)', () => {
let mockElement: any;
beforeEach(() => {
mockElement = createMockElement();
vi.clearAllMocks();
});
describe('Event Registration', () => {
test('should register touch and pointer events', () => {
// Diese Tests würden die tatsächlichen Touch-Actions testen
// Für jetzt testen wir nur die Utility-Funktionen
expect(mockEventListener).not.toHaveBeenCalled();
});
});
describe('Gesture Recognition', () => {
test('should calculate touch distances correctly', () => {
const touch1 = { clientX: 0, clientY: 0 };
const touch2 = { clientX: 100, clientY: 100 };
// Math.sqrt(100^2 + 100^2) = Math.sqrt(20000) ≈ 141.42
const expectedDistance = Math.sqrt(20000);
const actualDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2)
);
expect(actualDistance).toBeCloseTo(expectedDistance, 2);
});
test('should detect horizontal swipes', () => {
const startTouch = { clientX: 0, clientY: 100 };
const endTouch = { clientX: 100, clientY: 100 };
const deltaX = endTouch.clientX - startTouch.clientX;
const deltaY = endTouch.clientY - startTouch.clientY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Horizontal swipe: |deltaX| > |deltaY|
expect(absDeltaX).toBeGreaterThan(absDeltaY);
expect(deltaX).toBeGreaterThan(0); // Right swipe
});
test('should detect vertical swipes', () => {
const startTouch = { clientX: 100, clientY: 0 };
const endTouch = { clientX: 100, clientY: 100 };
const deltaX = endTouch.clientX - startTouch.clientX;
const deltaY = endTouch.clientY - startTouch.clientY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Vertical swipe: |deltaY| > |deltaX|
expect(absDeltaY).toBeGreaterThan(absDeltaX);
expect(deltaY).toBeGreaterThan(0); // Down swipe
});
});
describe('Touch Target Validation', () => {
test('should validate minimum touch target sizes', () => {
const sizes = [
{ width: 44, height: 44, expected: true },
{ width: 48, height: 48, expected: true },
{ width: 40, height: 40, expected: false },
{ width: 44, height: 40, expected: false },
{ width: 40, height: 44, expected: false },
];
sizes.forEach(({ width, height, expected }) => {
const element = createMockElement(width, height);
expect(isOptimalTouchTarget(element as any)).toBe(expected);
});
});
});
describe('Performance Considerations', () => {
test('should handle rapid touch events', () => {
// Simuliere viele schnelle Touch-Events
const events = Array.from({ length: 100 }, (_, i) => ({
clientX: i,
clientY: i,
timestamp: Date.now() + i,
}));
// In einer echten Implementation würden wir Throttling/Debouncing testen
expect(events).toHaveLength(100);
// Teste dass Events innerhalb vernünftiger Zeit verarbeitet werden können
const startTime = Date.now();
events.forEach((event) => {
// Simuliere Event-Verarbeitung
const deltaX = event.clientX;
const deltaY = event.clientY;
Math.sqrt(deltaX * deltaX + deltaY * deltaY);
});
const endTime = Date.now();
expect(endTime - startTime).toBeLessThan(100); // Sollte sehr schnell sein
});
});
describe('Accessibility Considerations', () => {
test('should maintain focus accessibility', () => {
// Touch-Actions sollten Keyboard-Navigation nicht beeinträchtigen
const element = createMockElement();
// Simuliere dass Element fokussierbar bleibt
element.tabIndex = 0;
element.setAttribute = vi.fn();
expect(element.tabIndex).toBe(0);
});
test('should work with screen readers', () => {
// Touch-Targets sollten Screen-Reader-kompatibel bleiben
const element = createMockElement();
element.getAttribute = vi.fn().mockReturnValue('button');
element.textContent = 'Touch Button';
expect(element.getAttribute('role')).toBe('button');
expect(element.textContent).toBe('Touch Button');
});
});
});

View file

@ -1,343 +0,0 @@
// Touch-optimierte Aktionen für mobile Geräte
import type { Action } from 'svelte/action';
// Touch-optimierte Ripple-Effekte
export const ripple: Action<HTMLElement, { color?: string; duration?: number }> = (
node,
options = {}
) => {
const { color = 'rgba(255, 255, 255, 0.3)', duration = 600 } = options;
let rippleElement: HTMLDivElement | null = null;
function createRipple(event: PointerEvent | TouchEvent) {
// Entferne vorherigen Ripple
if (rippleElement) {
rippleElement.remove();
}
// Erstelle neuen Ripple
rippleElement = document.createElement('div');
const rect = node.getBoundingClientRect();
// Berechne Position des Touches/Clicks
let clientX: number, clientY: number;
if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else if (event instanceof PointerEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else {
// Fallback zur Mitte des Elements
clientX = rect.left + rect.width / 2;
clientY = rect.top + rect.height / 2;
}
const x = clientX - rect.left;
const y = clientY - rect.top;
const size = Math.max(rect.width, rect.height) * 2;
// Style des Ripple-Elements
Object.assign(rippleElement.style, {
position: 'absolute',
top: `${y - size / 2}px`,
left: `${x - size / 2}px`,
width: `${size}px`,
height: `${size}px`,
backgroundColor: color,
borderRadius: '50%',
pointerEvents: 'none',
transform: 'scale(0)',
transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`,
zIndex: '1000',
});
// Stelle sicher, dass das Parent-Element relative Position hat
const computedStyle = getComputedStyle(node);
if (computedStyle.position === 'static') {
node.style.position = 'relative';
}
// Stelle sicher, dass overflow hidden ist für Ripple-Effekt
const originalOverflow = node.style.overflow;
node.style.overflow = 'hidden';
node.appendChild(rippleElement);
// Starte Animation
requestAnimationFrame(() => {
if (rippleElement) {
rippleElement.style.transform = 'scale(1)';
rippleElement.style.opacity = '0';
}
});
// Entferne Element nach Animation
setTimeout(() => {
if (rippleElement && rippleElement.parentNode) {
rippleElement.remove();
rippleElement = null;
// Stelle ursprünglichen overflow wieder her
node.style.overflow = originalOverflow;
}
}, duration);
}
// Event Listeners für verschiedene Eingabemethoden
node.addEventListener('pointerdown', createRipple);
node.addEventListener('touchstart', createRipple, { passive: true });
return {
destroy() {
node.removeEventListener('pointerdown', createRipple);
node.removeEventListener('touchstart', createRipple);
if (rippleElement) {
rippleElement.remove();
}
},
};
};
// Swipe-Gesten erkennen
interface SwipeOptions {
threshold?: number;
timeout?: number;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
}
export const swipe: Action<HTMLElement, SwipeOptions> = (node, options = {}) => {
const {
threshold = 50,
timeout = 300,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
} = options;
let startX: number;
let startY: number;
let startTime: number;
function handleTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) return;
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
}
function handleTouchEnd(event: TouchEvent) {
if (event.changedTouches.length !== 1) return;
const touch = event.changedTouches[0];
const endX = touch.clientX;
const endY = touch.clientY;
const endTime = Date.now();
// Prüfe Timeout
if (endTime - startTime > timeout) return;
const deltaX = endX - startX;
const deltaY = endY - startY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Prüfe ob Schwellenwert erreicht wurde
if (Math.max(absDeltaX, absDeltaY) < threshold) return;
// Bestimme Swipe-Richtung
if (absDeltaX > absDeltaY) {
// Horizontaler Swipe
if (deltaX > 0) {
onSwipeRight?.();
} else {
onSwipeLeft?.();
}
} else {
// Vertikaler Swipe
if (deltaY > 0) {
onSwipeDown?.();
} else {
onSwipeUp?.();
}
}
}
node.addEventListener('touchstart', handleTouchStart, { passive: true });
node.addEventListener('touchend', handleTouchEnd, { passive: true });
return {
destroy() {
node.removeEventListener('touchstart', handleTouchStart);
node.removeEventListener('touchend', handleTouchEnd);
},
};
};
// Long Press für mobile Geräte
interface LongPressOptions {
duration?: number;
onLongPress?: (event: PointerEvent | TouchEvent) => void;
}
export const longPress: Action<HTMLElement, LongPressOptions> = (node, options = {}) => {
const { duration = 500, onLongPress } = options;
let timer: ReturnType<typeof setTimeout>;
let startEvent: PointerEvent | TouchEvent;
function startLongPress(event: PointerEvent | TouchEvent) {
startEvent = event;
timer = setTimeout(() => {
onLongPress?.(startEvent);
}, duration);
}
function cancelLongPress() {
clearTimeout(timer);
}
// Touch Events
node.addEventListener('touchstart', startLongPress, { passive: true });
node.addEventListener('touchend', cancelLongPress, { passive: true });
node.addEventListener('touchcancel', cancelLongPress, { passive: true });
node.addEventListener('touchmove', cancelLongPress, { passive: true });
// Pointer Events (für bessere Unterstützung)
node.addEventListener('pointerdown', startLongPress);
node.addEventListener('pointerup', cancelLongPress);
node.addEventListener('pointercancel', cancelLongPress);
node.addEventListener('pointermove', cancelLongPress);
return {
destroy() {
clearTimeout(timer);
node.removeEventListener('touchstart', startLongPress);
node.removeEventListener('touchend', cancelLongPress);
node.removeEventListener('touchcancel', cancelLongPress);
node.removeEventListener('touchmove', cancelLongPress);
node.removeEventListener('pointerdown', startLongPress);
node.removeEventListener('pointerup', cancelLongPress);
node.removeEventListener('pointercancel', cancelLongPress);
node.removeEventListener('pointermove', cancelLongPress);
},
};
};
// Touch-freundliche Drag & Drop
interface TouchDragOptions {
onDragStart?: (event: PointerEvent | TouchEvent) => void;
onDragMove?: (event: PointerEvent | TouchEvent, deltaX: number, deltaY: number) => void;
onDragEnd?: (event: PointerEvent | TouchEvent) => void;
threshold?: number;
}
export const touchDrag: Action<HTMLElement, TouchDragOptions> = (node, options = {}) => {
const { onDragStart, onDragMove, onDragEnd, threshold = 5 } = options;
let isDragging = false;
let startX: number;
let startY: number;
let lastX: number;
let lastY: number;
function handleStart(event: PointerEvent | TouchEvent) {
let clientX: number, clientY: number;
if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else if (event instanceof PointerEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else {
return;
}
startX = lastX = clientX;
startY = lastY = clientY;
isDragging = false;
}
function handleMove(event: PointerEvent | TouchEvent) {
let clientX: number, clientY: number;
if (event instanceof TouchEvent && event.touches.length > 0) {
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else if (event instanceof PointerEvent) {
clientX = event.clientX;
clientY = event.clientY;
} else {
return;
}
const deltaX = clientX - lastX;
const deltaY = clientY - lastY;
const totalDeltaX = clientX - startX;
const totalDeltaY = clientY - startY;
// Prüfe ob Drag-Threshold erreicht wurde
if (!isDragging && (Math.abs(totalDeltaX) > threshold || Math.abs(totalDeltaY) > threshold)) {
isDragging = true;
onDragStart?.(event);
}
if (isDragging) {
onDragMove?.(event, deltaX, deltaY);
}
lastX = clientX;
lastY = clientY;
}
function handleEnd(event: PointerEvent | TouchEvent) {
if (isDragging) {
onDragEnd?.(event);
}
isDragging = false;
}
// Touch Events
node.addEventListener('touchstart', handleStart, { passive: true });
node.addEventListener('touchmove', handleMove, { passive: false });
node.addEventListener('touchend', handleEnd, { passive: true });
node.addEventListener('touchcancel', handleEnd, { passive: true });
// Pointer Events
node.addEventListener('pointerdown', handleStart);
node.addEventListener('pointermove', handleMove);
node.addEventListener('pointerup', handleEnd);
node.addEventListener('pointercancel', handleEnd);
return {
destroy() {
node.removeEventListener('touchstart', handleStart);
node.removeEventListener('touchmove', handleMove);
node.removeEventListener('touchend', handleEnd);
node.removeEventListener('touchcancel', handleEnd);
node.removeEventListener('pointerdown', handleStart);
node.removeEventListener('pointermove', handleMove);
node.removeEventListener('pointerup', handleEnd);
node.removeEventListener('pointercancel', handleEnd);
},
};
};
// Utility: Touch-Gerät erkennen
export function isTouchDevice(): boolean {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
// Utility: Optimale Touch-Target-Größe prüfen
export function isOptimalTouchTarget(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
const minSize = 44; // 44px ist die empfohlene Mindestgröße für Touch-Targets
return rect.width >= minSize && rect.height >= minSize;
}

View file

@ -1,145 +0,0 @@
/**
* Umami Analytics Event Tracking
* Provides type-safe event tracking with Umami Analytics
*/
declare global {
interface Window {
umami?: {
track: (eventName: string, data?: Record<string, string | number | boolean>) => void;
};
}
}
/**
* Event names for consistent tracking across the application
*/
export const EVENTS = {
// Link events
LINK_CREATED: 'link-created',
LINK_EDITED: 'link-edited',
LINK_DELETED: 'link-deleted',
LINK_CLICKED: 'link-clicked',
LINK_COPIED: 'link-copied',
LINK_SHARED: 'link-shared',
LINK_QR_GENERATED: 'link-qr-generated',
LINK_QR_DOWNLOADED: 'link-qr-downloaded',
LINK_EXPIRED: 'link-expired',
LINK_PASSWORD_SET: 'link-password-set',
LINK_PASSWORD_UNLOCKED: 'link-password-unlocked',
// User events
USER_SIGNUP: 'user-signup',
USER_LOGIN: 'user-login',
USER_LOGOUT: 'user-logout',
USER_PROFILE_UPDATED: 'user-profile-updated',
USER_PASSWORD_RESET: 'user-password-reset',
// Dashboard events
DASHBOARD_VIEWED: 'dashboard-viewed',
ANALYTICS_VIEWED: 'analytics-viewed',
PROFILE_VIEWED: 'profile-viewed',
// Search and filter
SEARCH_PERFORMED: 'search-performed',
FILTER_APPLIED: 'filter-applied',
SORT_CHANGED: 'sort-changed',
// Error events
ERROR_OCCURRED: 'error-occurred',
RATE_LIMITED: 'rate-limited',
} as const;
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
/**
* Track an event with Umami Analytics
* @param eventName - The name of the event to track
* @param data - Optional data to send with the event (will be converted to strings)
*/
export function trackEvent(eventName: EventName | string, data?: Record<string, any>): void {
if (typeof window === 'undefined' || !window.umami) {
console.debug('Umami not available, skipping event:', eventName, data);
return;
}
try {
// Convert all data values to strings (Umami requirement)
const stringData = data
? Object.entries(data).reduce(
(acc, [key, value]) => {
acc[key] = String(value);
return acc;
},
{} as Record<string, string>
)
: undefined;
window.umami.track(eventName, stringData);
console.debug('Event tracked:', eventName, stringData);
} catch (error) {
console.error('Failed to track event:', error);
}
}
/**
* Track a link click event
*/
export function trackLinkClick(linkData: {
shortCode: string;
username: string;
hasPassword?: boolean;
isExpiring?: boolean;
}): void {
trackEvent(EVENTS.LINK_CLICKED, {
short_code: linkData.shortCode,
username: linkData.username,
has_password: linkData.hasPassword || false,
is_expiring: linkData.isExpiring || false,
});
}
/**
* Track a link creation event
*/
export function trackLinkCreated(linkData: {
shortCode: string;
hasPassword?: boolean;
hasExpiry?: boolean;
hasClickLimit?: boolean;
}): void {
trackEvent(EVENTS.LINK_CREATED, {
short_code: linkData.shortCode,
has_password: linkData.hasPassword || false,
has_expiry: linkData.hasExpiry || false,
has_click_limit: linkData.hasClickLimit || false,
});
}
/**
* Track user authentication events
*/
export function trackAuth(type: 'signup' | 'login' | 'logout', method?: string): void {
const eventMap = {
signup: EVENTS.USER_SIGNUP,
login: EVENTS.USER_LOGIN,
logout: EVENTS.USER_LOGOUT,
};
trackEvent(eventMap[type], method ? { method } : undefined);
}
/**
* Track error events
*/
export function trackError(error: {
type: string;
message?: string;
code?: string | number;
}): void {
trackEvent(EVENTS.ERROR_OCCURRED, {
error_type: error.type,
error_message: error.message || 'Unknown error',
error_code: error.code || 'unknown',
});
}

View file

@ -1,15 +0,0 @@
/**
* Feedback Service Instance for uLoad Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { pb } from '$lib/pocketbase';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'uload',
getAuthToken: async () => pb.authStore.token || '',
});

View file

@ -1,146 +0,0 @@
import { pb } from './pocketbase';
import { generateUsernameFromEmail } from './username';
export interface RegisterData {
email: string;
password: string;
passwordConfirm: string;
}
export interface RegisterResult {
success: boolean;
user?: any;
error?: string;
}
export async function registerUser(data: RegisterData): Promise<RegisterResult> {
try {
const email = data.email.toLowerCase().trim();
// Basic validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { success: false, error: 'Please enter a valid email address' };
}
if (data.password !== data.passwordConfirm) {
return { success: false, error: 'Passwords do not match' };
}
if (data.password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Generate unique username
let username = generateUsernameFromEmail(email);
let attempts = 0;
// Try to find unique username
while (attempts < 10) {
try {
await pb.collection('users').getFirstListItem(`username="${username}"`);
// Username exists, add random suffix
username = `${generateUsernameFromEmail(email)}${Math.floor(Math.random() * 9999)}`;
attempts++;
} catch {
// Username is available
break;
}
}
// Create user with minimal data - DO NOT provide ID
const userData = {
email,
password: data.password,
passwordConfirm: data.passwordConfirm,
username,
emailVisibility: true,
};
console.log('Creating user with minimal data:', { email, username });
console.log('PocketBase URL:', pb.baseUrl);
const newUser = await pb.collection('users').create(userData);
// Auto-login after registration
try {
await pb.collection('users').authWithPassword(email, data.password);
} catch (loginErr) {
console.error('Auto-login failed:', loginErr);
// User created but login failed - still success
}
return {
success: true,
user: newUser,
};
} catch (error: any) {
console.error('Registration error:', error);
// Parse error details
const errorData = error?.response?.data || error?.data?.data || error?.data || {};
// Log full error for debugging
console.error('Full registration error:', JSON.stringify(errorData, null, 2));
// Handle specific errors
if (errorData.email?.message) {
if (errorData.email.message.includes('already exists')) {
return { success: false, error: 'This email is already registered. Please login instead.' };
}
return { success: false, error: errorData.email.message };
}
if (errorData.username?.message) {
// Try again with different username
console.log('Username conflict, this should not happen');
return { success: false, error: 'Username generation failed. Please try again.' };
}
if (errorData.password?.message) {
return { success: false, error: errorData.password.message };
}
if (errorData.id?.message) {
// ID error - this is the main issue we're trying to fix
console.error('Critical: ID field error detected');
console.error('ID error details:', errorData.id);
// Try to understand the error
if (errorData.id.message.includes('blank') || errorData.id.message.includes('required')) {
console.error('PocketBase is not auto-generating IDs!');
}
return {
success: false,
error: 'Registration system error. Please try again later or contact support.',
};
}
// Check for any field-level errors
for (const field in errorData) {
if (typeof errorData[field] === 'object' && errorData[field]?.message) {
return { success: false, error: `${field}: ${errorData[field].message}` };
}
}
// Generic error
return {
success: false,
error: error?.message || 'Registration failed. Please try again.',
};
}
}
export async function loginUser(email: string, password: string) {
try {
const authData = await pb
.collection('users')
.authWithPassword(email.toLowerCase().trim(), password);
return { success: true, user: authData.record };
} catch (error: any) {
console.error('Login error:', error);
return {
success: false,
error: 'Invalid email or password',
};
}
}

View file

@ -1,219 +0,0 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { cache, cacheKey, CacheKeys } from './cache';
describe('Cache System', () => {
beforeEach(() => {
cache.clear();
});
describe('Basic Cache Operations', () => {
test('should set and get values', () => {
const key = 'test-key';
const value = { data: 'test' };
cache.set(key, value);
const result = cache.get(key);
expect(result).toEqual(value);
});
test('should return null for non-existent keys', () => {
const result = cache.get('non-existent');
expect(result).toBeNull();
});
test('should handle TTL expiration', async () => {
const key = 'ttl-test';
const value = 'test-value';
const shortTTL = 10; // 10ms
cache.set(key, value, shortTTL);
// Should be available immediately
expect(cache.get(key)).toBe(value);
// Wait for TTL to expire
await new Promise((resolve) => setTimeout(resolve, 20));
// Should be null after expiration
expect(cache.get(key)).toBeNull();
});
test('should delete specific keys', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.delete('key1');
expect(cache.get('key1')).toBeNull();
expect(cache.get('key2')).toBe('value2');
});
test('should clear all keys', () => {
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.clear();
expect(cache.get('key1')).toBeNull();
expect(cache.get('key2')).toBeNull();
});
});
describe('Cache Key Generation', () => {
test('should generate cache keys correctly', () => {
const key = cacheKey('user', 123, 'profile');
expect(key).toBe('user:123:profile');
});
test('should handle different data types in keys', () => {
const key = cacheKey('prefix', 42, 'suffix', true);
expect(key).toBe('prefix:42:suffix:true');
});
test('should generate predefined cache keys', () => {
expect(CacheKeys.userLinks('user123')).toBe('user:user123:links');
expect(CacheKeys.linkStats('link456')).toBe('link:link456:stats');
expect(CacheKeys.userProfile('john')).toBe('profile:john');
expect(CacheKeys.linkRedirect('abc123')).toBe('redirect:abc123');
});
});
describe('Cache Cleanup', () => {
test('should cleanup expired entries', async () => {
const shortTTL = 10; // 10ms
cache.set('key1', 'value1', shortTTL);
cache.set('key2', 'value2', 60000); // 1 minute
// Wait for first key to expire
await new Promise((resolve) => setTimeout(resolve, 20));
cache.cleanup();
expect(cache.get('key1')).toBeNull();
expect(cache.get('key2')).toBe('value2');
});
});
describe('Type Safety', () => {
test('should handle typed values correctly', () => {
interface TestData {
id: string;
name: string;
count: number;
}
const key = 'typed-test';
const value: TestData = { id: '123', name: 'test', count: 42 };
cache.set<TestData>(key, value);
const result = cache.get<TestData>(key);
expect(result).toEqual(value);
expect(result?.id).toBe('123');
expect(result?.count).toBe(42);
});
test('should handle arrays and objects', () => {
const arrayKey = 'array-test';
const arrayValue = [1, 2, 3, 'test'];
const objectKey = 'object-test';
const objectValue = {
nested: { deep: true },
array: [1, 2, 3],
date: new Date().toISOString(),
};
cache.set(arrayKey, arrayValue);
cache.set(objectKey, objectValue);
expect(cache.get(arrayKey)).toEqual(arrayValue);
expect(cache.get(objectKey)).toEqual(objectValue);
});
});
describe('Edge Cases', () => {
test('should handle undefined and null values', () => {
cache.set('null-test', null);
cache.set('undefined-test', undefined);
expect(cache.get('null-test')).toBeNull();
expect(cache.get('undefined-test')).toBeUndefined();
});
test('should handle empty strings and zero values', () => {
cache.set('empty-string', '');
cache.set('zero', 0);
cache.set('false', false);
expect(cache.get('empty-string')).toBe('');
expect(cache.get('zero')).toBe(0);
expect(cache.get('false')).toBe(false);
});
test('should handle concurrent access', () => {
const key = 'concurrent-test';
// Simulate concurrent writes
cache.set(key, 'value1');
cache.set(key, 'value2');
cache.set(key, 'value3');
// Last write should win
expect(cache.get(key)).toBe('value3');
});
test('should handle very long keys', () => {
const longKey = 'a'.repeat(1000);
const value = 'test-value';
cache.set(longKey, value);
expect(cache.get(longKey)).toBe(value);
});
});
describe('Performance', () => {
test('should handle large number of entries efficiently', () => {
const startTime = Date.now();
const entryCount = 1000;
// Set many entries
for (let i = 0; i < entryCount; i++) {
cache.set(`key-${i}`, `value-${i}`);
}
// Get many entries
for (let i = 0; i < entryCount; i++) {
expect(cache.get(`key-${i}`)).toBe(`value-${i}`);
}
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete within reasonable time (1 second for 1000 entries)
expect(duration).toBeLessThan(1000);
});
test('should handle large values efficiently', () => {
const largeValue = {
data: 'x'.repeat(10000),
array: Array(1000).fill('test'),
nested: {
deep: {
very: {
deep: 'value',
},
},
},
};
const key = 'large-value-test';
cache.set(key, largeValue);
const result = cache.get(key);
expect(result).toEqual(largeValue);
});
});
});

View file

@ -1,93 +0,0 @@
// Simple in-memory cache with TTL for server-side caching
// In Produktion kann das durch Redis/Valkey ersetzt werden
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class SimpleCache {
private cache = new Map<string, CacheEntry<any>>();
private readonly defaultTTL = 5 * 60 * 1000; // 5 Minuten default
set<T>(key: string, data: T, ttlMs: number = this.defaultTTL): void {
const expiresAt = Date.now() + ttlMs;
this.cache.set(key, { data, expiresAt });
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
// Periodisches Cleanup abgelaufener Einträge
cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(key);
}
}
}
}
// Globale Cache-Instanz
export const cache = new SimpleCache();
// Cleanup alle 10 Minuten
if (typeof setInterval !== 'undefined') {
setInterval(() => cache.cleanup(), 10 * 60 * 1000);
}
// Helper Funktionen für häufige Cache-Pattern
export function cacheKey(...parts: (string | number)[]): string {
return parts.join(':');
}
// Cache-Decorator für async Funktionen
export function cached<T>(keyGenerator: (...args: any[]) => string, ttlMs: number = 5 * 60 * 1000) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]): Promise<T> {
const key = keyGenerator(...args);
const cached = cache.get<T>(key);
if (cached !== null) {
return cached;
}
const result = await originalMethod.apply(this, args);
cache.set(key, result, ttlMs);
return result;
};
return descriptor;
};
}
// Spezielle Cache-Keys für uLoad
export const CacheKeys = {
userLinks: (userId: string) => cacheKey('user', userId, 'links'),
linkStats: (linkId: string) => cacheKey('link', linkId, 'stats'),
userProfile: (username: string) => cacheKey('profile', username),
linkRedirect: (shortCode: string) => cacheKey('redirect', shortCode),
analyticsDaily: (linkId: string, date: string) => cacheKey('analytics', linkId, date),
userCards: (userId: string) => cacheKey('user', userId, 'cards'),
publicCard: (username: string, cardId: string) => cacheKey('public', username, cardId),
} as const;

View file

@ -1,180 +0,0 @@
<script lang="ts">
import { ChevronDown, User, Check, Users, UserPlus } from 'lucide-svelte';
import { accountsStore, currentViewingAccount } from '$lib/stores/accounts';
import { clickOutside } from '$lib/actions/clickOutside';
import { fade, scale } from 'svelte/transition';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as m from '$paraglide/messages';
interface Props {
position?: 'right' | 'left-outside';
}
let { position = 'right' }: Props = $props();
let showDropdown = $state(false);
let accounts = $derived($accountsStore);
let currentAccount = $derived($currentViewingAccount);
function toggleDropdown() {
showDropdown = !showDropdown;
}
function handleClickOutside() {
showDropdown = false;
}
async function switchToAccount(accountId: string) {
await accountsStore.switchViewingContext(accountId);
showDropdown = false;
// Force page reload to update data
window.location.reload();
}
function getAccountDisplayName(account: any): string {
if (!account) return 'Unknown';
return account.name || account.username || account.email;
}
function addAccount() {
showDropdown = false;
// Navigate to login page for adding existing account
goto('/login?additional=true');
}
</script>
<div class="relative" use:clickOutside={handleClickOutside}>
<button
onclick={toggleDropdown}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
aria-expanded={showDropdown}
aria-haspopup="true"
>
{#if currentAccount}
{#if accounts.viewingAs !== accounts.currentUser?.id}
<Users class="h-4 w-4 text-purple-500" />
{:else}
<User class="h-4 w-4 text-theme-text-muted" />
{/if}
<span class="max-w-[150px] truncate">
{getAccountDisplayName(currentAccount)}
</span>
<ChevronDown
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
? 'rotate-180'
: ''}"
/>
{:else if accounts.currentUser}
<User class="h-4 w-4 text-theme-text-muted" />
<span class="max-w-[150px] truncate">
{getAccountDisplayName(accounts.currentUser)}
</span>
<ChevronDown
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
? 'rotate-180'
: ''}"
/>
{/if}
</button>
{#if showDropdown}
<div
transition:scale={{ duration: 200, start: 0.95 }}
class="absolute z-50 {position === 'left-outside'
? 'left-0 top-full mt-2'
: 'right-0 mt-2'} w-72 {position === 'left-outside'
? 'origin-top-left'
: 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
>
<!-- Personal Account Section -->
{#if accounts.currentUser}
<div class="border-b border-theme-border p-2">
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
{m.account_my_account()}
</div>
<button
onclick={() => switchToAccount(accounts.currentUser.id)}
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<User class="h-5 w-5 text-theme-text-muted" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-theme-text">
{getAccountDisplayName(accounts.currentUser)}
</div>
<div class="text-xs text-theme-text-muted">
@{accounts.currentUser.username}
</div>
</div>
{#if accounts.viewingAs === accounts.currentUser.id}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
</div>
{/if}
<!-- Team Accounts Section -->
{#if accounts.sharedAccounts && accounts.sharedAccounts.length > 0}
<div class="border-b border-theme-border p-2">
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
{m.account_team_accounts()}
</div>
{#each accounts.sharedAccounts as shared}
{#if shared.expand?.owner}
<button
onclick={() => switchToAccount(shared.owner)}
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<Users class="h-5 w-5 text-purple-500" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-theme-text">
{getAccountDisplayName(shared.expand.owner)}
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-theme-text-muted">
@{shared.expand.owner.username}
</span>
<span
class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-900/20 dark:text-purple-400"
>
{m.account_team_member()}
</span>
</div>
</div>
{#if accounts.viewingAs === shared.owner}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
{/if}
{/each}
</div>
{:else}
<!-- Empty State for Team Accounts -->
<div class="border-b border-theme-border p-4">
<p class="text-center text-xs text-theme-text-muted">
{m.account_no_team_accounts()}
</p>
<p class="mt-1 text-center text-xs text-theme-text-muted">
{m.account_team_invite_info()}
</p>
</div>
{/if}
<!-- Add Account Button -->
<div class="border-t border-theme-border p-2">
<button
onclick={addAccount}
class="hover:bg-theme-primary/10 flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all"
>
<div class="bg-theme-primary/10 flex h-5 w-5 items-center justify-center rounded-full">
<UserPlus class="h-3.5 w-3.5 text-theme-primary" />
</div>
<span class="text-theme-text">{m.account_add_account()}</span>
</button>
</div>
</div>
{/if}
</div>
<style>
/* Custom styles if needed */
</style>

View file

@ -1,48 +0,0 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends HTMLButtonAttributes {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
}
let {
variant = 'secondary',
size = 'md',
fullWidth = false,
class: className = '',
children,
...restProps
}: Props = $props();
const variantClasses = {
primary: 'bg-purple-600 text-white hover:bg-purple-700',
secondary: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover',
ghost: 'bg-transparent text-theme-text hover:bg-theme-surface',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
const sizeClasses = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base',
};
const classes = $derived(
`
${variantClasses[variant]}
${sizeClasses[size]}
${fullWidth ? 'w-full' : ''}
rounded-lg transition-colors
${className}
`.trim()
);
</script>
<button class={classes} {...restProps}>
{@render children()}
</button>

View file

@ -1,186 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
export interface TableColumn {
key: string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
hideOnMobile?: boolean;
hideOnTablet?: boolean;
}
interface Props {
columns: TableColumn[];
items: any[];
title?: string;
mobileBreakpoint?: number;
tabletBreakpoint?: number;
emptyMessage?: string;
children: Snippet<[any, TableColumn[]]>;
mobileCard?: Snippet<[any]>;
}
let {
columns,
items,
title,
mobileBreakpoint = 768,
tabletBreakpoint = 1024,
emptyMessage = 'No items found',
children,
mobileCard,
}: Props = $props();
let windowWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1200);
let isMobile = $derived(windowWidth < mobileBreakpoint);
let isTablet = $derived(windowWidth >= mobileBreakpoint && windowWidth < tabletBreakpoint);
let isDesktop = $derived(windowWidth >= tabletBreakpoint);
// Filter columns based on screen size
let visibleColumns = $derived(
columns.filter((col) => {
if (isMobile && col.hideOnMobile) return false;
if (isTablet && col.hideOnTablet) return false;
return true;
})
);
// Generate grid template columns
let gridTemplate = $derived(() => {
if (isMobile) return 'grid-cols-1';
const widths = visibleColumns.map((col) => {
if (col.width === 'flex') return '1fr';
if (col.width) return col.width;
return 'auto';
});
// For Tailwind, we need to use predefined classes or inline styles
return widths.join(' ');
});
onMount(() => {
const handleResize = () => {
windowWidth = window.innerWidth;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
function getAlignment(align?: string) {
switch (align) {
case 'center':
return 'text-center justify-center';
case 'right':
return 'text-right justify-end';
default:
return 'text-left justify-start';
}
}
</script>
{#if items && items.length > 0}
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
{#if title}
<div class="border-b border-theme-border bg-theme-surface-hover px-6 py-4">
<h2 class="text-xl font-semibold text-theme-text">
{title}
</h2>
</div>
{/if}
{#if isMobile && renderMobileCard}
<!-- Mobile Card View -->
<div class="divide-y divide-theme-border">
{#each items as item}
{@html renderMobileCard(item)}
{/each}
</div>
{:else}
<!-- Desktop/Tablet Table View -->
<!-- Table Header -->
<div
class="hidden items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text md:grid"
style="grid-template-columns: {visibleColumns
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
.join(' ')}"
>
{#each visibleColumns as column}
<div class={getAlignment(column.align)}>
{column.label}
</div>
{/each}
</div>
<!-- Table Body -->
<div class="divide-y divide-theme-border">
{#each items as item}
<!-- Desktop Row -->
<div
class="hidden items-center gap-4 px-6 py-4 transition-colors hover:bg-theme-surface-hover md:grid"
style="grid-template-columns: {visibleColumns
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
.join(' ')}"
>
{#each visibleColumns as column}
<div class={getAlignment(column.align)}>
{#if column.render}
{@html column.render(item)}
{:else if column.key.includes('.')}
<!-- Handle nested properties -->
{@const keys = column.key.split('.')}
{@const value = keys.reduce((obj, key) => obj?.[key], item)}
{value || '-'}
{:else}
{item[column.key] || '-'}
{/if}
</div>
{/each}
</div>
<!-- Mobile Card -->
<div
class="space-y-3 bg-theme-surface p-4 transition-colors hover:bg-theme-surface-hover md:hidden"
>
{#if renderMobileCard}
{@html renderMobileCard(item)}
{:else}
<!-- Default mobile layout -->
<div class="space-y-2">
{#each columns.filter((col) => !col.hideOnMobile) as column}
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-theme-text-muted">{column.label}:</span>
<span class="text-theme-text">
{#if column.render}
{@html column.render(item)}
{:else if column.key.includes('.')}
{@const keys = column.key.split('.')}
{@const value = keys.reduce((obj, key) => obj?.[key], item)}
{value || '-'}
{:else}
{item[column.key] || '-'}
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text-muted">
{emptyMessage}
</p>
</div>
{/if}
<style>
/* Add any custom styles if needed */
</style>

View file

@ -1,197 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
interface DropdownItem {
label?: string;
icon?: string;
color?: string;
action?: () => void;
href?: string;
type?: 'button' | 'submit' | 'link' | 'form';
formAction?: string;
formMethod?: 'POST' | 'GET';
formData?: Record<string, string>;
divider?: boolean;
enhanceOptions?: (options: any) => any;
}
interface Props {
items: DropdownItem[];
buttonText?: string;
buttonIcon?: string;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
position?: 'left' | 'right';
class?: string;
}
let {
items,
buttonText = 'Actions',
buttonIcon,
variant = 'secondary',
size = 'md',
position = 'right',
class: className = '',
}: Props = $props();
let isOpen = $state(false);
let dropdownRef: HTMLDivElement;
let buttonRef: HTMLButtonElement;
let menuRef: HTMLDivElement;
let dropdownPosition = $state({ top: 0, left: 0 });
function toggleDropdown() {
if (!isOpen && buttonRef) {
const rect = buttonRef.getBoundingClientRect();
dropdownPosition = {
top: rect.bottom + window.scrollY + 8,
left: position === 'left' ? rect.left + window.scrollX : rect.right + window.scrollX - 192,
};
}
isOpen = !isOpen;
}
function closeDropdown() {
isOpen = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
// Check if click is outside both the dropdown container and the menu
if (dropdownRef && !dropdownRef.contains(target) && menuRef && !menuRef.contains(target)) {
closeDropdown();
}
}
function handleItemClick(item: DropdownItem) {
if (item.action) {
item.action();
}
closeDropdown();
}
function getItemClasses(color?: string) {
const baseClasses =
'flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors';
if (!color) return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
switch (color) {
case '#dc2626':
return `${baseClasses} text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20`;
case '#ea580c':
return `${baseClasses} text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20`;
case '#16a34a':
return `${baseClasses} text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20`;
case '#2563eb':
return `${baseClasses} text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20`;
case '#9333ea':
return `${baseClasses} text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20`;
case '#4f46e5':
return `${baseClasses} text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20`;
default:
return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
}
}
onMount(() => {
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
});
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2 text-base',
lg: 'px-4 py-3 text-lg',
};
const variantClasses = {
primary: 'bg-theme-primary text-white hover:bg-theme-primary-hover',
secondary:
'bg-theme-surface border border-theme-border text-theme-text hover:bg-theme-surface-hover',
ghost: 'text-theme-text hover:bg-theme-surface-hover',
};
const positionClasses = {
left: 'left-0',
right: 'right-0',
};
</script>
<div class="relative {className}" bind:this={dropdownRef}>
<button
bind:this={buttonRef}
onclick={toggleDropdown}
class="inline-flex items-center gap-2 rounded-lg font-medium transition-colors {sizeClasses[
size
]} {variantClasses[variant]}"
type="button"
>
{#if buttonIcon}
{@html buttonIcon}
{/if}
<span>{buttonText}</span>
<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="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div
bind:this={menuRef}
class="fixed z-[9999] min-w-[12rem] rounded-lg border border-theme-border bg-theme-surface shadow-lg"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px"
>
{#each items as item, index}
{#if item.divider}
<div class="border-t border-theme-border"></div>
{:else if item.type === 'form'}
<form
method={item.formMethod || 'POST'}
action={item.formAction}
use:enhance={item.enhanceOptions ||
(() => {
return async ({ update }) => {
closeDropdown();
await update();
};
})}
>
{#if item.formData}
{#each Object.entries(item.formData) as [name, value]}
<input type="hidden" {name} {value} />
{/each}
{/if}
<button type="submit" class={getItemClasses(item.color)}>
{#if item.icon}
{@html item.icon}
{/if}
{item.label}
</button>
</form>
{:else if item.href}
<a href={item.href} onclick={() => closeDropdown()} class={getItemClasses(item.color)}>
{#if item.icon}
{@html item.icon}
{/if}
{item.label}
</a>
{:else}
<button
onclick={() => handleItemClick(item)}
type={item.type || 'button'}
class={getItemClasses(item.color)}
>
{#if item.icon}
{@html item.icon}
{/if}
{item.label}
</button>
{/if}
{/each}
</div>
{/if}
</div>

View file

@ -1,644 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import * as m from '$paraglide/messages';
import { onMount } from 'svelte';
import { themeStore } from '$lib/themes/theme-store';
import { themes } from '$lib/themes/presets';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
import NotificationBell from './NotificationBell.svelte';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
interface Props {
user?: {
email: string;
username?: string;
} | null;
}
let { user }: Props = $props();
let collapsed = $state(false);
let mounted = $state(false);
let showThemeDropdown = $state(false);
// Subscribe to workspace stores for reactive URL updates
let currentWorkspaceId = $state(activeWorkspace.getId());
let currentWorkspaceData = $state(activeWorkspace.getData());
// Subscribe to changes
$effect(() => {
const unsubId = activeWorkspace.id.subscribe((id) => {
currentWorkspaceId = id;
});
const unsubData = activeWorkspace.data.subscribe((data) => {
currentWorkspaceData = data;
});
return () => {
unsubId();
unsubData();
};
});
// Reactive URL builder
function buildUrl(path: string): string {
if (currentWorkspaceId && !path.includes('workspace=')) {
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}workspace=${currentWorkspaceId}`;
}
return path;
}
let themeDropdownElement: HTMLDivElement;
function isActive(path: string): boolean {
const currentPath = $page.url.pathname;
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
return cleanPath === cleanHref;
}
function toggleCollapse() {
collapsed = !collapsed;
if (typeof window !== 'undefined') {
localStorage.setItem('sidebar-collapsed', collapsed.toString());
// Dispatch storage event for other components
window.dispatchEvent(new Event('storage'));
}
}
function toggleThemeDropdown(event: MouseEvent) {
event.stopPropagation();
showThemeDropdown = !showThemeDropdown;
}
function selectTheme(themeId: string, event: MouseEvent) {
event.stopPropagation();
themeStore.setPreset(themeId);
showThemeDropdown = false;
}
function toggleDarkMode(event: MouseEvent) {
event.stopPropagation();
themeStore.toggle();
}
function handleClickOutside(event: MouseEvent) {
if (themeDropdownElement && !themeDropdownElement.contains(event.target as Node)) {
showThemeDropdown = false;
}
}
$effect(() => {
if (showThemeDropdown) {
const timer = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
}
});
onMount(() => {
mounted = true;
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('sidebar-collapsed');
if (stored !== null) {
collapsed = stored === 'true';
}
}
});
</script>
{#if user && mounted}
<aside
class="sidebar-transition animate-slide-in fixed bottom-4 left-4 top-4 z-40 hidden flex-col lg:flex"
class:w-64={!collapsed}
class:w-20={collapsed}
>
<!-- Glassmorphism Background -->
<div
class="absolute inset-0 rounded-2xl border transition-all duration-300 {collapsed
? 'border-transparent bg-transparent shadow-none backdrop-blur-none'
: 'border-theme-border/30 bg-theme-surface/80 shadow-2xl backdrop-blur-xl'}"
></div>
<!-- Content Container -->
<div class="relative flex h-full flex-col p-4">
<!-- Logo Section -->
<div
class="mb-8"
class:flex={!collapsed}
class:flex-col={collapsed}
class:items-center={collapsed}
class:justify-between={!collapsed}
>
<a
href="/"
class="flex items-center gap-3 transition-opacity hover:opacity-80"
class:justify-center={collapsed}
class:mb-4={collapsed}
title="uload"
>
<svg
class="h-8 w-8 flex-shrink-0 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>
{#if !collapsed}
<span class="text-transition text-xl font-bold text-theme-text">uload</span>
{/if}
</a>
<!-- Collapse Toggle -->
<button
onclick={toggleCollapse}
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
class:mx-auto={collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg
class="icon-transition h-5 w-5"
class:rotate-180={collapsed}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
</svg>
</button>
</div>
<!-- Notifications & Account Switcher -->
{#if !collapsed}
<div class="mb-6 space-y-3">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wider text-theme-text-muted"
>Benachrichtigungen</span
>
<NotificationBell position="left-outside" />
</div>
<WorkspaceSwitcher position="left-outside" />
</div>
{:else}
<div class="mb-6 flex justify-center">
<NotificationBell position="left-outside" />
</div>
{/if}
<!-- Navigation Items -->
<nav class="flex-1 space-y-1">
<a
href={buildUrl('/my/links')}
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/links'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Links' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
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>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Links</span>
{/if}
</a>
<a
href={buildUrl('/my/cards')}
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/cards'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Cards' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
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 2"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Cards</span>
{/if}
</a>
<a
href={buildUrl('/my/tags')}
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/tags'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Tags' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
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>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Tags</span>
{/if}
</a>
<a
href="/template-store"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/template-store'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Templates' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
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>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Templates</span>
{/if}
</a>
</nav>
<!-- Bottom Section -->
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
<!-- Theme Toggle -->
<div class="relative" bind:this={themeDropdownElement}>
<button
onclick={(e) => toggleThemeDropdown(e)}
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
title={collapsed ? 'Theme' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if themeStore.isDark}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
{/if}
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Theme</span>
{/if}
</button>
{#if showThemeDropdown}
<div
class="absolute bottom-0 left-full z-50 ml-2 w-64 rounded-lg border border-theme-border bg-theme-surface shadow-lg"
>
<!-- Dark Mode Toggle -->
<div class="border-b border-theme-border p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-theme-text">Dark Mode</span>
<button
onclick={(e) => toggleDarkMode(e)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {themeStore.isDark
? 'bg-theme-accent'
: 'bg-theme-border'}"
aria-label="Toggle dark mode"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {themeStore.isDark
? 'translate-x-6'
: 'translate-x-1'}"
/>
</button>
</div>
</div>
<!-- Theme Selection -->
<div class="p-2">
<p class="mb-2 px-2 text-xs font-medium text-theme-text-muted">Choose Theme</p>
<div class="space-y-1">
{#each Object.values(themes) as theme}
<button
onclick={(e) => selectTheme(theme.id, e)}
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover {themeStore.preset ===
theme.id
? 'bg-theme-surface-hover'
: ''}"
>
<div class="flex items-center gap-3">
<!-- Theme Preview Colors -->
<div class="flex gap-1">
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.primary
: theme.colors.light.primary}"
/>
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.accent
: theme.colors.light.accent}"
/>
</div>
<div>
<span class="block text-sm font-medium text-theme-text">{theme.name}</span
>
<span class="block text-xs text-theme-text-muted"
>{theme.description}</span
>
</div>
</div>
{#if themeStore.preset === theme.id}
<svg
class="h-4 w-4 text-theme-accent"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Settings -->
<a
href="/settings"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Settings' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Settings</span>
{/if}
</a>
<!-- Team -->
<a
href="/settings/team"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings/team'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Team' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Team</span>
{/if}
</a>
<!-- Pricing -->
<a
href="/pricing"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/pricing'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? m.nav_pricing() : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text"
>{m.nav_pricing() || 'Pricing'}</span
>
{/if}
</a>
<!-- Profile -->
{#if user.username}
<a
href="/p/{user.username}"
target="_blank"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
title={collapsed ? m.nav_profile() : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">{m.nav_profile()}</span>
{/if}
</a>
{/if}
<!-- User Info -->
{#if !collapsed}
<div class="truncate px-3 py-2 text-xs text-theme-text-muted">
{user.email}
</div>
{/if}
<!-- Logout -->
<form method="POST" action="/login?/logout" class="w-full">
<button
type="submit"
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10 dark:text-red-500"
title={collapsed ? m.nav_logout() : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium">{m.nav_logout()}</span>
{/if}
</button>
</form>
</div>
</div>
</aside>
{/if}
<style>
.sidebar-transition {
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.icon-transition {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.text-transition {
transition:
opacity 0.2s ease-in-out,
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.active-indicator {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 70%;
background: var(--theme-primary);
border-radius: 0 4px 4px 0;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.active .active-indicator {
opacity: 1;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slideIn 0.4s ease-out;
}
</style>

View file

@ -1,201 +0,0 @@
<script lang="ts">
import * as m from '$paraglide/messages';
let currentYear = new Date().getFullYear();
</script>
<footer class="border-t border-theme-border bg-theme-surface">
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<!-- Company Info -->
<div class="col-span-1 md:col-span-2">
<div class="mb-4 flex items-center space-x-2">
<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>
</div>
<p class="max-w-md text-theme-text-muted">
Verkürzen Sie Ihre URLs schnell und einfach. Mit erweiterten Funktionen für professionelle
Nutzer.
</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
Navigation
</h3>
<ul class="space-y-3">
<li>
<a href="/" class="text-theme-text-muted transition-colors hover:text-theme-text">
Home
</a>
</li>
<li>
<a
href="/dashboard"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
{m.nav_dashboard()}
</a>
</li>
<li>
<a
href="/dashboard/tags"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Tags
</a>
</li>
<li>
<a
href="/dashboard/cards"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Cards
</a>
</li>
<li>
<a
href="/template-store"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Templates
</a>
</li>
<li>
<a
href="/features"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Features
</a>
</li>
<li>
<a
href="/pricing"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
</a>
</li>
<li>
<a href="/blog" class="text-theme-text-muted transition-colors hover:text-theme-text">
Blog
</a>
</li>
<li>
<a href="/about" class="text-theme-text-muted transition-colors hover:text-theme-text">
About
</a>
</li>
<li>
<a
href="/settings"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Settings
</a>
</li>
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
Rechtliches
</h3>
<ul class="space-y-3">
<li>
<a
href="/impressum"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Impressum
</a>
</li>
<li>
<a
href="/datenschutz"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Datenschutz
</a>
</li>
<li>
<a href="/agb" class="text-theme-text-muted transition-colors hover:text-theme-text">
AGB
</a>
</li>
<li>
<a
href="/sicherheit"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Sicherheit
</a>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-8 border-t border-theme-border pt-8">
<div class="flex flex-col items-center justify-between space-y-4 sm:flex-row sm:space-y-0">
<p class="text-sm text-theme-text-muted">
© {currentYear} uload. Alle Rechte vorbehalten.
</p>
<!-- Social Links (optional) -->
<div class="flex space-x-6">
<a
href="https://twitter.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="Twitter"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
/>
</svg>
</a>
<a
href="https://github.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="GitHub"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
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"
clip-rule="evenodd"
/>
</svg>
</a>
<a
href="https://linkedin.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="LinkedIn"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>

View file

@ -1,92 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import '$lib/i18n';
let showDropdown = $state(false);
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
];
let currentLanguage = $state(languages[0]);
// Get current language on mount
$effect(() => {
if (browser) {
const currentCode = get(locale) || 'en';
currentLanguage = languages.find((lang) => lang.code === currentCode) || languages[0];
}
});
function changeLanguage(langCode: string) {
if (browser) {
// Save preference
localStorage.setItem('preferred-language', langCode);
// Update svelte-i18n locale
locale.set(langCode);
// Update current language display
currentLanguage = languages.find((lang) => lang.code === langCode) || languages[0];
// Close dropdown
showDropdown = false;
}
}
</script>
<div class="relative">
<button
onclick={() => (showDropdown = !showDropdown)}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
aria-label="Change language"
>
<span class="text-lg">{currentLanguage.flag}</span>
<span class="hidden sm:inline">{currentLanguage.name}</span>
<svg
class="h-4 w-4 transition-transform {showDropdown ? '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 showDropdown}
<div
class="absolute right-0 z-50 mt-2 w-48 rounded-lg border border-theme-border bg-white shadow-lg dark:bg-gray-800"
>
{#each languages as lang}
<button
onclick={() => changeLanguage(lang.code)}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 {lang.code ===
currentLanguage.code
? 'bg-gray-50 dark:bg-gray-700/50'
: ''}"
>
<span class="text-lg">{lang.flag}</span>
<span class="text-theme-text">{lang.name}</span>
{#if lang.code === currentLanguage.code}
<svg class="ml-auto h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<svelte:window
onclick={(e) => {
// Close dropdown when clicking outside
if (showDropdown && !(e.target as HTMLElement)?.closest('.relative')) {
showDropdown = false;
}
}}
/>

View file

@ -1,92 +0,0 @@
<script lang="ts">
import { getLimitDisplayInfo } from '$lib/services/link-limits';
import { Check, AlertTriangle, X } from 'lucide-svelte';
let { user } = $props();
let usageInfo = $derived(getLimitDisplayInfo(user));
let barColor = $derived(() => {
switch (usageInfo.status) {
case 'danger':
return 'bg-red-500';
case 'warning':
return 'bg-yellow-500';
default:
return 'bg-blue-500';
}
});
let textColor = $derived(() => {
switch (usageInfo.status) {
case 'danger':
return 'text-red-700';
case 'warning':
return 'text-yellow-700';
default:
return 'text-blue-700';
}
});
let icon = $derived(() => {
switch (usageInfo.status) {
case 'danger':
return X;
case 'warning':
return AlertTriangle;
default:
return Check;
}
});
</script>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<svelte:component this={icon} class="h-4 w-4 {textColor}" />
<span class="text-sm font-medium text-gray-900 dark:text-white">
{#if usageInfo.unlimited}
Unbegrenzte Links
{:else}
Link-Nutzung diesen Monat
{/if}
</span>
</div>
{#if !usageInfo.unlimited}
<span class="text-sm {textColor}">
{usageInfo.current} / {usageInfo.limit}
</span>
{/if}
</div>
{#if !usageInfo.unlimited}
<!-- Progress Bar -->
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full transition-all duration-300 {barColor}"
style="width: {Math.min(usageInfo.percentage, 100)}%"
></div>
</div>
<!-- Status Messages -->
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
{#if usageInfo.status === 'danger'}
<span class="font-medium text-red-600 dark:text-red-400">
Monatslimit erreicht! Upgrade für mehr Links.
</span>
{:else if usageInfo.status === 'warning'}
<span class="font-medium text-yellow-600 dark:text-yellow-400">
{usageInfo.limit - usageInfo.current} Links verbleibend
</span>
{:else}
<span class="text-green-600 dark:text-green-400">
{usageInfo.limit - usageInfo.current} Links verbleibend
</span>
{/if}
</div>
{:else}
<div class="text-xs font-medium text-green-600 dark:text-green-400">
🎉 Du hast unbegrenzten Zugang zu allen Features!
</div>
{/if}
</div>

View file

@ -1,306 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import * as m from '$paraglide/messages';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
interface Props {
user?: {
email: string;
username?: string;
} | null;
open?: boolean;
onClose?: () => void;
}
let { user, open = false, onClose }: Props = $props();
function isActive(path: string): boolean {
const currentPath = $page.url.pathname;
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
return cleanPath === cleanHref;
}
function handleLinkClick() {
if (onClose) onClose();
}
</script>
{#if user && open}
<!-- Backdrop -->
<div class="fixed inset-0 z-50 bg-black/50 lg:hidden" onclick={onClose}></div>
<!-- Sidebar -->
<aside
class="slide-in fixed bottom-0 left-0 top-0 z-50 w-72 bg-theme-surface shadow-2xl lg:hidden"
>
<div class="flex h-full flex-col p-4">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div class="flex items-center gap-3">
<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>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Workspace Switcher -->
<div class="mb-6">
<WorkspaceSwitcher />
</div>
<!-- Navigation -->
<nav class="flex-1 space-y-1">
<a
href="/my"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
<span class="font-medium">{m.nav_dashboard()}</span>
</a>
<a
href="/my/links"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/links'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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">Links</span>
</a>
<a
href="/my/cards"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/cards'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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 2"
/>
</svg>
<span class="font-medium">Cards</span>
</a>
<a
href="/my/tags"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/my/tags'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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">Tags</span>
</a>
<a
href="/template-store"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/template-store'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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 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>
<span class="font-medium">Templates</span>
</a>
<div class="border-theme-border/30 my-2 border-t"></div>
<a
href="/pricing"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/pricing'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-medium">{m.nav_pricing() || 'Pricing'}</span>
</a>
{#if user.username}
<a
href="/p/{user.username}"
onclick={handleLinkClick}
target="_blank"
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-text transition-all hover:bg-theme-surface-hover"
>
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span class="font-medium">{m.nav_profile()}</span>
</a>
{/if}
</nav>
<!-- Bottom Section -->
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
<a
href="/settings"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span class="font-medium">Settings</span>
</a>
<a
href="/settings/team"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings/team'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span class="font-medium">Team</span>
</a>
<div class="px-3 py-2 text-sm text-theme-text-muted">
{user.email}
</div>
<form method="POST" action="/login?/logout" class="w-full">
<button
type="submit"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span class="font-medium">{m.nav_logout()}</span>
</button>
</form>
</div>
</div>
</aside>
{/if}
<style>
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.slide-in {
animation: slideInLeft 0.3s ease-out;
}
</style>

View file

@ -1,840 +0,0 @@
<script lang="ts">
import { trackAuth } from '$lib/analytics';
import ThemeDropdown from '$lib/components/ThemeDropdown.svelte';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import WorkspaceSwitcher from '$lib/components/WorkspaceSwitcher.svelte';
import * as m from '$paraglide/messages';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { themeStore } from '$lib/themes/theme-store';
import { themes } from '$lib/themes/presets';
import { toastMessages } from '$lib/services/toast';
interface Props {
user?: {
email: string;
username?: string;
} | null;
currentPath?: string;
}
let { user, currentPath = '' }: Props = $props();
let mobileMenuOpen = $state(false);
let scrollProgress = $state(0);
let isInFooter = $state(false);
let showThemeMenu = $state(false);
function handleLogout() {
trackAuth('logout');
toastMessages.logoutSuccess();
}
function toggleThemeMenu() {
showThemeMenu = !showThemeMenu;
}
function selectTheme(themeId: string) {
themeStore.setPreset(themeId);
showThemeMenu = false;
}
function toggleDarkMode() {
themeStore.toggle();
}
function isActive(path: string): boolean {
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
return cleanPath === cleanHref;
}
function updateScrollProgress() {
const footer = document.querySelector('footer');
const footerHeight = footer ? footer.offsetHeight : 0;
const totalHeight = document.documentElement.scrollHeight;
const scrollableHeight = totalHeight - window.innerHeight - footerHeight;
const scrollPosition = window.scrollY;
scrollProgress = scrollableHeight > 0 ? Math.min(scrollPosition / scrollableHeight, 1) : 0;
const footerTop = footer ? footer.getBoundingClientRect().top : Infinity;
isInFooter = footerTop <= window.innerHeight;
}
function getProgressColor(): string {
if (isInFooter) {
return 'rgba(148, 163, 184, 0.3)';
} else if (scrollProgress < 0.25) {
const t = scrollProgress / 0.25;
return `rgba(${Math.round(255)}, ${Math.round(0 + 165 * t)}, 0, 0.4)`;
} else if (scrollProgress < 0.5) {
const t = (scrollProgress - 0.25) / 0.25;
return `rgba(255, ${Math.round(165 + 90 * t)}, 0, 0.4)`;
} else if (scrollProgress < 0.75) {
const t = (scrollProgress - 0.5) / 0.25;
return `rgba(${Math.round(255 - 82 * t)}, 255, ${Math.round(47 * t)}, 0.4)`;
} else {
const t = (scrollProgress - 0.75) / 0.25;
return `rgba(${Math.round(173 - 173 * t)}, 255, ${Math.round(47 - 47 * t)}, 0.4)`;
}
}
onMount(() => {
updateScrollProgress();
window.addEventListener('scroll', updateScrollProgress);
return () => window.removeEventListener('scroll', updateScrollProgress);
});
$effect(() => {
if (mobileMenuOpen) {
if (typeof document !== 'undefined') {
document.body.style.overflow = 'hidden';
const main = document.querySelector('main');
const footer = document.querySelector('footer');
if (main) main.classList.add('brightness-[0.3]', 'transition-all', 'duration-200');
if (footer) footer.classList.add('brightness-[0.3]', 'transition-all', 'duration-200');
}
} else {
if (typeof document !== 'undefined') {
document.body.style.overflow = '';
const main = document.querySelector('main');
const footer = document.querySelector('footer');
if (main) main.classList.remove('brightness-[0.3]');
if (footer) footer.classList.remove('brightness-[0.3]');
}
}
});
</script>
<!-- Desktop Navigation -->
<nav
class="bg-theme-surface/80 sticky top-0 z-50 hidden border-b border-theme-border shadow-sm backdrop-blur-xl md:block"
>
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<div class="z-10 flex-shrink-0">
<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>
<!-- Desktop Navigation - Absolutely Centered -->
<div class="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 xl:flex">
<div class="flex items-center gap-6">
{#if user}
<a
href="/my/links"
class="transition-all {isActive('/my/links')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Links
</a>
<a
href="/my/cards"
class="transition-all {isActive('/my/cards')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Cards
</a>
<a
href="/my/tags"
class="transition-all {isActive('/my/tags')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Tags
</a>
<a
href="/template-store"
class="transition-all {isActive('/template-store')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Templates
</a>
<a
href="/pricing"
class="transition-all {isActive('/pricing')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
</a>
{#if user.username}
<a
href="/p/{user.username}"
target="_blank"
class="text-theme-text-muted transition-all hover:text-theme-text hover:underline"
>
{m.nav_profile()}
</a>
{/if}
{:else}
<a
href="/features"
class="transition-all {isActive('/features')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Features
</a>
<a
href="/pricing"
class="transition-all {isActive('/pricing')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
</a>
<a
href="/blog"
class="transition-all {isActive('/blog')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
Blog
</a>
<a
href="/about"
class="transition-all {isActive('/about')
? 'text-theme-text underline'
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
>
About
</a>
{/if}
</div>
</div>
<!-- Right side: Actions -->
<div class="z-10 flex flex-shrink-0 items-center gap-2">
{#if !user}
<a
href="/login"
class="rounded-lg px-4 py-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
>
{m.nav_login()}
</a>
<a
href="/register"
class="rounded-lg bg-theme-primary px-6 py-2 font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
>
{m.nav_register()}
</a>
{:else}
<!-- Account Switcher for logged-in users -->
<WorkspaceSwitcher />
{/if}
<LanguageSwitcher />
<ThemeDropdown />
<!-- Menu Button -->
<button
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text xl:hidden"
aria-label="Menu"
aria-expanded={mobileMenuOpen}
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if mobileMenuOpen}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
{/if}
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile Navigation - Bottom Pill -->
<nav
class="fixed bottom-4 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 md:hidden"
style="--scroll-progress: {scrollProgress}; --progress-color: {getProgressColor()}"
>
<!-- Progress border layer -->
<div class="absolute -inset-[5px] z-[-1] overflow-hidden rounded-full p-[5px]">
<div
class="scroll-progress-indicator absolute inset-0 rounded-full"
style="--scroll-progress: {scrollProgress}"
></div>
</div>
<!-- Main navigation content -->
<div
class="border-theme-border/20 bg-theme-surface/95 relative z-20 flex overflow-hidden rounded-full border-2 shadow-2xl backdrop-blur-xl transition-all duration-300 before:pointer-events-none before:absolute before:inset-0 before:rounded-full before:bg-gradient-to-t before:from-black/20 before:to-transparent"
>
<!-- Left Half: Logo -->
<a
href="/"
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
>
<svg class="h-6 w-6 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-lg font-semibold text-theme-text">uload</span>
</a>
<!-- Divider -->
<div class="bg-theme-border/30 relative z-10 w-px"></div>
<!-- Right Half: Menu -->
<button
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
aria-label="Menu"
aria-expanded={mobileMenuOpen}
>
<span class="text-lg font-medium text-theme-text">Menu</span>
<svg class="h-6 w-6 text-theme-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if mobileMenuOpen}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
{/if}
</svg>
</button>
</div>
</nav>
<!-- Mobile Menu Backdrop -->
{#if mobileMenuOpen}
<button
class="z-35 fixed inset-0 bg-black/40 md:hidden"
onclick={() => (mobileMenuOpen = false)}
onkeydown={(e) => e.key === 'Escape' && (mobileMenuOpen = false)}
aria-label="Close mobile menu"
style="top: 0;"
></button>
{/if}
<!-- Mobile Menu - Dropdown from bottom on mobile, from top on tablet/desktop -->
{#if mobileMenuOpen}
<div
class="animate-slide-up md:animate-slide-down fixed bottom-[80px] left-1/2 z-40 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 px-4 md:bottom-auto md:top-[65px] md:max-w-md"
>
<div
class="border-theme-border/30 bg-theme-surface/95 flex max-h-[60vh] w-full flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-xl"
>
<div class="flex-1 overflow-y-auto p-3">
{#if user}
<!-- Main Navigation -->
<div class="pb-1">
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
<a
href="/my/links"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="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-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/my/links'
)
? 'underline'
: 'group-hover:underline'}"
>
Links
</span>
</a>
<a
href="/my/cards"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="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 2"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/my/cards'
)
? 'underline'
: 'group-hover:underline'}"
>
Profile Cards
</span>
</a>
<a
href="/my/tags"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="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="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/my/tags'
)
? 'underline'
: 'group-hover:underline'}"
>
Tags
</span>
</a>
<a
href="/template-store"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="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>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/template-store'
)
? 'underline'
: 'group-hover:underline'}"
>
Templates
</span>
</a>
<a
href="/pricing"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/pricing'
)
? 'underline'
: 'group-hover:underline'}"
>
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
</span>
</a>
{#if user.username}
<a
href="/p/{user.username}"
onclick={() => (mobileMenuOpen = false)}
target="_blank"
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 group-hover:underline"
>
{m.nav_profile()}
</span>
</a>
{/if}
</div>
<!-- Account Section -->
<div class="pb-1 pt-2">
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Account</h3>
<a
href="/settings"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/settings'
)
? 'underline'
: 'group-hover:underline'}"
>
Settings
</span>
</a>
<a
href="/settings/team"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/settings/team'
)
? 'underline'
: 'group-hover:underline'}"
>
Team
</span>
</a>
</div>
<!-- Settings Section -->
<div class="border-theme-border/30 border-t pb-1 pt-2">
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
<div
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5H9m12 0v6m0 6v4"
/>
</svg>
<span class="flex-1 text-lg font-medium text-theme-text">Theme</span>
<ThemeDropdown />
</div>
<div
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
<span class="flex-1 text-lg font-medium text-theme-text">Language</span>
<LanguageSwitcher />
</div>
</div>
{:else}
<!-- Guest Navigation -->
<div class="pb-1">
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
<a
href="/features"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/features'
)
? 'underline'
: 'group-hover:underline'}"
>
Features
</span>
</a>
<a
href="/pricing"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/pricing'
)
? 'underline'
: 'group-hover:underline'}"
>
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
</span>
</a>
<a
href="/about"
onclick={() => (mobileMenuOpen = false)}
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
'/about'
)
? 'underline'
: 'group-hover:underline'}"
>
About
</span>
</a>
</div>
<!-- Settings Section -->
<div class="border-theme-border/30 border-t pb-1 pt-2">
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
<div
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5H9m12 0v6m0 6v4"
/>
</svg>
<span class="flex-1 text-lg font-medium text-theme-text">Theme</span>
<ThemeDropdown />
</div>
<div
class="group flex items-center gap-3 rounded-md px-3 py-2 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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
<span class="flex-1 text-lg font-medium text-theme-text">Language</span>
<LanguageSwitcher />
</div>
</div>
{/if}
</div>
<!-- Sticky Buttons at bottom -->
<div class="border-theme-border/30 space-y-2 border-t p-3">
{#if !user}
<a
href="/login"
class="block w-full rounded-lg bg-theme-surface-hover px-4 py-2 text-center font-medium text-theme-text transition-colors hover:bg-theme-border"
>
{m.nav_login()}
</a>
<a
href="/register"
class="block w-full rounded-lg bg-theme-primary px-4 py-2 text-center font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
>
{m.nav_register()}
</a>
{/if}
</div>
</div>
</div>
{/if}
<style>
.scroll-progress-indicator {
background: conic-gradient(
from -90deg at 50% 50%,
var(--progress-color, #ef4444) calc(var(--scroll-progress, 0) * 360deg),
transparent calc(var(--scroll-progress, 0) * 360deg)
);
transition: --progress-color 0.3s ease;
}
@keyframes slide-up {
from {
transform: translateX(-50%) translateY(20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
@keyframes slide-down {
from {
transform: translateX(-50%) translateY(-20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
.animate-slide-down {
animation: slide-down 0.2s ease-out;
}
</style>

View file

@ -1,270 +0,0 @@
<script lang="ts">
import { Bell, Check, Trash2, ExternalLink, X } from 'lucide-svelte';
import { notifications, unreadCount } from '$lib/stores/notifications';
import { pb } from '$lib/pocketbase';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { scale } from 'svelte/transition';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props {
position?: 'right' | 'left-outside';
}
let { position = 'right' }: Props = $props();
let showDropdown = $state(false);
onMount(() => {
// Load notifications on mount
notifications.load(pb);
// Set up real-time subscription
pb.collection('notifications').subscribe('*', (e) => {
if (e.action === 'create') {
notifications.add(e.record);
} else if (e.action === 'update') {
// Reload notifications to get updated data
notifications.load(pb);
} else if (e.action === 'delete') {
// Remove deleted notification
notifications.load(pb);
}
});
return () => {
pb.collection('notifications').unsubscribe('*');
};
});
function handleClickOutside() {
showDropdown = false;
}
async function handleMarkAsRead(notificationId: string) {
await notifications.markAsRead(pb, notificationId);
}
async function handleMarkAllAsRead() {
await notifications.markAllAsRead(pb);
}
async function handleDelete(notificationId: string) {
await notifications.delete(pb, notificationId);
}
async function handleAction(notification: any) {
// Mark as read first
await handleMarkAsRead(notification.id);
// Navigate to action URL if available
if (notification.action_url) {
if (notification.action_url.startsWith('http')) {
window.location.href = notification.action_url;
} else {
goto(notification.action_url);
}
}
showDropdown = false;
}
function getNotificationIcon(type: string) {
switch (type) {
case 'team_invite':
return '👥';
case 'team_accepted':
return '✅';
case 'team_declined':
return '⛔';
case 'link_shared':
return '🔗';
case 'system':
return '💡';
default:
return '🔔';
}
}
function getNotificationIconColor(type: string) {
switch (type) {
case 'team_invite':
return 'text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/20';
case 'team_accepted':
return 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/20';
case 'team_declined':
return 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/20';
case 'link_shared':
return 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/20';
case 'system':
return 'text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/20';
default:
return 'text-theme-text-muted bg-theme-primary/10';
}
}
function formatTime(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Gerade eben';
if (minutes < 60) return `vor ${minutes} Min.`;
if (hours < 24) return `vor ${hours} Std.`;
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
return date.toLocaleDateString('de-DE');
}
</script>
<div class="relative" use:clickOutside={handleClickOutside}>
<!-- Bell Button -->
<button
onclick={() => (showDropdown = !showDropdown)}
class="relative p-2 text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="Benachrichtigungen"
aria-expanded={showDropdown}
aria-haspopup="true"
>
<Bell class="h-5 w-5" />
{#if $unreadCount > 0}
<span
class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary text-xs font-bold text-white"
>
{$unreadCount > 9 ? '9+' : $unreadCount}
</span>
{/if}
</button>
<!-- Dropdown Panel -->
{#if showDropdown}
<div
transition:scale={{ duration: 200, start: 0.95 }}
class="absolute {position === 'left-outside'
? 'left-0 top-full mt-2 origin-top-left'
: 'right-0 mt-2 origin-top-right'} z-50 max-h-[600px] w-96 overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-xl"
>
<!-- Header -->
<div class="border-b border-theme-border p-2">
<div class="flex items-center justify-between px-3 py-2">
<h3 class="text-sm font-medium text-theme-text">Benachrichtigungen</h3>
<div class="flex items-center gap-2">
{#if $unreadCount > 0}
<button
onclick={handleMarkAllAsRead}
class="text-xs text-theme-primary transition-colors hover:text-theme-primary-hover"
>
Alle als gelesen markieren
</button>
{/if}
<button
onclick={() => (showDropdown = false)}
class="rounded-md p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
>
<X class="h-4 w-4" />
</button>
</div>
</div>
</div>
<!-- Notifications List -->
<div class="max-h-[500px] overflow-y-auto">
{#if $notifications.loading}
<div class="p-8 text-center text-theme-text-muted">
<div
class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-theme-primary"
></div>
<p class="mt-2 text-sm">Lade Benachrichtigungen...</p>
</div>
{:else if $notifications.notifications.length === 0}
<div class="p-8 text-center text-theme-text-muted">
<Bell class="mx-auto mb-3 h-12 w-12 opacity-20" />
<p class="text-sm">Keine Benachrichtigungen</p>
</div>
{:else}
<div class="p-2">
{#each $notifications.notifications as notification, i}
<div
class="group mb-1 rounded-md px-3 py-3 transition-colors hover:bg-theme-surface-hover {!notification.read
? 'bg-theme-primary/5'
: ''}"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full {getNotificationIconColor(
notification.type
)}"
>
<span class="text-base">
{getNotificationIcon(notification.type)}
</span>
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<button onclick={() => handleAction(notification)} class="flex-1 text-left">
<p class="text-sm font-medium text-theme-text">
{notification.title}
</p>
<p class="mt-0.5 text-xs text-theme-text-muted">
{notification.message}
</p>
<p class="mt-1.5 text-xs text-theme-text-muted">
{formatTime(notification.created)}
</p>
</button>
<!-- Actions -->
<div
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
{#if !notification.read}
<button
onclick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-primary"
title="Als gelesen markieren"
>
<Check class="h-3.5 w-3.5" />
</button>
{/if}
<button
onclick={(e) => {
e.stopPropagation();
handleDelete(notification.id);
}}
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
title="Löschen"
>
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
</div>
{#if notification.type === 'team_invite' && notification.action_url}
<button
onclick={(e) => {
e.stopPropagation();
handleAction(notification);
}}
class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-2 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium text-theme-primary transition-colors"
>
Einladung annehmen
<ExternalLink class="h-3 w-3" />
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -1,145 +0,0 @@
<script lang="ts">
import { ChevronDown, User, Users, Check } from 'lucide-svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { User as UserType, SharedAccess } from '$lib/types/accounts';
interface Props {
user: UserType | null;
sharedAccounts?: SharedAccess[];
}
let { user, sharedAccounts = [] }: Props = $props();
let isOpen = $state(false);
let currentAccount = $state<string>(user?.id || '');
// Get current viewing context from URL params or session
$effect(() => {
const viewingAs = $page.url.searchParams.get('viewing_as');
if (viewingAs) {
currentAccount = viewingAs;
} else {
currentAccount = user?.id || '';
}
});
async function switchAccount(accountId: string) {
if (accountId === currentAccount) {
isOpen = false;
return;
}
// Update URL with viewing context
const url = new URL($page.url);
if (accountId === user?.id) {
url.searchParams.delete('viewing_as');
} else {
url.searchParams.set('viewing_as', accountId);
}
await goto(url.toString());
isOpen = false;
}
// Get display name for current account
const currentAccountName = $derived(() => {
if (currentAccount === user?.id) {
return user?.name || user?.username || 'My Account';
}
const shared = sharedAccounts.find((s) => s.owner === currentAccount);
if (shared?.expand?.owner) {
return shared.expand.owner.name || shared.expand.owner.username || shared.expand.owner.email;
}
return 'Unknown Account';
});
// Check if viewing a shared account
const isViewingShared = $derived(currentAccount !== user?.id);
</script>
{#if user && sharedAccounts.length > 0}
<div class="relative">
<button
onclick={() => (isOpen = !isOpen)}
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
<div class="flex items-center gap-2">
{#if isViewingShared}
<Users class="h-4 w-4 text-theme-primary" />
{:else}
<User class="h-4 w-4" />
{/if}
<span>{currentAccountName()}</span>
</div>
<ChevronDown class="h-4 w-4 {isOpen ? 'rotate-180' : ''} transition-transform" />
</button>
{#if isOpen}
<!-- Backdrop -->
<button onclick={() => (isOpen = false)} class="fixed inset-0 z-40" aria-label="Close menu"
></button>
<!-- Dropdown -->
<div
class="absolute right-0 top-full z-50 mt-2 w-64 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-800"
>
<div class="p-2">
<!-- My Account -->
<button
onclick={() => switchAccount(user.id)}
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<div class="flex items-center gap-3">
<User class="h-4 w-4 text-theme-text-muted" />
<div>
<p class="text-sm font-medium text-theme-text">My Account</p>
<p class="text-xs text-theme-text-muted">{user.email}</p>
</div>
</div>
{#if currentAccount === user.id}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
{#if sharedAccounts.length > 0}
<div class="my-2 border-t border-theme-border"></div>
<!-- Shared Accounts -->
<div class="mb-1 px-3 py-1">
<p class="text-xs font-medium text-theme-text-muted">Team Accounts</p>
</div>
{#each sharedAccounts as shared}
{#if shared.invitation_status === 'accepted'}
<button
onclick={() => switchAccount(shared.owner)}
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<div class="flex items-center gap-3">
<Users class="h-4 w-4 text-theme-text-muted" />
<div>
<p class="text-sm font-medium text-theme-text">
{shared.expand?.owner?.name ||
shared.expand?.owner?.username ||
'Team Account'}
</p>
<p class="text-xs text-theme-text-muted">
{shared.expand?.owner?.email}
</p>
</div>
</div>
{#if currentAccount === shared.owner}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
{/if}

View file

@ -1,159 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
stats?: {
totalUsers: number;
totalLinks: number;
totalFolders: number;
totalClicks: number;
};
}
let { stats = { totalUsers: 0, totalLinks: 0, totalFolders: 0, totalClicks: 0 } }: Props =
$props();
let displayStats = $state({
totalUsers: 0,
totalLinks: 0,
totalFolders: 0,
totalClicks: 0,
});
let isVisible = $state(false);
// Animate numbers counting up
function animateValue(
start: number,
end: number,
duration: number,
key: keyof typeof displayStats
) {
const range = end - start;
const startTime = Date.now();
function update() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const current = Math.floor(start + range * easeOutQuart);
displayStats[key] = current;
if (progress < 1) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
// Format large numbers with commas
function formatNumber(num: number): string {
return num.toLocaleString('en-US');
}
onMount(() => {
// Trigger visibility animation
setTimeout(() => {
isVisible = true;
}, 100);
// Start counter animations after a short delay
if (stats) {
setTimeout(() => {
animateValue(0, stats.totalUsers || 0, 1500, 'totalUsers');
animateValue(0, stats.totalLinks || 0, 1500, 'totalLinks');
animateValue(0, stats.totalFolders || 0, 1500, 'totalFolders');
animateValue(0, stats.totalClicks || 0, 1500, 'totalClicks');
}, 500);
}
});
const statItems = [
{
icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
label: 'Users',
key: 'totalUsers' as const,
color: 'blue',
},
{
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
label: 'Links',
key: 'totalLinks' as const,
color: 'purple',
},
{
icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z',
label: 'Folders',
key: 'totalFolders' as const,
color: 'green',
},
{
icon: 'M22 12h-4l-3 9L9 3l-3 9H2',
label: 'Clicks',
key: 'totalClicks' as const,
color: 'orange',
},
];
</script>
<div class="transition-all duration-500 {isVisible ? 'opacity-100' : 'opacity-0'}">
<!-- Stats bar -->
<div class="rounded-lg border border-theme-border bg-theme-surface shadow-sm">
<div class="px-4 py-2 sm:px-6">
<div class="flex flex-wrap items-center justify-between gap-4">
{#each statItems as stat}
<div class="group flex items-center gap-2">
<!-- Icon -->
<svg
class="h-5 w-5 text-theme-text-muted"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d={stat.icon} />
</svg>
<!-- Stats text -->
<div class="flex items-baseline gap-1">
<span class="text-lg font-bold text-theme-text">
{formatNumber(displayStats[stat.key] || 0)}
</span>
<span class="text-xs text-theme-text-muted">
{stat.label}
</span>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
/* Additional styles for smooth animations */
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View file

@ -1,75 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
interface Props {
tag: Tag;
size?: 'xs' | 'sm' | 'md' | 'lg';
clickable?: boolean;
removable?: boolean;
onclick?: () => void;
onremove?: () => void;
}
let {
tag,
size = 'sm',
clickable = false,
removable = false,
onclick,
onremove,
}: Props = $props();
const sizeClasses = {
xs: 'px-1.5 py-0 text-[10px]',
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-1.5 text-base',
};
function handleClick(e: MouseEvent) {
if (clickable && onclick) {
e.stopPropagation();
onclick();
}
}
function handleRemove(e: MouseEvent) {
e.stopPropagation();
if (onremove) {
onremove();
}
}
</script>
{#if tag && tag.name}
<span
class="inline-flex items-center gap-1 rounded-full font-medium transition-all {sizeClasses[
size
]} {clickable ? 'cursor-pointer hover:scale-105' : ''}"
style="background-color: {tag.color || '#3B82F6'}20; color: {tag.color || '#3B82F6'}"
onclick={handleClick}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : -1}
>
{#if tag.icon && tag.icon.trim()}
<span>{tag.icon}</span>
{/if}
<span>{tag.name}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
aria-label="Remove tag"
>
<svg class="h-3 w-3" 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>
{/if}
</span>
{/if}

View file

@ -1,175 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
import { enhance } from '$app/forms';
import TagBadge from './TagBadge.svelte';
import Dropdown from './Dropdown.svelte';
import { DEFAULT_TAG_COLORS } from '$lib/pocketbase';
import { MousePointer, Link, Hash } from 'lucide-svelte';
interface Props {
tag: Tag & { linkCount?: number; totalClicks?: number };
}
let { tag }: Props = $props();
let editingTag = $state(false);
function startEdit() {
editingTag = true;
}
function cancelEdit() {
editingTag = false;
}
</script>
<div
class="group relative z-0 rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-colors hover:bg-theme-surface-hover"
>
{#if editingTag}
<form
method="POST"
action="?/update"
use:enhance={() => {
return async ({ update }) => {
await update();
cancelEdit();
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<div class="space-y-3">
<input
type="text"
name="name"
value={tag.name}
required
class="w-full rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
/>
<select
name="color"
class="rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text"
>
{#each DEFAULT_TAG_COLORS as color}
<option value={color} selected={color === tag.color}>{color}</option>
{/each}
</select>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="is_public"
checked={tag.is_public}
class="h-3 w-3 rounded border-theme-border"
/>
Public
</label>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-green-600 px-3 py-1 text-sm font-medium text-white hover:bg-green-700"
>
Save
</button>
<button
type="button"
onclick={cancelEdit}
class="rounded bg-theme-surface-hover px-3 py-1 text-sm font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
</div>
</div>
</form>
{:else}
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-4">
<TagBadge {tag} size="lg" />
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-theme-text-muted">
<div class="group/stat relative flex items-center gap-1.5">
<Link class="h-3.5 w-3.5" />
<span>{tag.linkCount || 0} links</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Used in {tag.linkCount || 0} links
</div>
</div>
<span class="text-theme-border"></span>
<div class="group/stat relative flex items-center gap-1.5">
<MousePointer class="h-3.5 w-3.5" />
<span>{tag.totalClicks || 0} clicks</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Total clicks: {tag.totalClicks || 0}
</div>
</div>
<span class="text-theme-border"></span>
<div class="group/stat relative flex items-center gap-1.5">
<Hash class="h-3.5 w-3.5" />
<span>{tag.usage_count || 0} uses</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Usage count: {tag.usage_count || 0}
</div>
</div>
{#if tag.is_public}
<span class="text-theme-border"></span>
<span class="font-medium text-green-600 dark:text-green-400">Public</span>
{:else}
<span class="text-theme-border"></span>
<span class="font-medium">Private</span>
{/if}
</div>
</div>
<Dropdown
items={[
{
label: 'Edit',
icon: '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: startEdit,
},
{
label: 'View Links',
icon: '<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="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>',
color: '#2563eb',
href: `/my/links?tag=${tag.name}`,
},
{
label: tag.is_public ? 'Make Private' : 'Make Public',
icon: tag.is_public
? '<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
: '<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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
color: '#ea580c',
type: 'form',
formAction: '?/togglePublic',
formData: { id: tag.id, is_public: String(!tag.is_public) },
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: tag.id },
enhanceOptions: () => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
},
},
]}
buttonText="Actions"
size="sm"
/>
</div>
{/if}
</div>

View file

@ -1,100 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
import TagCard from './TagCard.svelte';
import TagListItem from './TagListItem.svelte';
import TagStats from './TagStats.svelte';
import type { ViewMode } from '$lib/stores/viewModes';
interface Props {
tags: (Tag & { linkCount?: number; totalClicks?: number })[];
viewMode: ViewMode;
isSelectMode?: boolean;
selectedTags?: Set<string>;
onToggleSelect?: (tagId: string) => void;
}
let {
tags,
viewMode,
isSelectMode = false,
selectedTags = new Set<string>(),
onToggleSelect = () => {},
}: Props = $props();
</script>
{#if tags && tags.length > 0}
{#if viewMode === 'stats'}
<TagStats {tags} />
{:else if viewMode === 'cards'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tags as tag}
<div
class="relative {isSelectMode && selectedTags.has(tag.id)
? 'rounded-xl ring-2 ring-theme-primary'
: ''}"
>
{#if isSelectMode}
<div class="absolute left-3 top-3 z-10">
<input
type="checkbox"
checked={selectedTags.has(tag.id)}
onchange={() => onToggleSelect(tag.id)}
class="h-5 w-5 cursor-pointer rounded border-theme-border bg-white text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
<TagCard {tag} />
</div>
{/each}
</div>
{:else}
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
<div class="border-b border-theme-border bg-theme-surface-hover px-4 py-4 sm:px-6">
<h2 class="text-lg font-semibold text-theme-text sm:text-xl">
Your Tags ({tags.length} total)
</h2>
</div>
<!-- Desktop Table Header -->
<div
class="hidden lg:grid {isSelectMode
? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]'
: 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
>
{#if isSelectMode}<div></div>{/if}
<div>Tag Name</div>
<div>Links</div>
<div>Clicks</div>
<div>Uses</div>
<div>Status</div>
<div class="text-right">Actions</div>
</div>
<!-- Tablet Table Header -->
<div
class="hidden md:grid lg:hidden {isSelectMode
? 'grid-cols-[40px_1fr_100px_120px_140px]'
: 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text"
>
{#if isSelectMode}<div></div>{/if}
<div>Tag Name</div>
<div>Links</div>
<div>Clicks</div>
<div class="text-right">Actions</div>
</div>
<!-- Table Body -->
<div>
{#each tags as tag}
<TagListItem
{tag}
{isSelectMode}
isSelected={selectedTags.has(tag.id)}
onToggleSelect={() => onToggleSelect(tag.id)}
/>
{/each}
</div>
</div>
{/if}
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text-muted">No tags yet. Create your first tag to organize your links!</p>
</div>
{/if}

View file

@ -1,413 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
import { enhance } from '$app/forms';
import TagBadge from './TagBadge.svelte';
import { DEFAULT_TAG_COLORS } from '$lib/pocketbase';
import { MousePointer } from 'lucide-svelte';
interface Props {
tag: Tag & { linkCount?: number; totalClicks?: number };
isSelectMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
}
let {
tag,
isSelectMode = false,
isSelected = false,
onToggleSelect = () => {},
}: Props = $props();
let editingTag = $state(false);
function startEdit() {
editingTag = true;
}
function cancelEdit() {
editingTag = false;
}
</script>
<!-- Desktop View -->
<div
class="hidden lg:grid {isSelectMode
? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]'
: 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} px-6 py-4 transition-colors hover:bg-theme-surface-hover"
>
{#if isSelectMode}
<div>
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
{#if editingTag}
<form
method="POST"
action="?/update"
class="col-span-6 flex w-full items-center gap-4"
use:enhance={() => {
return async ({ update }) => {
await update();
cancelEdit();
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<div class="flex flex-1 items-center gap-4">
<input
type="text"
name="name"
value={tag.name}
required
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
/>
<select
name="color"
class="rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text"
>
{#each DEFAULT_TAG_COLORS as color}
<option value={color} selected={color === tag.color}>{color}</option>
{/each}
</select>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="is_public"
checked={tag.is_public}
class="h-4 w-4 rounded border-theme-border"
/>
Public
</label>
</div>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-green-600 px-3 py-1 text-sm font-medium text-white hover:bg-green-700"
>
Save
</button>
<button
type="button"
onclick={cancelEdit}
class="rounded bg-theme-surface-hover px-3 py-1 text-sm font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
</div>
</form>
{:else}
<!-- Tag Name Column -->
<div class="flex items-center">
<TagBadge {tag} size="md" />
</div>
<!-- Links Column -->
<div class="group/stat relative text-sm text-theme-text-muted">
<span>{tag.linkCount || 0} links</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Used in {tag.linkCount || 0} links
</div>
</div>
<!-- Clicks Column -->
<div class="group/stat relative flex items-center gap-1 text-sm text-theme-text-muted">
<MousePointer class="h-3 w-3" />
<span>{tag.totalClicks || 0} clicks</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Total clicks from all links: {tag.totalClicks || 0}
</div>
</div>
<!-- Uses Column -->
<div class="group/stat relative text-sm text-theme-text-muted">
<span>{tag.usage_count || 0} uses</span>
<div
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
>
Internal usage counter
</div>
</div>
<!-- Status Column -->
<div class="text-sm">
{#if tag.is_public}
<span class="text-green-600 dark:text-green-400">Public</span>
{:else}
<span class="text-theme-text-muted">Private</span>
{/if}
</div>
<!-- Actions Column -->
<div class="flex justify-end gap-2">
<button
onclick={startEdit}
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded px-3 py-1 text-sm font-medium text-theme-primary transition"
>
Edit
</button>
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<button
type="submit"
class="rounded bg-red-100 px-3 py-1 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
>
Delete
</button>
</form>
</div>
{/if}
</div>
<!-- Tablet View -->
<div
class="hidden md:grid lg:hidden {isSelectMode
? 'grid-cols-[40px_1fr_100px_120px_140px]'
: 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} px-4 py-4 transition-colors hover:bg-theme-surface-hover"
>
{#if isSelectMode}
<div>
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
{#if editingTag}
<form
method="POST"
action="?/update"
class="col-span-4 flex w-full items-center gap-4"
use:enhance={() => {
return async ({ update }) => {
await update();
cancelEdit();
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<div class="flex flex-1 items-center gap-4">
<input
type="text"
name="name"
value={tag.name}
required
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
/>
<select
name="color"
class="rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text"
>
{#each DEFAULT_TAG_COLORS as color}
<option value={color} selected={color === tag.color}>{color}</option>
{/each}
</select>
</div>
<div class="flex gap-2">
<button
type="submit"
class="rounded bg-green-600 px-3 py-1 text-sm font-medium text-white hover:bg-green-700"
>
Save
</button>
<button
type="button"
onclick={cancelEdit}
class="rounded bg-theme-surface-hover px-3 py-1 text-sm font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
</div>
</form>
{:else}
<div class="flex items-center">
<TagBadge {tag} size="md" />
</div>
<div class="text-sm text-theme-text-muted">
{tag.linkCount || 0} links
</div>
<div class="flex items-center gap-1 text-sm text-theme-text-muted">
<MousePointer class="h-3 w-3" />
<span>{tag.totalClicks || 0}</span>
</div>
<div class="flex justify-end gap-2">
<button
onclick={startEdit}
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded px-3 py-1 text-sm font-medium text-theme-primary transition"
>
Edit
</button>
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<button
type="submit"
class="rounded bg-red-100 px-3 py-1 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
>
Delete
</button>
</form>
</div>
{/if}
</div>
<!-- Mobile View -->
<div
class="border-b border-theme-border md:hidden {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} p-4 transition-colors hover:bg-theme-surface-hover"
>
{#if editingTag}
<form
method="POST"
action="?/update"
class="space-y-3"
use:enhance={() => {
return async ({ update }) => {
await update();
cancelEdit();
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<input
type="text"
name="name"
value={tag.name}
required
class="w-full rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
/>
<select
name="color"
class="w-full rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text"
>
{#each DEFAULT_TAG_COLORS as color}
<option value={color} selected={color === tag.color}>{color}</option>
{/each}
</select>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="is_public"
checked={tag.is_public}
class="h-4 w-4 rounded border-theme-border"
/>
Public
</label>
<div class="flex gap-2">
<button
type="submit"
class="flex-1 rounded bg-green-600 px-3 py-2 text-sm font-medium text-white hover:bg-green-700"
>
Save
</button>
<button
type="button"
onclick={cancelEdit}
class="flex-1 rounded bg-theme-surface-hover px-3 py-2 text-sm font-medium text-theme-text hover:bg-theme-border"
>
Cancel
</button>
</div>
</form>
{:else}
<div class="space-y-3">
{#if isSelectMode}
<div class="mb-2 flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<span class="text-sm text-theme-text">Select</span>
</label>
</div>
{/if}
<div class="flex items-center justify-between">
<TagBadge {tag} size="md" />
{#if tag.is_public}
<span class="text-xs font-medium text-green-600 dark:text-green-400">Public</span>
{:else}
<span class="text-xs text-theme-text-muted">Private</span>
{/if}
</div>
<div class="flex items-center justify-between text-sm text-theme-text-muted">
<div class="flex items-center gap-4">
<span>{tag.linkCount || 0} links</span>
<span class="flex items-center gap-1">
<MousePointer class="h-3 w-3" />
{tag.totalClicks || 0} clicks
</span>
<span>{tag.usage_count || 0} uses</span>
</div>
</div>
{#if !isSelectMode}
<div class="flex gap-2">
<button
onclick={startEdit}
class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded px-3 py-2 text-sm font-medium text-theme-primary transition"
>
Edit
</button>
<form
method="POST"
action="?/delete"
class="flex-1"
use:enhance={() => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<button
type="submit"
class="w-full rounded bg-red-100 px-3 py-2 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
>
Delete
</button>
</form>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -1,207 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
import { pb, generateTagSlug, DEFAULT_TAG_COLORS } from '$lib/pocketbase';
import TagBadge from './TagBadge.svelte';
interface Props {
userId: string;
selectedTags?: Tag[];
onchange?: (tags: Tag[]) => void;
placeholder?: string;
}
let { userId, selectedTags = [], onchange, placeholder = 'Add tags...' }: Props = $props();
let availableTags = $state<Tag[]>([]);
let searchQuery = $state('');
let isDropdownOpen = $state(false);
let isCreatingTag = $state(false);
let newTagName = $state('');
let selectedTagIds = $state<Set<string>>(new Set(selectedTags.map((t) => t.id)));
let inputElement: HTMLInputElement;
$effect(() => {
loadUserTags();
});
async function loadUserTags() {
try {
const tags = await pb.collection('tags').getList<Tag>(1, 100, {
filter: `user_id="${userId}"`,
sort: '-usage_count,name',
});
availableTags = tags.items;
} catch (err) {
console.error('Failed to load tags:', err);
}
}
function handleInputFocus() {
isDropdownOpen = true;
}
function handleInputBlur(e: FocusEvent) {
// Delay to allow clicking on dropdown items
setTimeout(() => {
if (!inputElement?.contains(e.relatedTarget as Node)) {
isDropdownOpen = false;
isCreatingTag = false;
newTagName = '';
}
}, 200);
}
async function toggleTag(tag: Tag) {
if (selectedTagIds.has(tag.id)) {
selectedTagIds.delete(tag.id);
selectedTags = selectedTags.filter((t) => t.id !== tag.id);
} else {
selectedTagIds.add(tag.id);
selectedTags = [...selectedTags, tag];
}
if (onchange) {
onchange(selectedTags);
}
}
async function createNewTag() {
if (!newTagName.trim()) return;
try {
const randomColor = DEFAULT_TAG_COLORS[Math.floor(Math.random() * DEFAULT_TAG_COLORS.length)];
const newTag = await pb.collection('tags').create<Tag>({
name: newTagName.trim(),
slug: generateTagSlug(newTagName.trim()),
color: randomColor,
user_id: userId,
is_public: false,
usage_count: 0,
});
availableTags = [...availableTags, newTag];
await toggleTag(newTag);
newTagName = '';
isCreatingTag = false;
searchQuery = '';
} catch (err) {
console.error('Failed to create tag:', err);
}
}
function removeTag(tag: Tag) {
selectedTagIds.delete(tag.id);
selectedTags = selectedTags.filter((t) => t.id !== tag.id);
if (onchange) {
onchange(selectedTags);
}
}
const filteredTags = $derived(
availableTags.filter(
(tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase()) && !selectedTagIds.has(tag.id)
)
);
const canCreateNewTag = $derived(
searchQuery.trim() &&
!availableTags.some((tag) => tag.name.toLowerCase() === searchQuery.toLowerCase())
);
</script>
<div class="space-y-2">
{#if selectedTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each selectedTags as tag}
<TagBadge {tag} removable onremove={() => removeTag(tag)} />
{/each}
</div>
{/if}
<div class="relative">
<input
bind:this={inputElement}
bind:value={searchQuery}
type="text"
{placeholder}
onfocus={handleInputFocus}
onblur={handleInputBlur}
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"
/>
{#if isDropdownOpen && (filteredTags.length > 0 || canCreateNewTag || isCreatingTag)}
<div
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border border-theme-border bg-white shadow-lg dark:bg-gray-800"
>
{#if isCreatingTag}
<div class="border-b border-theme-border p-3">
<div class="flex gap-2">
<input
bind:value={newTagName}
type="text"
placeholder="Enter tag name"
class="flex-1 rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-theme-accent"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
createNewTag();
} else if (e.key === 'Escape') {
isCreatingTag = false;
newTagName = '';
}
}}
/>
<button
onclick={createNewTag}
class="rounded bg-theme-primary px-3 py-1 text-sm text-white hover:bg-theme-primary-hover"
>
Add
</button>
<button
onclick={() => {
isCreatingTag = false;
newTagName = '';
}}
class="rounded bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
{/if}
{#each filteredTags as tag}
<button
onclick={() => toggleTag(tag)}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TagBadge {tag} size="sm" />
</button>
{/each}
{#if canCreateNewTag && !isCreatingTag}
<button
onclick={() => {
isCreatingTag = true;
newTagName = searchQuery;
}}
class="flex w-full items-center gap-2 border-t border-theme-border px-3 py-2 text-left text-sm text-theme-accent hover:bg-gray-100 dark:hover:bg-gray-700"
>
<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>
Create "{searchQuery}"
</button>
{/if}
</div>
{/if}
</div>
</div>

View file

@ -1,290 +0,0 @@
<script lang="ts">
import type { Tag } from '$lib/pocketbase';
import { BarChart3, TrendingUp, Hash, MousePointer, Activity, Calendar } from 'lucide-svelte';
interface Props {
tags: (Tag & { linkCount?: number; totalClicks?: number; usage_count?: number })[];
}
let { tags }: Props = $props();
let totalTags = $derived(tags.length);
let totalClicks = $derived(tags.reduce((sum, tag) => sum + (tag.totalClicks || 0), 0));
let totalLinks = $derived(tags.reduce((sum, tag) => sum + (tag.linkCount || 0), 0));
let averageLinksPerTag = $derived(totalTags > 0 ? (totalLinks / totalTags).toFixed(1) : '0');
let mostUsedTag = $derived(
tags.reduce(
(max, tag) => ((tag.usage_count || 0) > (max?.usage_count || 0) ? tag : max),
tags[0]
)
);
let mostClickedTag = $derived(
tags.reduce(
(max, tag) => ((tag.totalClicks || 0) > (max?.totalClicks || 0) ? tag : max),
tags[0]
)
);
let topTagsByClicks = $derived(
[...tags].sort((a, b) => (b.totalClicks || 0) - (a.totalClicks || 0)).slice(0, 10)
);
let topTagsByLinks = $derived(
[...tags].sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0)).slice(0, 10)
);
let maxClicks = $derived(Math.max(...topTagsByClicks.map((t) => t.totalClicks || 0), 1));
let maxLinks = $derived(Math.max(...topTagsByLinks.map((t) => t.linkCount || 0), 1));
function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
function calculateCTR(tag: any): string {
if (!tag.linkCount || tag.linkCount === 0) return '0%';
const ctr = ((tag.totalClicks || 0) / tag.linkCount) * 100;
return `${ctr.toFixed(1)}%`;
}
</script>
<div class="space-y-6">
<!-- Übersichts-Karten -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-text-muted">Gesamt Tags</p>
<p class="mt-2 text-3xl font-bold text-theme-text">{totalTags}</p>
</div>
<div class="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/20">
<Hash class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-text-muted">Gesamt Klicks</p>
<p class="mt-2 text-3xl font-bold text-theme-text">{formatNumber(totalClicks)}</p>
</div>
<div class="rounded-lg bg-green-100 p-3 dark:bg-green-900/20">
<MousePointer class="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
</div>
</div>
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-text-muted">Ø Links/Tag</p>
<p class="mt-2 text-3xl font-bold text-theme-text">{averageLinksPerTag}</p>
</div>
<div class="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/20">
<Activity class="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
</div>
</div>
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-theme-text-muted">Top Tag</p>
{#if mostClickedTag}
<p class="mt-2 truncate text-lg font-bold text-theme-text">{mostClickedTag.name}</p>
<p class="text-xs text-theme-text-muted">
{formatNumber(mostClickedTag.totalClicks || 0)} Klicks
</p>
{:else}
<p class="mt-2 text-lg text-theme-text-muted">-</p>
{/if}
</div>
<div class="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/20">
<TrendingUp class="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
</div>
</div>
</div>
<!-- Visualisierungen -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Top Tags nach Klicks -->
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">Top 10 Tags nach Klicks</h3>
<BarChart3 class="h-5 w-5 text-theme-text-muted" />
</div>
<div class="space-y-3">
{#each topTagsByClicks as tag, index}
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text"
>
{index + 1}
</div>
<div class="flex-1">
<div class="mb-1 flex items-center justify-between">
<span class="max-w-[200px] truncate text-sm font-medium text-theme-text">
{tag.name}
</span>
<span class="text-sm text-theme-text-muted">
{formatNumber(tag.totalClicks || 0)}
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-theme-surface-hover">
<div
class="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-500"
style="width: {((tag.totalClicks || 0) / maxClicks) * 100}%"
></div>
</div>
</div>
</div>
{/each}
{#if topTagsByClicks.length === 0}
<p class="text-center text-theme-text-muted">Keine Daten verfügbar</p>
{/if}
</div>
</div>
<!-- Top Tags nach Links -->
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-md">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-theme-text">Top 10 Tags nach Links</h3>
<Activity class="h-5 w-5 text-theme-text-muted" />
</div>
<div class="space-y-3">
{#each topTagsByLinks as tag, index}
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text"
>
{index + 1}
</div>
<div class="flex-1">
<div class="mb-1 flex items-center justify-between">
<span class="max-w-[200px] truncate text-sm font-medium text-theme-text">
{tag.name}
</span>
<span class="text-sm text-theme-text-muted">
{tag.linkCount || 0} Links
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-theme-surface-hover">
<div
class="h-full rounded-full bg-gradient-to-r from-green-500 to-green-600 transition-all duration-500"
style="width: {((tag.linkCount || 0) / maxLinks) * 100}%"
></div>
</div>
</div>
</div>
{/each}
{#if topTagsByLinks.length === 0}
<p class="text-center text-theme-text-muted">Keine Daten verfügbar</p>
{/if}
</div>
</div>
</div>
<!-- Detaillierte Tabelle -->
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-md">
<div class="border-b border-theme-border bg-theme-surface-hover px-6 py-4">
<h3 class="text-lg font-semibold text-theme-text">Detaillierte Tag-Statistiken</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="border-b border-theme-border bg-theme-surface-hover">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Tag
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Links
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Klicks
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
CTR
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Verwendungen
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
>
Erstellt
</th>
</tr>
</thead>
<tbody class="divide-y divide-theme-border">
{#each tags as tag}
<tr class="transition-colors hover:bg-theme-surface-hover">
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {tag.color}"></div>
<span class="font-medium text-theme-text">{tag.name}</span>
</div>
</td>
<td class="px-6 py-4 text-theme-text">
{tag.linkCount || 0}
</td>
<td class="px-6 py-4 text-theme-text">
{formatNumber(tag.totalClicks || 0)}
</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
{calculateCTR(tag)}
</span>
</td>
<td class="px-6 py-4 text-theme-text">
{tag.usage_count || 0}
</td>
<td class="px-6 py-4">
{#if tag.is_public}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
Öffentlich
</span>
{:else}
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-900/20 dark:text-gray-400"
>
Privat
</span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-theme-text-muted">
{new Date(tag.created).toLocaleDateString('de-DE')}
</td>
</tr>
{/each}
</tbody>
</table>
{#if tags.length === 0}
<div class="px-6 py-12 text-center">
<p class="text-theme-text-muted">Keine Tags vorhanden</p>
</div>
{/if}
</div>
</div>
</div>

View file

@ -1,160 +0,0 @@
<script lang="ts">
import { themeStore } from '$lib/themes/theme-store';
import { themes } from '$lib/themes/presets';
import { get } from 'svelte/store';
// Subscribe to stores for reactive values
let isDark = $state(false);
let preset = $state('');
$effect(() => {
const unsubscribeDark = themeStore.isDark.subscribe((value) => (isDark = value));
const unsubscribePreset = themeStore.preset.subscribe((value) => (preset = value));
return () => {
unsubscribeDark();
unsubscribePreset();
};
});
let showDropdown = $state(false);
let dropdownElement: HTMLDivElement;
function toggleDropdown(event: MouseEvent) {
event.stopPropagation();
showDropdown = !showDropdown;
}
function selectTheme(themeId: string, event: MouseEvent) {
event.stopPropagation();
themeStore.setPreset(themeId);
showDropdown = false;
}
function toggleDarkMode(event: MouseEvent) {
event.stopPropagation();
themeStore.toggle();
}
function handleClickOutside(event: MouseEvent) {
if (dropdownElement && !dropdownElement.contains(event.target as Node)) {
showDropdown = false;
}
}
$effect(() => {
if (showDropdown) {
// Use setTimeout to avoid immediate closing
const timer = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
}
});
</script>
<div class="relative" bind:this={dropdownElement}>
<button
onclick={(e) => toggleDropdown(e)}
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
aria-label="Theme settings"
title="Theme settings"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 z-50 mt-2 w-64 rounded-lg border border-theme-border bg-theme-surface shadow-lg"
>
<!-- Dark Mode Toggle -->
<div class="border-b border-theme-border p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-theme-text">Dark Mode</span>
<button
onclick={(e) => toggleDarkMode(e)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {themeStore.isDark
? 'bg-theme-accent'
: 'bg-theme-border'}"
aria-label="Toggle dark mode"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {themeStore.isDark
? 'translate-x-6'
: 'translate-x-1'}"
></span>
</button>
</div>
</div>
<!-- Theme Selection -->
<div class="p-2">
<p class="mb-2 px-2 text-xs font-medium text-theme-text-muted">Choose Theme</p>
<div class="space-y-1">
{#each Object.values(themes) as theme}
<button
onclick={(e) => selectTheme(theme.id, e)}
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover {themeStore.preset ===
theme.id
? 'bg-theme-surface-hover'
: ''}"
>
<div class="flex items-center gap-3">
<!-- Theme Preview Colors -->
<div class="flex gap-1">
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.primary
: theme.colors.light.primary}"
></div>
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.accent
: theme.colors.light.accent}"
></div>
</div>
<div>
<span class="block text-sm font-medium text-theme-text">{theme.name}</span>
<span class="block text-xs text-theme-text-muted">{theme.description}</span>
</div>
</div>
{#if themeStore.preset === theme.id}
<svg
class="h-4 w-4 text-theme-accent"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>

View file

@ -1,75 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
export let priceType: 'monthly' | 'yearly' | 'lifetime' = 'monthly';
export let className = '';
export let size: 'sm' | 'md' | 'lg' = 'md';
let loading = false;
let error = '';
const priceDisplay = {
monthly: '4,99€/Monat',
yearly: '39,99€/Jahr',
lifetime: '129,99€ einmalig',
};
const sizeClasses = {
sm: 'btn-sm',
md: '',
lg: 'btn-lg',
};
async function handleUpgrade() {
loading = true;
error = '';
try {
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceType }),
});
if (!response.ok) {
const data = await response.json();
if (response.status === 401) {
// Not logged in, redirect to login
goto('/login?redirect=/pricing');
return;
}
throw new Error(data.error || 'Checkout fehlgeschlagen');
}
const { url } = await response.json();
if (url) {
// Redirect to Stripe Checkout
window.location.href = url;
}
} catch (err: any) {
error = err.message;
loading = false;
}
}
</script>
<button
onclick={handleUpgrade}
disabled={loading}
class="btn btn-primary {sizeClasses[size]} {className}"
class:loading
>
{#if loading}
<span class="loading loading-spinner"></span>
Lädt...
{:else}
Upgrade für {priceDisplay[priceType]}
{/if}
</button>
{#if error}
<div class="text-error mt-2 text-sm">{error}</div>
{/if}

View file

@ -1,62 +0,0 @@
<script lang="ts">
interface Props {
currentView: 'cards' | 'list' | 'stats';
onViewChange: (view: 'cards' | 'list' | 'stats') => void;
showStats?: boolean;
}
let { currentView, onViewChange, showStats = false }: Props = $props();
</script>
<div class="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface p-1">
<button
onclick={() => onViewChange('cards')}
class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
'cards'
? 'bg-theme-primary text-white'
: 'text-theme-text hover:bg-theme-surface-hover'}"
title="Card View"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" stroke-width="2" rx="1" />
<rect x="14" y="3" width="7" height="7" stroke-width="2" rx="1" />
<rect x="3" y="14" width="7" height="7" stroke-width="2" rx="1" />
<rect x="14" y="14" width="7" height="7" stroke-width="2" rx="1" />
</svg>
<span class="hidden sm:inline">Cards</span>
</button>
<button
onclick={() => onViewChange('list')}
class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
'list'
? 'bg-theme-primary text-white'
: 'text-theme-text hover:bg-theme-surface-hover'}"
title="List View"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<line x1="3" y1="6" x2="21" y2="6" stroke-width="2" />
<line x1="3" y1="12" x2="21" y2="12" stroke-width="2" />
<line x1="3" y1="18" x2="21" y2="18" stroke-width="2" />
</svg>
<span class="hidden sm:inline">List</span>
</button>
{#if showStats}
<button
onclick={() => onViewChange('stats')}
class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
'stats'
? 'bg-theme-primary text-white'
: 'text-theme-text hover:bg-theme-surface-hover'}"
title="Stats View"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="13" width="18" height="8" stroke-width="2" rx="1" />
<line x1="7" y1="17" x2="7" y2="13" stroke-width="2" />
<line x1="11" y1="17" x2="11" y2="10" stroke-width="2" />
<line x1="15" y1="17" x2="15" y2="7" stroke-width="2" />
<line x1="19" y1="17" x2="19" y2="4" stroke-width="2" />
</svg>
<span class="hidden sm:inline">Stats</span>
</button>
{/if}
</div>

View file

@ -1,202 +0,0 @@
<script lang="ts">
import { ChevronDown, Building2, Check, Users, Plus, User } from 'lucide-svelte';
import { workspacesStore, currentWorkspace, allWorkspaces } from '$lib/stores/workspaces';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
import { clickOutside } from '$lib/actions/clickOutside';
import { fade, scale } from 'svelte/transition';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as m from '$paraglide/messages';
interface Props {
position?: 'right' | 'left-outside';
}
let { position = 'right' }: Props = $props();
let showDropdown = $state(false);
let workspaces = $derived($allWorkspaces);
let workspacesState = $derived($workspacesStore);
// Use activeWorkspace store as the primary source
let activeWorkspaceId = $state(activeWorkspace.getId());
let activeWorkspaceData = $state(activeWorkspace.getData());
// Subscribe to activeWorkspace changes
$effect(() => {
const unsubId = activeWorkspace.id.subscribe((id) => {
activeWorkspaceId = id;
});
const unsubData = activeWorkspace.data.subscribe((data) => {
activeWorkspaceData = data;
});
return () => {
unsubId();
unsubData();
};
});
// Derive current workspace from activeWorkspace or fallback to old store
let current = $derived(activeWorkspaceData || $currentWorkspace);
function toggleDropdown() {
showDropdown = !showDropdown;
}
function handleClickOutside() {
showDropdown = false;
}
async function switchToWorkspace(workspaceId: string) {
// Find the workspace data
const workspace = workspaces.find((w) => w.id === workspaceId);
if (workspace) {
// Set in the new active workspace store
activeWorkspace.set(workspace);
// Also update the old store for compatibility
await workspacesStore.switchWorkspace(workspaceId);
showDropdown = false;
// Navigate to maintain workspace context in URL
const currentPath = $page.url.pathname;
const searchParams = new URLSearchParams($page.url.search);
searchParams.set('workspace', workspaceId);
await goto(`${currentPath}?${searchParams.toString()}`);
}
}
function getWorkspaceDisplayName(workspace: any): string {
if (!workspace) return 'Unknown';
return workspace.name || 'Unnamed Workspace';
}
function createWorkspace() {
showDropdown = false;
// Navigate to workspace creation page with current workspace context
activeWorkspace.goto('/settings/workspaces/new');
}
</script>
<div class="relative" use:clickOutside={handleClickOutside}>
<button
onclick={toggleDropdown}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
aria-expanded={showDropdown}
aria-haspopup="true"
>
{#if activeWorkspaceData || current}
{#if (activeWorkspaceData || current).type === 'team'}
<Users class="h-4 w-4 text-purple-500" />
{:else}
<User class="h-4 w-4 text-theme-text-muted" />
{/if}
<span class="max-w-[150px] truncate">
{getWorkspaceDisplayName(activeWorkspaceData || current)}
</span>
<ChevronDown
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
? 'rotate-180'
: ''}"
/>
{:else}
<Building2 class="h-4 w-4 text-theme-text-muted" />
<span class="max-w-[150px] truncate"> Select Workspace </span>
<ChevronDown
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
? 'rotate-180'
: ''}"
/>
{/if}
</button>
{#if showDropdown}
<div
transition:scale={{ duration: 200, start: 0.95 }}
class="absolute z-50 {position === 'left-outside'
? 'left-0 top-full mt-2'
: 'right-0 mt-2'} w-72 {position === 'left-outside'
? 'origin-top-left'
: 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
>
<!-- Personal Workspace Section -->
{#if workspacesState.personalWorkspace}
<div class="border-b border-theme-border p-2">
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
Personal Workspace
</div>
<button
onclick={() =>
workspacesState.personalWorkspace &&
switchToWorkspace(workspacesState.personalWorkspace.id)}
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<User class="h-5 w-5 text-theme-text-muted" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-theme-text">
{getWorkspaceDisplayName(workspacesState.personalWorkspace)}
</div>
<div class="text-xs text-theme-text-muted">Your personal workspace</div>
</div>
{#if activeWorkspaceId === workspacesState.personalWorkspace.id}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
</div>
{/if}
<!-- Team Workspaces Section -->
{#if workspacesState.teamWorkspaces && workspacesState.teamWorkspaces.length > 0}
<div class="border-b border-theme-border p-2">
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
Team Workspaces
</div>
{#each workspacesState.teamWorkspaces as workspace}
<button
onclick={() => switchToWorkspace(workspace.id)}
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
>
<Users class="h-5 w-5 text-purple-500" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-theme-text">
{getWorkspaceDisplayName(workspace)}
</div>
{#if workspace.description}
<div class="text-xs text-theme-text-muted">
{workspace.description}
</div>
{/if}
</div>
{#if activeWorkspaceId === workspace.id}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
{/each}
</div>
{:else}
<!-- Empty State for Team Workspaces -->
<div class="border-b border-theme-border p-4">
<p class="text-center text-xs text-theme-text-muted">No team workspaces yet</p>
<p class="mt-1 text-center text-xs text-theme-text-muted">
Create or join a team workspace to collaborate
</p>
</div>
{/if}
<!-- Create Workspace Button -->
<div class="border-t border-theme-border p-2">
<button
onclick={createWorkspace}
class="hover:bg-theme-primary/10 flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all"
>
<div class="bg-theme-primary/10 flex h-5 w-5 items-center justify-center rounded-full">
<Plus class="h-3.5 w-3.5 text-theme-primary" />
</div>
<span class="text-theme-text">Create Workspace</span>
</button>
</div>
</div>
{/if}
</div>
<style>
/* Custom styles if needed */
</style>

View file

@ -1,146 +0,0 @@
<script lang="ts">
import type { BlogPostWithMeta } from '../../../content/config';
// Svelte 5: Props mit $props()
let {
post,
featured = false,
viewMode = 'cards',
} = $props<{
post: BlogPostWithMeta;
featured?: boolean;
viewMode?: 'cards' | 'list';
}>();
// Svelte 5: $state für Hover-State
let isHovered = $state(false);
// Svelte 5: $derived für berechnete Werte
let formattedDate = $derived(
new Date(post.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
);
let readingTimeText = $derived(`${post.readingTime} Min. Lesezeit`);
let cardClasses = $derived(() => {
if (viewMode === 'list') {
return 'flex gap-4 p-4';
}
return 'flex flex-col h-full';
});
</script>
<article
class="group relative overflow-hidden rounded-xl border border-theme-border bg-theme-surface transition-all hover:border-theme-accent hover:shadow-lg {cardClasses()} {featured
? 'ring-2 ring-theme-primary'
: ''}"
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if post.image && viewMode === 'cards'}
<div
class="from-theme-primary/5 to-theme-accent/5 relative h-48 w-full overflow-hidden bg-gradient-to-br"
>
<img
src={post.image}
alt={post.title}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
{#if featured}
<div class="absolute left-3 top-3">
<span
class="inline-flex items-center rounded-full bg-theme-primary px-3 py-1 text-xs font-semibold text-white shadow-lg"
>
Featured
</span>
</div>
{/if}
</div>
{/if}
{#if post.image && viewMode === 'list'}
<div
class="from-theme-primary/5 to-theme-accent/5 relative h-32 w-48 flex-shrink-0 overflow-hidden rounded-lg bg-gradient-to-br"
>
<img src={post.image} alt={post.title} class="h-full w-full object-cover" loading="lazy" />
{#if featured}
<div class="absolute left-2 top-2">
<span
class="inline-flex items-center rounded-full bg-theme-primary px-2 py-0.5 text-xs font-semibold text-white"
>
Featured
</span>
</div>
{/if}
</div>
{/if}
<div class="flex flex-1 flex-col p-6">
{#if featured && !post.image}
<span
class="bg-theme-primary/10 mb-2 inline-block rounded-full px-3 py-1 text-xs font-semibold text-theme-primary"
>
Featured
</span>
{/if}
<h3 class="mb-2 line-clamp-2 text-lg font-semibold text-theme-text">
<a href="/blog/{post.slug}" class="transition-colors hover:text-theme-primary">
{post.title}
</a>
</h3>
<p class="mb-4 line-clamp-2 text-sm text-theme-text-muted">
{post.excerpt}
</p>
<div class="mt-auto flex items-center justify-between text-xs text-theme-text-muted">
<time datetime={post.date.toISOString()}>
{formattedDate}
</time>
<span class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{readingTimeText}
</span>
</div>
{#if post.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1.5">
{#each post.tags.slice(0, 3) as tag}
<a
href="/blog?tag={tag}"
class="inline-flex items-center rounded-full border border-theme-border bg-theme-background px-2 py-0.5 text-xs text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
>
#{tag}
</a>
{/each}
{#if post.tags.length > 3}
<span class="inline-flex items-center px-2 py-0.5 text-xs text-theme-text-muted">
+{post.tags.length - 3}
</span>
{/if}
</div>
{/if}
</div>
</article>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -1,73 +0,0 @@
<script lang="ts">
interface Props {
variant?: 'default' | 'compact' | 'hero' | 'minimal' | 'glass' | 'gradient';
layout?: {
padding?: string;
gap?: string;
columns?: number;
};
animations?: {
hover?: boolean;
entrance?: 'fade' | 'slide' | 'scale' | 'none';
};
className?: string;
children?: any;
}
let {
variant = 'default',
layout = {},
animations = {},
className = '',
children,
}: Props = $props();
// Generate CSS classes based on variant
let variantClasses = $derived(() => {
const classes = {
default: 'bg-white border border-gray-200 shadow-sm',
compact: 'bg-white border border-gray-200 shadow-sm p-2',
hero: 'bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-lg',
minimal: 'bg-transparent border-none',
glass: 'bg-white/20 backdrop-blur-md border border-white/30',
gradient: 'bg-gradient-to-br from-indigo-50 to-blue-50 border border-indigo-200',
};
return classes[variant] || classes.default;
});
// Generate layout styles
let layoutStyles = $derived(() => {
const styles = [];
if (layout.padding) styles.push(`padding: ${layout.padding}`);
if (layout.gap) styles.push(`gap: ${layout.gap}`);
return styles.join('; ');
});
// Generate animation classes
let animationClasses = $derived(() => {
const classes = [];
if (animations.hover)
classes.push('hover:shadow-md hover:scale-[1.02] transition-all duration-200');
if (animations.entrance === 'fade') classes.push('animate-fade-in');
return classes.join(' ');
});
</script>
<div class="rounded-lg {variantClasses()} {animationClasses()} {className}" style={layoutStyles()}>
{@render children()}
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-in-out;
}
</style>

View file

@ -1,541 +0,0 @@
<script lang="ts">
import type { Card, CardConfig, Module } from './types';
import { isBeginnerCard, isAdvancedCard, isExpertCard } from './types';
import { cardValidator } from '$lib/services/cardValidator';
import ModuleEditor from './editor/ModuleEditor.svelte';
import TemplateEditor from './editor/TemplateEditor.svelte';
import CodeEditor from './editor/CodeEditor.svelte';
interface Props {
card: Card;
onSave: (card: Card) => void;
onCancel: () => void;
}
let { card, onSave, onCancel }: Props = $props();
let editingCard = $state<Card>({
...card,
metadata: card.metadata || {},
constraints: card.constraints || {},
});
let activeTab = $state<'config' | 'metadata' | 'preview'>('config');
let validationErrors = $state<string[]>([]);
// Mode options
const modes = [
{ value: 'beginner', label: 'Beginner', description: 'Visual modules' },
{ value: 'advanced', label: 'Advanced', description: 'HTML templates' },
{ value: 'expert', label: 'Expert', description: 'Custom HTML/CSS' },
];
// Validate card on changes
$effect(() => {
const validation = cardValidator.validate(editingCard);
validationErrors = validation.errors?.map((e) => `${e.field}: ${e.message}`) || [];
});
// Handle mode change
async function handleModeChange(newMode: string) {
if (newMode === editingCard.config.mode) return;
// Convert config to new mode
// For now, just reset to default config for the new mode
let newConfig: CardConfig;
switch (newMode) {
case 'beginner':
newConfig = {
mode: 'beginner',
modules: [],
layout: { columns: 1, gap: '1rem', padding: '1.5rem' },
};
break;
case 'advanced':
newConfig = {
mode: 'advanced',
template:
'<div class="card-content">\n <h2>{{title}}</h2>\n <p>{{content}}</p>\n</div>',
css: '',
variables: [],
values: {},
};
break;
case 'expert':
newConfig = {
mode: 'expert',
html: '<div class="card-content">\n <h2>Title</h2>\n <p>Content</p>\n</div>',
css: '.card-content { padding: 1.5rem; }',
};
break;
default:
return;
}
editingCard = {
...editingCard,
config: newConfig,
};
}
// Handle save
function handleSave() {
if (validationErrors.length > 0) {
alert('Please fix validation errors before saving');
return;
}
onSave(editingCard);
}
// Add module (for beginner mode)
function addModule(type: Module['type']) {
if (!isBeginnerCard(editingCard.config)) return;
const newModule: Module = {
id: `module_${Date.now()}`,
type,
props: {},
order: editingCard.config.modules.length,
};
editingCard.config.modules = [...editingCard.config.modules, newModule];
}
// Remove module
function removeModule(moduleId: string) {
if (!isBeginnerCard(editingCard.config)) return;
editingCard.config.modules = editingCard.config.modules.filter((m) => m.id !== moduleId);
// Reorder remaining modules
editingCard.config.modules.forEach((m, i) => {
m.order = i;
});
}
</script>
<div class="card-editor-overlay">
<div class="card-editor">
<!-- Header -->
<div class="editor-header">
<h2>{card.id === 'new' ? 'Create Card' : 'Edit Card'}</h2>
<button onclick={onCancel} class="close-btn" aria-label="Close editor">
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Tabs -->
<div class="editor-tabs">
<button
class="tab"
class:active={activeTab === 'config'}
onclick={() => (activeTab = 'config')}
>
Configuration
</button>
<button
class="tab"
class:active={activeTab === 'metadata'}
onclick={() => (activeTab = 'metadata')}
>
Metadata
</button>
<button
class="tab"
class:active={activeTab === 'preview'}
onclick={() => (activeTab = 'preview')}
>
Preview
</button>
</div>
<!-- Content -->
<div class="editor-content">
{#if activeTab === 'config'}
<!-- Mode selector -->
<div class="form-group">
<label>Mode</label>
<select
value={editingCard.config.mode}
onchange={(e) => handleModeChange(e.currentTarget.value)}
class="select"
>
{#each modes as mode}
<option value={mode.value}>
{mode.label} - {mode.description}
</option>
{/each}
</select>
</div>
<!-- Mode-specific editor -->
{#if isBeginnerCard(editingCard.config)}
<div class="modules-editor">
<h3>Modules</h3>
<!-- Module list -->
{#if editingCard.config.modules.length > 0}
<div class="modules-list">
{#each editingCard.config.modules as module (module.id)}
<ModuleEditor
{module}
onUpdate={(updated) => {
if (!isBeginnerCard(editingCard.config)) return;
const index = editingCard.config.modules.findIndex((m) => m.id === module.id);
if (index >= 0) {
editingCard.config.modules[index] = updated;
}
}}
onRemove={() => removeModule(module.id)}
/>
{/each}
</div>
{:else}
<p class="empty-message">No modules yet. Add one below.</p>
{/if}
<!-- Add module buttons -->
<div class="add-module-buttons">
<button onclick={() => addModule('header')} class="btn btn-sm">+ Header</button>
<button onclick={() => addModule('content')} class="btn btn-sm">+ Content</button>
<button onclick={() => addModule('links')} class="btn btn-sm">+ Links</button>
<button onclick={() => addModule('media')} class="btn btn-sm">+ Media</button>
<button onclick={() => addModule('stats')} class="btn btn-sm">+ Stats</button>
<button onclick={() => addModule('footer')} class="btn btn-sm">+ Footer</button>
</div>
</div>
{:else if isAdvancedCard(editingCard.config)}
<TemplateEditor
bind:template={editingCard.config.template}
bind:css={editingCard.config.css}
bind:variables={editingCard.config.variables}
bind:values={editingCard.config.values}
/>
{:else if isExpertCard(editingCard.config)}
<CodeEditor bind:html={editingCard.config.html} bind:css={editingCard.config.css} />
{/if}
{:else if activeTab === 'metadata'}
<div class="metadata-editor">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
type="text"
bind:value={editingCard.metadata!.name}
class="input"
placeholder="Card name"
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
bind:value={editingCard.metadata!.description}
class="textarea"
placeholder="Card description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="page">Page</label>
<input
id="page"
type="text"
bind:value={editingCard.page}
class="input"
placeholder="default"
/>
</div>
<div class="form-group">
<label for="position">Position</label>
<input
id="position"
type="number"
bind:value={editingCard.position}
class="input"
min="0"
/>
</div>
<div class="form-group">
<label>
<input type="checkbox" bind:checked={editingCard.metadata!.isActive} />
Active
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" bind:checked={editingCard.metadata!.isPublic} />
Public
</label>
</div>
<div class="form-group">
<label for="aspectRatio">Aspect Ratio</label>
<select
id="aspectRatio"
bind:value={editingCard.constraints!.aspectRatio}
class="select"
>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
<option value="3/2">3:2</option>
<option value="auto">Auto</option>
</select>
</div>
</div>
{:else if activeTab === 'preview'}
<div class="preview-container">
<div class="preview-card">
<!-- Preview would go here -->
<p>Preview coming soon...</p>
</div>
</div>
{/if}
</div>
<!-- Validation errors -->
{#if validationErrors.length > 0}
<div class="validation-errors">
<h4>Validation Errors:</h4>
<ul>
{#each validationErrors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<!-- Footer -->
<div class="editor-footer">
<button onclick={onCancel} class="btn btn-secondary"> Cancel </button>
<button onclick={handleSave} class="btn btn-primary" disabled={validationErrors.length > 0}>
Save Card
</button>
</div>
</div>
</div>
<style>
.card-editor-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.card-editor {
background: white;
border-radius: 0.75rem;
max-width: 900px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.editor-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
}
.close-btn {
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: color 0.2s;
}
.close-btn:hover {
color: #1f2937;
}
.editor-tabs {
display: flex;
gap: 1rem;
padding: 0 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.tab {
padding: 1rem 0;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #1f2937;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.input,
.select,
.textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.input:focus,
.select:focus,
.textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modules-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.empty-message {
text-align: center;
color: #6b7280;
padding: 2rem;
background: #f9fafb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.add-module-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover {
background: #f9fafb;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.validation-errors {
padding: 1rem 1.5rem;
background: #fef2f2;
border-top: 1px solid #fecaca;
}
.validation-errors h4 {
color: #dc2626;
margin-bottom: 0.5rem;
}
.validation-errors ul {
list-style: disc;
padding-left: 1.5rem;
color: #dc2626;
font-size: 0.875rem;
}
.editor-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.preview-container {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
border-radius: 0.5rem;
}
</style>

View file

@ -1,285 +0,0 @@
<script lang="ts">
import type { Card } from './types';
import { isBeginnerCard, isAdvancedCard, isExpertCard } from './types';
import ModularCard from './ModularCard.svelte';
import TemplateCard from './TemplateCard.svelte';
import CustomCard from './CustomCard.svelte';
interface Props {
card: Card;
className?: string;
editable?: boolean;
onEdit?: (card: Card) => void;
onDelete?: (id: string) => void;
showMetadata?: boolean;
userCardCustomization?: {
cardBackgroundColor?: string;
cardBorderColor?: string;
cardLinkColor?: string;
cardTextColor?: string;
};
}
let {
card,
className = '',
editable = false,
onEdit = () => {},
onDelete = () => {},
showMetadata = false,
userCardCustomization,
}: Props = $props();
// Determine variant classes and user customization styles
let variantClasses = $derived(() => {
const classes = {
default: 'bg-white border border-gray-200',
compact: 'bg-white border border-gray-200 p-4',
hero: 'bg-gradient-to-br from-blue-500 to-purple-600 text-white',
minimal: 'bg-transparent border-0',
glass: 'bg-white/10 backdrop-blur-lg border border-white/20',
gradient: 'bg-gradient-to-br from-blue-50 to-purple-50 border border-purple-200',
};
return classes[card.variant || 'default'] || classes.default;
});
// Generate CSS custom properties for user card customization
let cardCustomStyles = $derived(() => {
if (!userCardCustomization) return '';
const styles = [];
if (userCardCustomization.cardBackgroundColor) {
styles.push(`--card-bg: ${userCardCustomization.cardBackgroundColor}`);
}
if (userCardCustomization.cardBorderColor) {
styles.push(`--card-border: ${userCardCustomization.cardBorderColor}`);
}
if (userCardCustomization.cardLinkColor) {
styles.push(`--card-links: ${userCardCustomization.cardLinkColor}`);
}
if (userCardCustomization.cardTextColor) {
styles.push(`--card-text: ${userCardCustomization.cardTextColor}`);
}
return styles.join('; ');
});
// Handle edit click
function handleEdit() {
if (editable) {
onEdit(card);
}
}
// Handle delete click
function handleDelete() {
if (editable && card.id && confirm('Are you sure you want to delete this card?')) {
onDelete(card.id);
}
}
// Helper to get metadata from either Card or UnifiedCard
function getMetadata() {
if ('metadata' in card) {
return card.metadata;
}
return undefined;
}
// Helper to get constraints from either Card or UnifiedCard
function getConstraints() {
if ('constraints' in card) {
return card.constraints;
}
return undefined;
}
</script>
<div
class="card-renderer rounded-xl shadow-sm transition-all {variantClasses()} {className}"
style={cardCustomStyles()}
data-card-id={card.id}
data-card-mode={card.config.mode}
>
{#if editable}
<div class="card-controls">
<button onclick={handleEdit} class="control-button edit-button" title="Edit card">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button onclick={handleDelete} class="control-button delete-button" title="Delete card">
<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="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>
</button>
</div>
{/if}
{#if isBeginnerCard(card.config)}
<ModularCard
modules={card.config.modules}
theme={card.config.theme}
layout={card.config.layout}
animations={card.config.animations}
{userCardCustomization}
/>
{:else if isAdvancedCard(card.config)}
<TemplateCard
template={card.config.template}
css={card.config.css}
variables={card.config.variables}
values={card.config.values}
constraints={getConstraints()}
/>
{:else if isExpertCard(card.config)}
<CustomCard html={card.config.html} css={card.config.css} constraints={getConstraints()} />
{:else}
<div class="error-state">
<svg class="error-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p>Unknown card mode: {card.config.mode}</p>
</div>
{/if}
{#if (showMetadata || !editable) && getMetadata()?.name}
<div class="card-metadata">
<span class="metadata-name">{getMetadata()?.name}</span>
{#if getMetadata()?.version}
<span class="metadata-version">v{getMetadata()?.version}</span>
{/if}
</div>
{/if}
</div>
<style>
.card-renderer {
position: relative;
width: 100%;
overflow: hidden;
background-color: var(--card-bg, #ffffff);
border-color: var(--card-border, #e2e8f0);
color: var(--card-text, #0f172a);
}
.card-renderer :global(a),
.card-renderer :global(.card-link),
.card-renderer :global(.module-link) {
color: var(--card-links, #0ea5e9);
}
.card-renderer :global(a:hover),
.card-renderer :global(.card-link:hover),
.card-renderer :global(.module-link:hover) {
color: var(--card-links, #0ea5e9);
opacity: 0.8;
}
.card-renderer:hover {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.card-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
opacity: 0;
transition: opacity 0.2s;
}
.card-renderer:hover .card-controls {
opacity: 1;
}
.control-button {
padding: 0.375rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.control-button:hover {
transform: scale(1.05);
}
.edit-button:hover {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.delete-button:hover {
background: #ef4444;
color: white;
border-color: #ef4444;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #ef4444;
min-height: 200px;
}
.error-icon {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
}
.card-metadata {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 10;
}
.card-renderer:hover .card-metadata {
opacity: 1;
}
.metadata-name,
.metadata-version {
padding: 0.125rem 0.5rem;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 0.75rem;
border-radius: 0.25rem;
}
.metadata-version {
background: rgba(59, 130, 246, 0.8);
}
</style>

View file

@ -1,156 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CardConstraints } from './types';
import { cardSanitizer } from '$lib/services/cardSanitizer';
import { iframePool } from '$lib/services/iframePool';
interface Props {
html: string;
css: string;
constraints?: CardConstraints;
className?: string;
}
let { html, css, constraints = {}, className = '' }: Props = $props();
let container: HTMLDivElement;
let iframe: HTMLIFrameElement | null = null;
let isLoading = $state(true);
let error = $state<string | null>(null);
// Get aspect ratio style
let aspectRatioStyle = $derived(() => {
const ratio = constraints?.aspectRatio || '16/9';
return ratio === 'auto' ? '' : `aspect-ratio: ${ratio};`;
});
// Create safe iframe content
let iframeContent = $derived(() => {
try {
return cardSanitizer.createSafeIframeContent(html, css, constraints);
} catch (e) {
console.error('Error creating iframe content:', e);
error = 'Failed to render card content';
return '';
}
});
onMount(() => {
// Get iframe from pool
iframe = iframePool.acquire();
if (iframe && container) {
// Configure iframe
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
// Set content
iframe.srcdoc = iframeContent();
// Handle load event
iframe.onload = () => {
isLoading = false;
// Auto-adjust height if no aspect ratio
if (!constraints?.aspectRatio || constraints.aspectRatio === 'auto') {
try {
const doc = iframe!.contentDocument || iframe!.contentWindow?.document;
if (doc && container) {
const height = doc.body.scrollHeight;
container.style.height = `${height}px`;
}
} catch (e) {
// Cross-origin restriction, ignore
}
}
};
// Add to container
container.appendChild(iframe);
}
return () => {
// Return iframe to pool
if (iframe) {
iframePool.release(iframe);
iframe = null;
}
};
});
// Update iframe content when props change
$effect(() => {
if (iframe && !isLoading) {
iframe.srcdoc = iframeContent();
}
});
</script>
<div bind:this={container} class="custom-card {className}" style={aspectRatioStyle()}>
{#if error}
<div class="error-state">
<svg class="error-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p>{error}</p>
</div>
{:else if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Loading...</p>
</div>
{/if}
</div>
<style>
.custom-card {
position: relative;
width: 100%;
min-height: 100px;
background: white;
overflow: hidden;
}
.loading-state,
.error-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #6b7280;
z-index: 1;
}
.spinner {
width: 32px;
height: 32px;
margin: 0 auto 0.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-icon {
width: 48px;
height: 48px;
margin: 0 auto 0.5rem;
color: #ef4444;
}
</style>

View file

@ -1,236 +0,0 @@
<script lang="ts">
import type { Module, Theme } from './types';
import { moduleEventBus } from '$lib/services/moduleEventBus';
import HeaderModule from './modules/HeaderModule.svelte';
import ContentModule from './modules/ContentModule.svelte';
import MediaModule from './modules/MediaModule.svelte';
import StatsModule from './modules/StatsModule.svelte';
import ActionsModule from './modules/ActionsModule.svelte';
import FooterModule from './modules/FooterModule.svelte';
import LinksModule from './modules/LinksModule.svelte';
interface Props {
modules: Module[];
theme?: Theme;
layout?: {
columns?: number;
gap?: string;
padding?: string;
};
animations?: {
hover?: boolean;
entrance?: 'fade' | 'slide' | 'scale' | 'none';
};
className?: string;
userCardCustomization?: {
cardBackgroundColor?: string;
cardBorderColor?: string;
cardLinkColor?: string;
cardTextColor?: string;
};
}
let {
modules = [],
theme,
layout = { columns: 1, gap: '1rem', padding: '1.5rem' },
animations = {},
className = '',
userCardCustomization,
}: Props = $props();
// Module component map
const moduleComponents = {
header: HeaderModule,
content: ContentModule,
media: MediaModule,
stats: StatsModule,
actions: ActionsModule,
footer: FooterModule,
links: LinksModule,
};
// Sort modules by order - create a copy to avoid mutation
// Also ensure modules is an array to prevent errors
let sortedModules = $derived(() => {
if (!Array.isArray(modules)) return [];
return [...modules].sort((a, b) => (a.order || 0) - (b.order || 0));
});
// Generate CSS variables from theme and user customization
let themeStyles = $derived(() => {
const styles = [];
// Add theme colors
if (theme?.colors) {
styles.push(...Object.entries(theme.colors).map(([key, value]) => `--${key}: ${value}`));
}
// Add user card customization (takes priority)
if (userCardCustomization) {
if (userCardCustomization.cardBackgroundColor) {
styles.push(`--card-bg: ${userCardCustomization.cardBackgroundColor}`);
}
if (userCardCustomization.cardBorderColor) {
styles.push(`--card-border: ${userCardCustomization.cardBorderColor}`);
}
if (userCardCustomization.cardLinkColor) {
styles.push(`--card-links: ${userCardCustomization.cardLinkColor}`);
}
if (userCardCustomization.cardTextColor) {
styles.push(`--card-text: ${userCardCustomization.cardTextColor}`);
}
}
return styles.join('; ');
});
// Animation classes
let animationClass = $derived(() => {
if (!animations?.entrance || animations.entrance === 'none') return '';
const classes = {
fade: 'animate-fade-in',
slide: 'animate-slide-up',
scale: 'animate-scale-in',
};
return classes[animations.entrance] || '';
});
// Grid styles
let gridStyles = $derived(() => {
const styles = [];
if (layout?.columns && layout.columns > 1) {
styles.push(`display: grid`);
styles.push(`grid-template-columns: repeat(${layout.columns}, 1fr)`);
styles.push(`gap: ${layout.gap || '1rem'}`);
}
if (layout?.padding) {
styles.push(`padding: ${layout.padding}`);
}
return styles.join('; ');
});
// Check if module should be visible
function isModuleVisible(module: Module): boolean {
if (!module.visibility || module.visibility === 'always') return true;
if (typeof window === 'undefined') return true;
const width = window.innerWidth;
const isMobile = width < 768;
if (module.visibility === 'mobile') return isMobile;
if (module.visibility === 'desktop') return !isMobile;
return true;
}
// Handle module events
function handleModuleEvent(moduleId: string, event: string, data: any) {
moduleEventBus.emit(`module:${event}`, {
moduleId,
data,
});
}
</script>
<div class="modular-card {animationClass()} {className}" style="{themeStyles()} {gridStyles()}">
{#each sortedModules() as module (module.id)}
{#if isModuleVisible(module) && moduleComponents[module.type]}
<div
class="module-wrapper {module.className || ''}"
data-module-id={module.id}
data-module-type={module.type}
style={module.grid
? `
grid-column: ${module.grid.col || 'auto'} / span ${module.grid.colSpan || 1};
grid-row: ${module.grid.row || 'auto'} / span ${module.grid.rowSpan || 1};
`
: ''}
>
<svelte:component
this={moduleComponents[module.type]}
{...module.props}
onEvent={(event, data) => handleModuleEvent(module.id, event, data)}
/>
</div>
{/if}
{/each}
</div>
<style>
.modular-card {
width: 100%;
min-height: 100px;
}
.module-wrapper {
position: relative;
}
/* Animations */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes scale-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
/* Responsive */
@media (max-width: 768px) {
.modular-card {
display: block !important;
}
.module-wrapper {
grid-column: 1 !important;
grid-row: auto !important;
margin-bottom: 1rem;
}
.module-wrapper:last-child {
margin-bottom: 0;
}
}
</style>

View file

@ -1,149 +0,0 @@
<script lang="ts">
import type { Card } from './types';
import CardRenderer from './CardRenderer.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
interface Props {
card: Card;
index: number;
isDragging: boolean;
dropTargetIndex: number | null;
onDragStart: (event: DragEvent, index: number) => void;
onDragOver: (event: DragEvent, index: number) => void;
onDragLeave: () => void;
onDrop: (event: DragEvent, index: number) => void;
onDragEnd: () => void;
onEdit: (card: Card) => void;
onDuplicate: (card: Card) => void;
onToggleVisibility: (card: Card) => void;
onToggleProfileDisplay: (card: Card) => void;
onDelete: (cardId: string) => void;
}
let {
card,
index,
isDragging,
dropTargetIndex,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
onEdit,
onDuplicate,
onToggleVisibility,
onToggleProfileDisplay,
onDelete,
}: Props = $props();
// Generate dropdown items based on card state
let dropdownItems = $derived([
{
label: 'Duplicate',
icon: '<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>',
color: '#2563eb',
action: () => onDuplicate(card),
},
{
divider: true,
},
{
label: card.metadata?.is_active !== false ? 'Hide Card' : 'Show Card',
icon:
card.metadata?.is_active !== false
? '<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
: '<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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
color: '#ea580c',
action: () => onToggleVisibility(card),
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
action: () => card.id && onDelete(card.id),
},
]);
</script>
<div
role="listitem"
draggable="true"
ondragstart={(e) => onDragStart(e, index)}
ondragover={(e) => onDragOver(e, index)}
ondragleave={onDragLeave}
ondrop={(e) => onDrop(e, index)}
ondragend={onDragEnd}
class="group relative cursor-move transition-all {dropTargetIndex === index
? 'scale-105 opacity-50'
: ''}"
>
<!-- Card Number Badge -->
<div
class="absolute -left-2 -top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-theme-primary text-sm font-bold text-white"
>
{index + 1}
</div>
<!-- Card Preview -->
<div
class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-colors hover:bg-theme-surface-hover"
>
<!-- Card Info -->
<div class="mb-3">
<h3 class="font-semibold text-theme-text">
{card.metadata?.name || `Card ${index + 1}`}
</h3>
<p class="text-sm text-theme-text-muted">
Aspect: {card.constraints?.aspectRatio || 'auto'}
</p>
</div>
<!-- Mini Preview -->
<div class="mb-3 max-h-48 overflow-hidden rounded border border-theme-border">
<CardRenderer {card} />
</div>
<!-- Profile Display Toggle -->
<div class="mb-3 flex items-center justify-between rounded bg-theme-surface-hover p-2">
<label class="flex items-center gap-2 text-sm font-medium text-theme-text">
<input
type="checkbox"
checked={card.page === 'profile'}
onchange={() => onToggleProfileDisplay(card)}
class="rounded border-theme-border"
/>
Show on Profile
</label>
{#if card.visibility !== 'public' && card.page === 'profile'}
<span class="text-xs text-orange-600">⚠️ Set to public to display</span>
{/if}
</div>
<!-- Card Actions -->
<div class="flex gap-2">
<button
onclick={() => onEdit(card)}
class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded px-3 py-1.5 text-sm font-medium text-theme-primary transition"
>
Edit Card
</button>
<Dropdown items={dropdownItems} buttonText="Actions" size="sm" />
</div>
</div>
<!-- Drag Handle -->
<div class="absolute right-4 top-4 opacity-0 transition-opacity group-hover:opacity-100">
<svg
class="h-6 w-6 text-theme-text-muted"
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>
</div>

View file

@ -1,234 +0,0 @@
<script lang="ts">
import type { Card } from './types';
interface Props {
card: Card;
className?: string;
showMetadata?: boolean;
compact?: boolean;
userCardCustomization?: {
cardBackgroundColor?: string;
cardBorderColor?: string;
cardLinkColor?: string;
cardTextColor?: string;
};
}
let {
card,
className = '',
showMetadata = false,
compact = false,
userCardCustomization,
}: Props = $props();
// Safe rendering function that won't break on errors
function renderCard(card: Card) {
try {
if (!card?.config) return null;
// Handle modular cards (beginner mode)
if (card.config.mode === 'beginner' && card.config.modules) {
return {
type: 'modular',
modules: card.config.modules || [],
};
}
// Handle template cards (advanced mode)
if (card.config.mode === 'advanced' && card.config.template) {
return {
type: 'template',
template: card.config.template,
values: card.config.values || {},
};
}
// Handle custom HTML cards (expert mode)
if (card.config.mode === 'expert') {
return {
type: 'custom',
html: card.config.html || '',
css: card.config.css || '',
};
}
return null;
} catch (error) {
console.error('Error rendering card:', error);
return null;
}
}
let cardData = $derived(renderCard(card));
let headerModule = $derived(cardData?.modules?.find((m) => m.type === 'header'));
let linksModule = $derived(cardData?.modules?.find((m) => m.type === 'links'));
// Generate CSS custom properties for user card customization
let cardCustomStyles = $derived(() => {
if (!userCardCustomization) return '';
const styles = [];
if (userCardCustomization.cardBackgroundColor) {
styles.push(`--card-bg: ${userCardCustomization.cardBackgroundColor}`);
}
if (userCardCustomization.cardBorderColor) {
styles.push(`--card-border: ${userCardCustomization.cardBorderColor}`);
}
if (userCardCustomization.cardLinkColor) {
styles.push(`--card-links: ${userCardCustomization.cardLinkColor}`);
}
if (userCardCustomization.cardTextColor) {
styles.push(`--card-text: ${userCardCustomization.cardTextColor}`);
}
return styles.join('; ');
});
</script>
<div
class="safe-card-renderer overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm {className}"
class:p-3={compact}
class:p-6={!compact}
style={cardCustomStyles()}
>
{#if cardData?.type === 'modular'}
<!-- Modular card rendering -->
<div class="space-y-4">
<!-- Header Module -->
{#if headerModule}
<div class="text-center">
<!-- Avatar -->
{#if headerModule.props?.avatar}
<div class="mx-auto mb-3 h-16 w-16 overflow-hidden rounded-full bg-gray-100">
<img
src={headerModule.props.avatar}
alt="Avatar"
class="h-full w-full object-cover"
onerror={(e) => {
e.currentTarget.style.display = 'none';
if (e.currentTarget.nextElementSibling) {
e.currentTarget.nextElementSibling.style.display = 'flex';
}
}}
/>
<div
class="hidden h-full w-full items-center justify-center bg-gray-200 text-lg font-bold text-gray-600"
>
{(headerModule.props?.title || 'U')[0].toUpperCase()}
</div>
</div>
{:else if headerModule.props?.title}
<div
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-lg font-bold text-white"
>
{headerModule.props.title[0].toUpperCase()}
</div>
{/if}
<!-- Title and Subtitle -->
{#if headerModule.props?.title}
<h3 class="text-xl font-bold text-gray-900">{headerModule.props.title}</h3>
{/if}
{#if headerModule.props?.subtitle}
<p class="mt-1 text-sm text-gray-600">{headerModule.props.subtitle}</p>
{/if}
</div>
{/if}
<!-- Links Module -->
{#if linksModule?.props?.links && linksModule.props.links.length > 0}
<div class="space-y-2">
{#each linksModule.props.links as link}
<div class="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
{#if link.icon}
<span class="text-lg">{link.icon}</span>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-gray-900">
{link.title || link.original_url}
</div>
{#if link.description}
<div class="truncate text-sm text-gray-600">{link.description}</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if cardData?.type === 'template'}
<!-- Template card rendering -->
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900">Template Card</h3>
<p class="mt-2 text-sm text-gray-600">Template: {cardData.template}</p>
</div>
{:else if cardData?.type === 'custom'}
<!-- Custom HTML card rendering -->
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900">Custom Card</h3>
<p class="mt-2 text-sm text-gray-600">Custom HTML/CSS Card</p>
</div>
{:else}
<!-- Fallback for invalid cards -->
<div class="text-center">
<div class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">
{card.title || card.metadata?.name || 'Unnamed Card'}
</h3>
{#if card.subtitle}
<p class="mt-1 text-sm text-gray-600">{card.subtitle}</p>
{/if}
</div>
{/if}
<!-- Metadata -->
{#if showMetadata}
<div class="mt-4 border-t border-gray-200 pt-3">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{card.metadata?.name || 'Unnamed Card'}</span>
<span>{card.config?.mode || 'unknown'} mode</span>
</div>
</div>
{/if}
</div>
<style>
.safe-card-renderer {
background-color: var(--card-bg, #ffffff);
border-color: var(--card-border, #e2e8f0);
color: var(--card-text, #0f172a);
}
.safe-card-renderer :global(a),
.safe-card-renderer :global(.card-link) {
color: var(--card-links, #0ea5e9);
}
.safe-card-renderer :global(a:hover),
.safe-card-renderer :global(.card-link:hover) {
color: var(--card-links, #0ea5e9);
opacity: 0.8;
}
/* Apply card text color to various text elements */
.safe-card-renderer :global(h1),
.safe-card-renderer :global(h2),
.safe-card-renderer :global(h3),
.safe-card-renderer :global(h4),
.safe-card-renderer :global(h5),
.safe-card-renderer :global(h6),
.safe-card-renderer :global(p),
.safe-card-renderer :global(.text-gray-900) {
color: var(--card-text, #0f172a);
}
</style>

View file

@ -1,190 +0,0 @@
<script lang="ts">
import type { TemplateVariable, CardConstraints } from './types';
import { cardSanitizer } from '$lib/services/cardSanitizer';
import CustomCard from './CustomCard.svelte';
interface Props {
template: string;
css?: string;
variables: TemplateVariable[];
values: Record<string, any>;
constraints?: CardConstraints;
className?: string;
}
let { template, css = '', variables, values, constraints = {}, className = '' }: Props = $props();
// Process template with variables
let processedHTML = $derived(() => {
if (!template) return '';
return cardSanitizer.replaceVariables(template, values || {});
});
// Default CSS if none provided
let finalCSS = $derived(() => {
return (
css ||
`
.card-content {
padding: 1.5rem;
height: 100%;
}
h1, h2, h3, h4, h5, h6 {
margin-bottom: 0.5rem;
line-height: 1.2;
}
p {
margin-bottom: 1rem;
line-height: 1.6;
}
a {
color: var(--primary, #3b82f6);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--primary-dark, #2563eb);
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.button {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--primary, #3b82f6);
color: white;
border-radius: 0.375rem;
text-decoration: none;
transition: all 0.2s;
}
.button:hover {
background: var(--primary-dark, #2563eb);
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
`
);
});
</script>
<div class="template-card-wrapper {className}">
{#if template}
<CustomCard html={processedHTML()} css={finalCSS()} {constraints} />
{:else}
<div class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p>No template provided</p>
</div>
{/if}
</div>
{#if variables.length > 0 && import.meta.env.DEV}
<!-- Debug panel for development -->
<details class="debug-panel">
<summary>Template Variables ({variables.length})</summary>
<div class="variables-list">
{#each variables as variable}
<div class="variable-item">
<span class="variable-name">{variable.name}</span>
<span class="variable-type">{variable.type}</span>
<span class="variable-value">
{values?.[variable.name] || 'undefined'}
</span>
</div>
{/each}
</div>
</details>
{/if}
<style>
.template-card-wrapper {
position: relative;
width: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
background: var(--theme-surface, #f9fafb);
border-radius: var(--theme-radius-lg, 0.75rem);
border: 2px dashed var(--theme-border, #e5e7eb);
color: var(--theme-text-muted, #6b7280);
min-height: 200px;
}
.empty-icon {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
opacity: 0.5;
}
/* Debug panel (only in development) */
.debug-panel {
margin-top: 1rem;
padding: 1rem;
background: #f3f4f6;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.debug-panel summary {
cursor: pointer;
font-weight: 600;
color: #4b5563;
}
.variables-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.variable-item {
display: flex;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: white;
border-radius: 0.25rem;
}
.variable-name {
font-weight: 600;
color: #1f2937;
}
.variable-type {
color: #6b7280;
font-size: 0.75rem;
background: #e5e7eb;
padding: 0 0.25rem;
border-radius: 0.125rem;
}
.variable-value {
margin-left: auto;
color: #059669;
font-family: monospace;
}
</style>

View file

@ -1,102 +0,0 @@
<script lang="ts">
interface Props {
html: string;
css: string;
}
let { html = $bindable(), css = $bindable() }: Props = $props();
</script>
<div class="code-editor">
<div class="editor-section">
<h3>HTML</h3>
<textarea
bind:value={html}
class="code-textarea"
placeholder="<div class='card-content'>
<h2>Your Title</h2>
<p>Your content here...</p>
</div>"
rows="12"
spellcheck="false"
></textarea>
</div>
<div class="editor-section">
<h3>CSS</h3>
<textarea
bind:value={css}
class="code-textarea"
placeholder="Add your custom CSS styles here..."
rows="12"
spellcheck="false"
></textarea>
</div>
<div class="editor-info">
<p>
💡 <strong>Tip:</strong> Your HTML and CSS will be sanitized for security. Scripts and dangerous
patterns will be removed.
</p>
<p>📏 <strong>Limits:</strong> HTML max 100KB, CSS max 50KB</p>
</div>
</div>
<style>
.code-editor {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.editor-section h3 {
margin-bottom: 0.5rem;
font-weight: 600;
color: #1f2937;
}
.code-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
background: #f9fafb;
tab-size: 2;
}
.code-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background: white;
}
.editor-info {
padding: 1rem;
background: #f0f9ff;
border: 1px solid #bfdbfe;
border-radius: 0.375rem;
}
.editor-info p {
margin: 0.5rem 0;
font-size: 0.875rem;
color: #1e40af;
}
.editor-info p:first-child {
margin-top: 0;
}
.editor-info p:last-child {
margin-bottom: 0;
}
.editor-info strong {
font-weight: 600;
}
</style>

View file

@ -1,363 +0,0 @@
<script lang="ts">
import type { Module } from '../types';
interface Props {
module: Module;
onUpdate: (module: Module) => void;
onRemove: () => void;
}
let { module, onUpdate, onRemove }: Props = $props();
let expanded = $state(false);
// Update module props
function updateProp(key: string, value: any) {
onUpdate({
...module,
props: {
...module.props,
[key]: value,
},
});
}
// Get module icon
function getModuleIcon(type: Module['type']): string {
const icons = {
header: '📄',
content: '📝',
links: '🔗',
media: '🖼️',
stats: '📊',
actions: '⚡',
footer: '📌',
custom: '🎨',
};
return icons[type] || '📦';
}
</script>
<div class="module-editor">
<div class="module-header">
<button class="expand-btn" onclick={() => (expanded = !expanded)}>
<span class="module-icon">{getModuleIcon(module.type)}</span>
<span class="module-type">{module.type}</span>
<svg
class="expand-icon"
class:rotated={expanded}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="module-actions">
<input
type="number"
value={module.order}
onchange={(e) => onUpdate({ ...module, order: parseInt(e.currentTarget.value) })}
class="order-input"
min="0"
title="Order"
/>
<button onclick={onRemove} class="remove-btn" title="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if expanded}
<div class="module-content">
{#if module.type === 'header'}
<div class="field">
<label>Title</label>
<input
type="text"
value={module.props.title || ''}
oninput={(e) => updateProp('title', e.currentTarget.value)}
placeholder="Enter title"
/>
</div>
<div class="field">
<label>Subtitle</label>
<input
type="text"
value={module.props.subtitle || ''}
oninput={(e) => updateProp('subtitle', e.currentTarget.value)}
placeholder="Enter subtitle"
/>
</div>
{:else if module.type === 'content'}
<div class="field">
<label>Text</label>
<textarea
value={module.props.text || ''}
oninput={(e) => updateProp('text', e.currentTarget.value)}
placeholder="Enter content"
rows="4"
></textarea>
</div>
{:else if module.type === 'links'}
<div class="field">
<label>Links (JSON)</label>
<textarea
value={JSON.stringify(module.props.links || [], null, 2)}
oninput={(e) => {
try {
const links = JSON.parse(e.currentTarget.value);
updateProp('links', links);
} catch {}
}}
placeholder="JSON array of links"
rows="4"
></textarea>
</div>
<div class="field">
<label>Style</label>
<select
value={module.props.style || 'button'}
onchange={(e) => updateProp('style', e.currentTarget.value)}
>
<option value="button">Button</option>
<option value="list">List</option>
<option value="card">Card</option>
</select>
</div>
{:else if module.type === 'media'}
<div class="field">
<label>Type</label>
<select
value={module.props.type || 'image'}
onchange={(e) => updateProp('type', e.currentTarget.value)}
>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="qr">QR Code</option>
</select>
</div>
{#if module.props.type === 'image'}
<div class="field">
<label>Image URL</label>
<input
type="text"
value={module.props.src || ''}
oninput={(e) => updateProp('src', e.currentTarget.value)}
placeholder="https://example.com/image.jpg"
/>
</div>
<div class="field">
<label>Alt Text</label>
<input
type="text"
value={module.props.alt || ''}
oninput={(e) => updateProp('alt', e.currentTarget.value)}
placeholder="Image description"
/>
</div>
{:else if module.props.type === 'qr'}
<div class="field">
<label>QR Data</label>
<input
type="text"
value={module.props.qrData || ''}
oninput={(e) => updateProp('qrData', e.currentTarget.value)}
placeholder="https://example.com"
/>
</div>
{/if}
{:else if module.type === 'stats'}
<div class="field">
<label>Stats (JSON)</label>
<textarea
value={JSON.stringify(module.props.stats || [], null, 2)}
oninput={(e) => {
try {
const stats = JSON.parse(e.currentTarget.value);
updateProp('stats', stats);
} catch {}
}}
placeholder="JSON array of stats"
rows="4"
></textarea>
</div>
{:else if module.type === 'footer'}
<div class="field">
<label>Text</label>
<input
type="text"
value={module.props.text || ''}
oninput={(e) => updateProp('text', e.currentTarget.value)}
placeholder="Footer text"
/>
</div>
<div class="field">
<label>Copyright</label>
<input
type="text"
value={module.props.copyright || ''}
oninput={(e) => updateProp('copyright', e.currentTarget.value)}
placeholder="© 2024"
/>
</div>
{:else}
<div class="field">
<label>Custom Props (JSON)</label>
<textarea
value={JSON.stringify(module.props, null, 2)}
oninput={(e) => {
try {
const props = JSON.parse(e.currentTarget.value);
onUpdate({ ...module, props });
} catch {}
}}
placeholder="JSON object"
rows="4"
></textarea>
</div>
{/if}
<div class="field">
<label>Visibility</label>
<select
value={module.visibility || 'always'}
onchange={(e) => onUpdate({ ...module, visibility: e.currentTarget.value as any })}
>
<option value="always">Always</option>
<option value="desktop">Desktop Only</option>
<option value="mobile">Mobile Only</option>
</select>
</div>
</div>
{/if}
</div>
<style>
.module-editor {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
}
.module-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f9fafb;
}
.expand-btn {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-weight: 500;
color: #374151;
}
.module-icon {
font-size: 1.25rem;
}
.module-type {
text-transform: capitalize;
}
.expand-icon {
margin-left: auto;
transition: transform 0.2s;
}
.expand-icon.rotated {
transform: rotate(180deg);
}
.module-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.order-input {
width: 50px;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.875rem;
}
.remove-btn {
padding: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: color 0.2s;
}
.remove-btn:hover {
color: #ef4444;
}
.module-content {
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
.field {
margin-bottom: 1rem;
}
.field:last-child {
margin-bottom: 0;
}
.field label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.field input,
.field select,
.field textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field textarea {
font-family: monospace;
resize: vertical;
}
</style>

View file

@ -1,202 +0,0 @@
<script lang="ts">
import type { TemplateVariable } from '../types';
import { cardSanitizer } from '$lib/services/cardSanitizer';
interface Props {
template: string;
css: string;
variables: TemplateVariable[];
values: Record<string, any>;
}
let {
template = $bindable(),
css = $bindable(),
variables = $bindable(),
values = $bindable(),
}: Props = $props();
// Extract variables when template changes
$effect(() => {
variables = cardSanitizer.extractVariables(template);
});
</script>
<div class="template-editor">
<div class="editor-section">
<h3>HTML Template</h3>
<p class="help-text">Use {'{{variable}}'} syntax for dynamic content</p>
<textarea
bind:value={template}
class="code-editor"
placeholder="Add your HTML template with {{ variable }} syntax..."
rows="10"
></textarea>
</div>
<div class="editor-section">
<h3>CSS Styles</h3>
<textarea
bind:value={css}
class="code-editor"
placeholder="Add your CSS styles here..."
rows="8"
></textarea>
</div>
{#if variables.length > 0}
<div class="editor-section">
<h3>Template Variables</h3>
<div class="variables-list">
{#each variables as variable}
<div class="variable-field">
<label>
{variable.label || variable.name}
<span class="variable-type">({variable.type})</span>
</label>
{#if variable.type === 'text'}
<input
type="text"
value={values[variable.name] || ''}
oninput={(e) => (values[variable.name] = e.currentTarget.value)}
placeholder={variable.placeholder || `Enter ${variable.name}`}
/>
{:else if variable.type === 'number'}
<input
type="number"
value={values[variable.name] || 0}
oninput={(e) => (values[variable.name] = parseFloat(e.currentTarget.value))}
placeholder={variable.placeholder || '0'}
/>
{:else if variable.type === 'color'}
<input
type="color"
value={values[variable.name] || '#000000'}
oninput={(e) => (values[variable.name] = e.currentTarget.value)}
/>
{:else if variable.type === 'boolean'}
<label class="checkbox-label">
<input
type="checkbox"
checked={values[variable.name] || false}
onchange={(e) => (values[variable.name] = e.currentTarget.checked)}
/>
{variable.placeholder || 'Enabled'}
</label>
{:else if variable.type === 'link'}
<input
type="url"
value={values[variable.name] || ''}
oninput={(e) => (values[variable.name] = e.currentTarget.value)}
placeholder={variable.placeholder || 'https://example.com'}
/>
{:else if variable.type === 'image'}
<input
type="url"
value={values[variable.name] || ''}
oninput={(e) => (values[variable.name] = e.currentTarget.value)}
placeholder={variable.placeholder || 'https://example.com/image.jpg'}
/>
{:else}
<input
type="text"
value={values[variable.name] || ''}
oninput={(e) => (values[variable.name] = e.currentTarget.value)}
placeholder={variable.placeholder || `Enter ${variable.name}`}
/>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.template-editor {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.editor-section h3 {
margin-bottom: 0.5rem;
font-weight: 600;
color: #1f2937;
}
.help-text {
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
.code-editor {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
background: #f9fafb;
}
.code-editor:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background: white;
}
.variables-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.variable-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.variable-field label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.variable-type {
font-size: 0.75rem;
color: #6b7280;
font-weight: normal;
}
.variable-field input {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.variable-field input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #374151;
}
.checkbox-label input {
width: auto;
}
</style>

View file

@ -1,55 +0,0 @@
<script lang="ts">
import type { ActionsModuleProps } from '../types';
let { actions = [], layout = 'horizontal', alignment = 'left' }: ActionsModuleProps = $props();
let containerClass = $derived(() => {
const layoutClasses = {
horizontal: 'flex flex-wrap gap-2',
vertical: 'flex flex-col gap-2',
grid: 'grid grid-cols-2 gap-2',
};
const alignmentClasses = {
left: 'justify-start',
center: 'justify-center',
right: 'justify-end',
between: 'justify-between',
};
return `${layoutClasses[layout]} ${layout === 'horizontal' ? alignmentClasses[alignment] : ''}`;
});
function getButtonClass(variant: string = 'primary') {
const classes = {
primary: 'bg-theme-primary text-theme-background hover:bg-theme-primary-hover',
secondary: 'bg-theme-surface-hover text-theme-text hover:bg-theme-border',
ghost: 'text-theme-text hover:bg-theme-surface-hover',
link: 'text-theme-accent hover:text-theme-accent-hover underline-offset-4 hover:underline',
};
return `${classes[variant] || classes.primary} rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`;
}
function handleClick(action: any) {
if (action.href) {
window.open(action.href, '_blank');
} else if (action.action) {
action.action();
}
}
</script>
<div class="actions-module {containerClass()}">
{#each actions as action}
<button
onclick={() => handleClick(action)}
class={getButtonClass(action.variant)}
disabled={action.disabled}
>
{#if action.icon}
<span class="mr-2">{action.icon}</span>
{/if}
{action.label}
</button>
{/each}
</div>

View file

@ -1,72 +0,0 @@
<script lang="ts">
import type { ContentModuleProps } from '../types';
let {
text = '',
html = '',
items = [],
truncate = false,
maxLines = 3,
}: ContentModuleProps = $props();
let truncateClass = $derived(truncate ? `line-clamp-${maxLines}` : '');
</script>
<div class="content-module">
{#if html}
{@html html}
{:else if text}
<p class="text-theme-text {truncateClass}">{text}</p>
{:else if items.length > 0}
<ul class="space-y-2">
{#each items as item}
<li class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if item.icon}
<span class="text-theme-text-muted">{item.icon}</span>
{/if}
<span class="text-sm text-theme-text">{item.label}</span>
</div>
<span class="font-medium text-theme-text">{item.value}</span>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.line-clamp-4 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.line-clamp-5 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
}
</style>

View file

@ -1,53 +0,0 @@
<script lang="ts">
import type { FooterModuleProps } from '../types';
let { text = '', links = [], copyright = '', socialLinks = [] }: FooterModuleProps = $props();
</script>
<div class="footer-module border-t border-theme-border pt-4">
{#if text}
<p class="text-sm text-theme-text-muted">{text}</p>
{/if}
{#if links.length > 0}
<div class="flex flex-wrap gap-4">
{#each links as link}
<a
href={link.href}
class="text-sm text-theme-accent hover:text-theme-accent-hover"
target="_blank"
rel="noopener noreferrer"
>
{#if link.icon}
<span class="mr-1">{link.icon}</span>
{/if}
{link.label}
</a>
{/each}
</div>
{/if}
{#if socialLinks.length > 0}
<div class="mt-3 flex gap-3">
{#each socialLinks as social}
<a
href={social.url}
class="text-theme-text-muted hover:text-theme-text"
title={social.platform}
target="_blank"
rel="noopener noreferrer"
>
{#if social.icon}
<span class="text-lg">{social.icon}</span>
{:else}
<span class="text-sm">{social.platform}</span>
{/if}
</a>
{/each}
</div>
{/if}
{#if copyright}
<p class="mt-3 text-xs text-theme-text-muted">{copyright}</p>
{/if}
</div>

View file

@ -1,60 +0,0 @@
<script lang="ts">
import type { HeaderModuleProps } from '../types';
let {
title = '',
subtitle = '',
avatar = '',
avatarAlt = '',
badge = '',
icon = '',
actions = [],
}: HeaderModuleProps = $props();
</script>
<div class="header-module flex items-start justify-between">
<div class="flex items-center gap-4">
{#if avatar}
<img src={avatar} alt={avatarAlt || title} class="h-12 w-12 rounded-full object-cover" />
{:else if icon}
<div class="bg-theme-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<span class="text-2xl">{icon}</span>
</div>
{/if}
<div>
{#if title}
<h3 class="text-lg font-semibold text-theme-text">
{title}
{#if badge}
<span class="ml-2 rounded-full bg-theme-accent px-2 py-0.5 text-xs text-white">
{badge}
</span>
{/if}
</h3>
{/if}
{#if subtitle}
<p class="text-sm text-theme-text-muted">{subtitle}</p>
{/if}
</div>
</div>
{#if actions.length > 0}
<div class="flex gap-2">
{#each actions as action}
<button
onclick={action.action}
class="rounded-lg p-2 text-theme-text hover:bg-theme-surface-hover"
title={action.label}
>
{#if action.icon}
<span>{action.icon}</span>
{:else}
<span class="text-sm">{action.label}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -1,125 +0,0 @@
<script lang="ts">
import type { LinksModuleProps } from '../types';
let {
links = [],
style = 'button',
columns = 1,
showDescription = false,
showIcon = true,
target = '_blank',
buttonVariant = 'secondary',
gap = 'md',
}: LinksModuleProps = $props();
let containerClass = $derived(() => {
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-2',
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-3',
lg: 'gap-4',
};
return `grid ${columnClasses[columns] || 'grid-cols-1'} ${gapClasses[gap] || 'gap-3'}`;
});
function getButtonClass(variant: string = 'secondary') {
const classes = {
primary: 'bg-theme-primary text-theme-background hover:bg-theme-primary-hover',
secondary:
'bg-theme-surface hover:bg-theme-surface-hover text-theme-text border border-theme-border',
ghost: 'text-theme-text hover:bg-theme-surface-hover',
outline:
'border-2 border-theme-primary text-theme-primary hover:bg-theme-primary hover:text-theme-background',
};
return classes[variant] || classes.secondary;
}
function getLinkStyleClass() {
const baseClass = 'transition-all duration-200 rounded-lg';
switch (style) {
case 'button':
return `${baseClass} ${getButtonClass(buttonVariant)} px-4 py-3 flex items-center justify-between group`;
case 'list':
return `${baseClass} px-3 py-2 hover:bg-theme-surface-hover flex items-center justify-between group`;
case 'card':
return `${baseClass} bg-theme-surface border border-theme-border hover:border-theme-accent hover:shadow-md px-4 py-3 flex items-center justify-between group`;
default:
return `${baseClass} ${getButtonClass(buttonVariant)} px-4 py-3 flex items-center justify-between group`;
}
}
function handleClick(href: string) {
if (href) {
window.open(href, target);
}
}
</script>
<div class="links-module {containerClass()}">
{#each links as link}
<button
onclick={() => handleClick(link.href)}
class={getLinkStyleClass()}
disabled={link.disabled}
title={link.description || link.label}
>
<div class="flex flex-1 items-center gap-3 text-left">
{#if showIcon && link.icon}
<span class="flex-shrink-0 text-xl">{link.icon}</span>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate font-medium">
{link.label}
</div>
{#if showDescription && link.description}
<div class="truncate text-sm text-theme-text-muted">
{link.description}
</div>
{/if}
</div>
</div>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted transition-colors group-hover:text-theme-accent"
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>
</button>
{/each}
</div>
<style>
.links-module button {
width: 100%;
text-align: left;
}
.links-module button:hover {
transform: translateX(2px);
}
.links-module button:active {
transform: scale(0.98);
}
@media (max-width: 640px) {
.links-module {
grid-template-columns: 1fr !important;
}
}
</style>

View file

@ -1,44 +0,0 @@
<script lang="ts">
import type { MediaModuleProps } from '../types';
import { generateQRCodeURL } from '$lib/qrcode';
let {
type = 'image',
src = '',
alt = '',
aspectRatio = '16/9',
objectFit = 'cover',
qrData = '',
qrSize = 200,
qrColor = 'black',
icon = '',
iconSize = '3rem',
}: MediaModuleProps = $props();
</script>
<div class="media-module">
{#if type === 'image' && src}
<img
{src}
{alt}
class="w-full rounded-lg"
style="aspect-ratio: {aspectRatio}; object-fit: {objectFit};"
/>
{:else if type === 'video' && src}
<video {src} controls class="w-full rounded-lg" style="aspect-ratio: {aspectRatio};">
<track kind="captions" />
</video>
{:else if type === 'qr' && qrData}
<div class="flex justify-center">
<img
src={generateQRCodeURL(qrData, qrSize, qrColor, 'png')}
alt="QR Code"
class="rounded-lg bg-white p-2"
/>
</div>
{:else if type === 'icon' && icon}
<div class="flex justify-center">
<span style="font-size: {iconSize};">{icon}</span>
</div>
{/if}
</div>

View file

@ -1,49 +0,0 @@
<script lang="ts">
import type { StatsModuleProps } from '../types';
let { stats = [], layout = 'grid' }: StatsModuleProps = $props();
let layoutClass = $derived(() => {
const classes = {
grid: 'grid grid-cols-2 gap-4',
list: 'space-y-3',
compact: 'flex flex-wrap gap-4',
};
return classes[layout] || classes.grid;
});
</script>
<div class="stats-module {layoutClass()}">
{#each stats as stat}
<div class="stat-item {layout === 'compact' ? 'flex items-center gap-2' : ''}">
{#if stat.icon}
<span class="text-2xl" style="color: {stat.color || 'inherit'}">
{stat.icon}
</span>
{/if}
<div class={layout === 'compact' ? '' : 'mt-1'}>
<div class="text-2xl font-bold text-theme-text" style="color: {stat.color || 'inherit'}">
{stat.value}
{#if stat.change}
<span class="ml-1 text-sm {stat.change > 0 ? 'text-green-500' : 'text-red-500'}">
{stat.change > 0 ? '↑' : '↓'}
{Math.abs(stat.change)}%
</span>
{/if}
</div>
<div class="text-xs text-theme-text-muted">{stat.label}</div>
</div>
</div>
{/each}
</div>
<style>
.stat-item {
transition: transform 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
}
</style>

View file

@ -1,275 +0,0 @@
// ============================================
// SIMPLIFIED CARD SYSTEM V2 - Using Discriminated Unions
// ============================================
// Base Types
export type RenderMode = 'beginner' | 'advanced' | 'expert';
// Card Metadata
export interface CardMetadata {
name?: string;
description?: string;
author?: string;
version?: string;
created?: string;
updated?: string;
tags?: string[];
isActive?: boolean;
isPublic?: boolean;
}
// Card Constraints
export interface CardConstraints {
aspectRatio?: string;
maxWidth?: string;
minHeight?: string;
maxHeight?: string;
maxModules?: number;
maxHTMLSize?: number;
maxCSSSize?: number;
preventScripts?: boolean;
}
// Theme Configuration
export interface Theme {
id?: string;
name?: string;
colors?: Record<string, string>;
typography?: {
fontFamily?: string;
fontSize?: Record<string, string>;
fontWeight?: Record<string, number>;
lineHeight?: Record<string, string>;
};
spacing?: Record<string, string>;
borderRadius?: Record<string, string>;
shadows?: Record<string, string>;
}
// Module Definition
export interface Module {
id: string;
type: 'header' | 'content' | 'footer' | 'media' | 'stats' | 'actions' | 'links' | 'custom';
props: Record<string, any>;
order: number;
visibility?: 'always' | 'desktop' | 'mobile';
grid?: {
col?: number;
row?: number;
colSpan?: number;
rowSpan?: number;
};
className?: string;
}
// Template Variable
export interface TemplateVariable {
name: string;
type: 'text' | 'number' | 'image' | 'link' | 'list' | 'boolean' | 'color';
label: string;
default?: any;
required?: boolean;
placeholder?: string;
options?: Array<{ label: string; value: any }>;
}
// ============================================
// DISCRIMINATED UNION FOR CARD CONFIGURATIONS
// ============================================
export type CardConfig =
| {
mode: 'beginner';
modules: Module[];
theme?: Theme;
layout?: {
columns?: number;
gap?: string;
padding?: string;
};
animations?: {
hover?: boolean;
entrance?: 'fade' | 'slide' | 'scale' | 'none';
};
}
| {
mode: 'advanced';
template: string;
css?: string;
variables: TemplateVariable[];
values: Record<string, any>;
}
| {
mode: 'expert';
html: string;
css: string;
javascript?: string;
};
// Main Card Interface (Consolidated from UnifiedCard)
export interface Card {
id?: string;
user_id?: string;
type?: 'user' | 'template' | 'system';
template_id?: string;
source?: 'created' | 'duplicated' | 'imported' | 'migrated';
config: CardConfig;
metadata?: CardMetadata;
constraints?: CardConstraints;
page?: string;
position?: number;
visibility?: 'private' | 'public' | 'unlisted';
variant?: 'default' | 'compact' | 'hero' | 'minimal' | 'glass' | 'gradient' | string;
tags?: string[];
category?: string;
usage_count?: number;
likes_count?: number;
is_featured?: boolean;
allow_duplication?: boolean;
created?: string;
updated?: string;
}
// Database Card Interface
export interface DBCard {
id: string;
user_id: string;
config: string; // JSON stringified CardConfig
metadata: string; // JSON stringified CardMetadata
constraints: string; // JSON stringified CardConstraints
variant?: string;
created: string;
updated: string;
}
// ============================================
// MODULE PROP TYPES (Simplified)
// ============================================
export interface ModuleProps {
header: {
title?: string;
subtitle?: string;
avatar?: string;
badge?: string;
icon?: string;
};
content: {
text?: string;
html?: string;
truncate?: boolean;
maxLines?: number;
};
links: {
links: Array<{
label: string;
href: string;
icon?: string;
description?: string;
}>;
style?: 'button' | 'list' | 'card';
columns?: 1 | 2;
target?: '_blank' | '_self';
};
media: {
type: 'image' | 'video' | 'qr';
src?: string;
alt?: string;
aspectRatio?: string;
qrData?: string;
};
stats: {
stats: Array<{
label: string;
value: string | number;
change?: number;
icon?: string;
}>;
layout?: 'grid' | 'list';
};
actions: {
actions: Array<{
label: string;
href?: string;
onClick?: () => void;
variant?: 'primary' | 'secondary' | 'ghost';
icon?: string;
}>;
layout?: 'horizontal' | 'vertical';
};
footer: {
text?: string;
links?: Array<{
label: string;
href: string;
}>;
copyright?: string;
};
custom: {
html: string;
css?: string;
};
}
// Type Guards
export function isBeginnerCard(
config: CardConfig
): config is Extract<CardConfig, { mode: 'beginner' }> {
return config.mode === 'beginner';
}
export function isAdvancedCard(
config: CardConfig
): config is Extract<CardConfig, { mode: 'advanced' }> {
return config.mode === 'advanced';
}
export function isExpertCard(
config: CardConfig
): config is Extract<CardConfig, { mode: 'expert' }> {
return config.mode === 'expert';
}
// Conversion Types
export interface CardConverter {
toModular(config: CardConfig): Promise<Extract<CardConfig, { mode: 'beginner' }>>;
toTemplate(config: CardConfig): Promise<Extract<CardConfig, { mode: 'advanced' }>>;
toCustom(config: CardConfig): Promise<Extract<CardConfig, { mode: 'expert' }>>;
}
// Validation Result
export interface ValidationResult {
valid: boolean;
errors?: Array<{
field: string;
message: string;
}>;
}
// Card Events
export interface CardEvent {
type: 'created' | 'updated' | 'deleted' | 'converted';
cardId: string;
timestamp: number;
data?: any;
}
// Card Store Actions
export interface CardActions {
create(config: CardConfig, metadata?: CardMetadata): Promise<Card>;
update(id: string, updates: Partial<Card>): Promise<Card>;
delete(id: string): Promise<boolean>;
convert(id: string, targetMode: RenderMode): Promise<Card>;
duplicate(id: string): Promise<Card>;
validate(card: Card): ValidationResult;
}
// Export all types
export type { Theme as ThemeConfig }; // Alias for backward compatibility
// Legacy aliases for backward compatibility
export type UnifiedCard = Card;
export type ModularConfig = Extract<CardConfig, { mode: 'beginner' }>;
export type TemplateConfig = Extract<CardConfig, { mode: 'advanced' }>;
export type CustomHTMLConfig = Extract<CardConfig, { mode: 'expert' }>;
export type { Module as ModuleConfig };

View file

@ -1,328 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
GDPRManager,
acceptAllCookies,
acceptNecessaryOnly,
type GDPRConsent,
} from '$lib/gdpr/compliance';
import { slide } from 'svelte/transition';
// State
let showBanner = $state(false);
let showDetails = $state(false);
let consent = $state<GDPRConsent | null>(null);
let customConsent = $state({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
});
onMount(() => {
// Prüfe ob Banner gezeigt werden muss
showBanner = GDPRManager.needsConsent();
consent = GDPRManager.getConsent();
// Event Listener für Consent-Updates
const handleConsentUpdate = (event: CustomEvent) => {
consent = event.detail;
showBanner = false;
};
window.addEventListener('gdpr:consent-updated', handleConsentUpdate as EventListener);
return () => {
window.removeEventListener('gdpr:consent-updated', handleConsentUpdate as EventListener);
};
});
function handleAcceptAll() {
acceptAllCookies();
showBanner = false;
}
function handleAcceptNecessary() {
acceptNecessaryOnly();
showBanner = false;
}
function handleSaveCustom() {
GDPRManager.setConsent(customConsent);
showBanner = false;
}
function toggleDetails() {
showDetails = !showDetails;
}
function handleCustomChange(type: keyof typeof customConsent, value: boolean) {
customConsent = { ...customConsent, [type]: value };
}
</script>
{#if showBanner}
<div
class="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900"
transition:slide={{ duration: 300 }}
>
<div class="mx-auto max-w-7xl p-4 md:p-6">
{#if !showDetails}
<!-- Basis Cookie Banner -->
<div class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<!-- Content -->
<div class="flex-1">
<div class="flex items-start space-x-3">
<!-- Cookie Icon -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"
>
<svg
class="h-5 w-5 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<!-- Text -->
<div class="flex-1">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
Cookies & Datenschutz
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
Wir verwenden Cookies und ähnliche Technologien, um Ihnen die bestmögliche
Erfahrung zu bieten. Einige sind technisch notwendig, andere helfen uns die
Website zu verbessern und zu analysieren.
</p>
<!-- Links -->
<div class="mt-2 flex items-center space-x-4 text-xs">
<a href="/datenschutz" class="text-blue-600 hover:underline dark:text-blue-400">
Datenschutzerklärung
</a>
<a href="/impressum" class="text-blue-600 hover:underline dark:text-blue-400">
Impressum
</a>
<button
onclick={toggleDetails}
class="text-blue-600 hover:underline dark:text-blue-400"
>
Details anzeigen
</button>
</div>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex w-full flex-col gap-3 sm:flex-row md:w-auto">
<button
onclick={handleAcceptNecessary}
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Nur notwendige
</button>
<button
onclick={toggleDetails}
class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20"
>
Anpassen
</button>
<button
onclick={handleAcceptAll}
class="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Alle akzeptieren
</button>
</div>
</div>
{:else}
<!-- Detaillierte Cookie-Einstellungen -->
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Cookie-Einstellungen
</h3>
<button
onclick={toggleDetails}
class="p-2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
aria-label="Schließen"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Cookie Categories -->
<div class="grid gap-4">
<!-- Notwendige Cookies -->
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Notwendige Cookies</h4>
<p class="text-sm text-gray-600 dark:text-gray-300">
Technisch erforderlich für die Grundfunktionen der Website
</p>
</div>
<div class="flex items-center">
<span class="mr-2 text-sm text-gray-500">Immer aktiv</span>
<div class="relative h-6 w-10 rounded-full bg-green-600">
<div class="absolute right-1 top-1 h-4 w-4 rounded-full bg-white"></div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Speichern von Login-Status, Spracheinstellungen und technischen Präferenzen
</p>
</div>
<!-- Analytics Cookies -->
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Analytics Cookies</h4>
<p class="text-sm text-gray-600 dark:text-gray-300">
Helfen uns die Website zu verbessern
</p>
</div>
<button
onclick={() => handleCustomChange('analytics', !customConsent.analytics)}
class="relative"
>
<div
class="h-6 w-10 rounded-full transition-colors {customConsent.analytics
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'}"
>
<div
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.analytics
? 'translate-x-4'
: 'translate-x-1'}"
></div>
</div>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Anonyme Nutzungsstatistiken, Seitenaufrufe und Klick-Verhalten
</p>
</div>
<!-- Marketing Cookies -->
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Marketing Cookies</h4>
<p class="text-sm text-gray-600 dark:text-gray-300">
Für personalisierte Inhalte und Werbung
</p>
</div>
<button
onclick={() => handleCustomChange('marketing', !customConsent.marketing)}
class="relative"
>
<div
class="h-6 w-10 rounded-full transition-colors {customConsent.marketing
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'}"
>
<div
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.marketing
? 'translate-x-4'
: 'translate-x-1'}"
></div>
</div>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Newsletter-Präferenzen und zielgerichtete Kommunikation
</p>
</div>
<!-- Preferences Cookies -->
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<div>
<h4 class="font-medium text-gray-900 dark:text-white">Präferenz Cookies</h4>
<p class="text-sm text-gray-600 dark:text-gray-300">
Speichern Ihre persönlichen Einstellungen
</p>
</div>
<button
onclick={() => handleCustomChange('preferences', !customConsent.preferences)}
class="relative"
>
<div
class="h-6 w-10 rounded-full transition-colors {customConsent.preferences
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'}"
>
<div
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.preferences
? 'translate-x-4'
: 'translate-x-1'}"
></div>
</div>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Theme-Einstellungen, Layout-Präferenzen und Benutzeroberfläche
</p>
</div>
</div>
<!-- Action Buttons -->
<div
class="flex flex-col gap-3 border-t border-gray-200 pt-4 dark:border-gray-700 sm:flex-row"
>
<button
onclick={handleAcceptNecessary}
class="flex-1 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Nur notwendige Cookies
</button>
<button
onclick={handleSaveCustom}
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Auswahl speichern
</button>
<button
onclick={handleAcceptAll}
class="flex-1 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
Alle akzeptieren
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
/* Custom animations */
.toggle-switch {
transition: all 0.2s ease-in-out;
}
/* Focus styles for accessibility */
button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
</style>

View file

@ -1,118 +0,0 @@
<script lang="ts">
import type { BlogPostWithMeta } from '../../../content/config';
let { posts = [] } = $props<{ posts?: BlogPostWithMeta[] }>();
let formattedPosts = $derived(
posts.slice(0, 3).map((post) => ({
...post,
formattedDate: new Date(post.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
}))
);
</script>
<section class="bg-theme-surface py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-12 text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text">Insights & Wissen</h2>
<p class="mx-auto max-w-2xl text-lg text-theme-text-muted">
Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für
erfolgreiches Link-Management.
</p>
</div>
{#if formattedPosts.length > 0}
<div class="mb-8 grid gap-8 md:grid-cols-3">
{#each formattedPosts as post}
<article
class="overflow-hidden rounded-lg bg-theme-background shadow-md transition-shadow hover:shadow-lg"
>
{#if post.image}
<img
src={post.image}
alt={post.title}
class="h-48 w-full object-cover"
loading="lazy"
/>
{:else}
<div class="h-48 w-full bg-gradient-to-br from-blue-500 to-purple-600"></div>
{/if}
<div class="p-6">
<div class="mb-3 flex items-center gap-2">
<span class="text-xs text-theme-text-muted">
{post.formattedDate}
</span>
<span class="text-xs text-theme-text-muted"></span>
<span class="text-xs text-blue-600">
{post.category}
</span>
</div>
<h3 class="mb-2 text-xl font-semibold text-theme-text">
<a href="/blog/{post.slug}" class="transition hover:text-theme-primary">
{post.title}
</a>
</h3>
<p class="mb-4 line-clamp-2 text-theme-text-muted">
{post.excerpt}
</p>
<a
href="/blog/{post.slug}"
class="inline-flex items-center gap-1 font-medium text-theme-primary hover:text-theme-primary-hover"
>
Weiterlesen
<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="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
</article>
{/each}
</div>
{:else}
<div class="py-12 text-center">
<p class="mb-4 text-theme-text-muted">
Bald verfügbar: Spannende Artikel über URL-Optimierung und digitales Marketing.
</p>
</div>
{/if}
<div class="text-center">
<a
href="/blog"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 text-white transition hover:bg-theme-primary-hover"
>
Alle Artikel ansehen
<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 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</a>
</div>
</div>
</section>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -1,553 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
let selectedFeature = $state<'links' | 'cards' | 'analytics' | 'qr' | 'team' | 'templates'>(
'links'
);
let animationKey = $state(0);
$effect(() => {
// Trigger re-animation when feature changes
animationKey++;
});
</script>
<section id="features" class="bg-theme-surface/50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">
Alle Features die du brauchst
</h2>
<p class="mx-auto mb-12 max-w-2xl text-lg text-theme-text-muted">
Von Link-Verkürzung bis Team-Kollaboration - alles in einer Plattform vereint
</p>
</div>
<div class="grid gap-8 lg:grid-cols-2">
<!-- Feature Navigation -->
<div class="space-y-2">
<button
onclick={() => (selectedFeature = 'links')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'links'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
'links'
? 'bg-white/20'
: 'bg-theme-primary/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'links' ? 'text-white' : 'text-theme-primary'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
</div>
<div class="flex-1">
<h3
class="font-semibold {selectedFeature === 'links' ? 'text-white' : 'text-theme-text'}"
>
Smart Link Management
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'links'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
Kurze URLs mit erweiterten Features
</p>
</div>
</button>
<button
onclick={() => (selectedFeature = 'cards')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'cards'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
'cards'
? 'bg-white/20'
: 'bg-purple-600/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'cards' ? 'text-white' : 'text-purple-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
</div>
<div class="flex-1">
<h3
class="font-semibold {selectedFeature === 'cards' ? 'text-white' : 'text-theme-text'}"
>
Profilkarten Builder
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'cards'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
3-Stufen Builder mit Live-Preview
</p>
</div>
</button>
<button
onclick={() => (selectedFeature = 'analytics')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'analytics'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
'analytics'
? 'bg-white/20'
: 'bg-blue-600/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'analytics' ? 'text-white' : 'text-blue-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
</div>
<div class="flex-1">
<h3
class="font-semibold {selectedFeature === 'analytics'
? 'text-white'
: 'text-theme-text'}"
>
Professionelle Analytics
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'analytics'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
Echtzeit-Tracking und Insights
</p>
</div>
</button>
<button
onclick={() => (selectedFeature = 'qr')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'qr'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'qr'
? 'bg-white/20'
: 'bg-green-600/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'qr' ? 'text-white' : 'text-green-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="font-semibold {selectedFeature === 'qr' ? 'text-white' : 'text-theme-text'}">
QR-Code Generator
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'qr'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
Anpassbare Designs und Farben
</p>
</div>
</button>
<button
onclick={() => (selectedFeature = 'team')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'team'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'team'
? 'bg-white/20'
: 'bg-indigo-600/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'team' ? 'text-white' : 'text-indigo-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div class="flex-1">
<h3
class="font-semibold {selectedFeature === 'team' ? 'text-white' : 'text-theme-text'}"
>
Team Kollaboration
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'team'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
Workspaces und Berechtigungen
</p>
</div>
</button>
<button
onclick={() => (selectedFeature = 'templates')}
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
'templates'
? 'bg-theme-primary text-white shadow-lg'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
'templates'
? 'bg-white/20'
: 'bg-pink-600/10'}"
>
<svg
class="h-6 w-6 {selectedFeature === 'templates' ? 'text-white' : 'text-pink-600'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
</div>
<div class="flex-1">
<h3
class="font-semibold {selectedFeature === 'templates'
? 'text-white'
: 'text-theme-text'}"
>
Template Store
</h3>
<p
class="mt-1 text-sm {selectedFeature === 'templates'
? 'text-white/80'
: 'text-theme-text-muted'}"
>
Vorgefertigte Designs und Layouts
</p>
</div>
</button>
</div>
<!-- Feature Preview -->
<div class="flex items-center justify-center">
<div key={animationKey} class="animate-fade-in relative w-full max-w-md">
{#if selectedFeature === 'links'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">Smart Link Features</h4>
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Custom Short Codes</span>
</div>
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Ablaufdatum festlegen</span>
</div>
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Click-Limits definieren</span>
</div>
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Passwortschutz aktivieren</span>
</div>
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Tags zur Organisation</span>
</div>
<div class="flex items-center gap-3">
<div class="h-2 w-2 rounded-full bg-green-500"></div>
<span class="text-sm text-theme-text">Bulk-Operationen</span>
</div>
</div>
<div class="bg-theme-primary/10 mt-6 rounded-lg p-3">
<p class="text-xs text-theme-primary">
Beispiel: ulo.ad/produkt-launch → 500 Clicks, läuft in 7 Tagen ab
</p>
</div>
</div>
{/if}
{#if selectedFeature === 'cards'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">3-Stufen Builder</h4>
<div class="space-y-4">
<div
class="rounded-lg border border-green-500 bg-green-50 p-3 dark:bg-green-900/20"
>
<p class="font-medium text-green-700 dark:text-green-400">👶 Anfänger</p>
<p class="mt-1 text-sm text-green-600 dark:text-green-300">
Einfache Vorlagen, schnell anpassbar
</p>
</div>
<div class="rounded-lg border border-blue-500 bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="font-medium text-blue-700 dark:text-blue-400">💪 Fortgeschritten</p>
<p class="mt-1 text-sm text-blue-600 dark:text-blue-300">
Drag & Drop Module, mehr Kontrolle
</p>
</div>
<div
class="rounded-lg border border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
>
<p class="font-medium text-purple-700 dark:text-purple-400">🚀 Experte</p>
<p class="mt-1 text-sm text-purple-600 dark:text-purple-300">
Volle Freiheit, eigener Code möglich
</p>
</div>
</div>
</div>
{/if}
{#if selectedFeature === 'analytics'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">Analytics Dashboard</h4>
<div class="space-y-4">
<!-- Mini chart visualization -->
<div class="flex items-end gap-2">
<div class="bg-theme-primary/20 h-16 w-8 rounded"></div>
<div class="bg-theme-primary/40 h-24 w-8 rounded"></div>
<div class="bg-theme-primary/60 h-32 w-8 rounded"></div>
<div class="bg-theme-primary/80 h-28 w-8 rounded"></div>
<div class="h-36 w-8 rounded bg-theme-primary"></div>
<div class="h-30 bg-theme-primary/90 w-8 rounded"></div>
<div class="h-26 bg-theme-primary/70 w-8 rounded"></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg bg-theme-surface p-3">
<p class="text-xs text-theme-text-muted">Total Clicks</p>
<p class="text-2xl font-bold text-theme-text">24.5k</p>
</div>
<div class="rounded-lg bg-theme-surface p-3">
<p class="text-xs text-theme-text-muted">CTR</p>
<p class="text-2xl font-bold text-theme-text">3.2%</p>
</div>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-theme-text-muted">Top Referrer</p>
<div class="flex items-center justify-between text-sm">
<span class="text-theme-text">Instagram</span>
<span class="text-theme-primary">45%</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-theme-text">Twitter</span>
<span class="text-theme-primary">28%</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-theme-text">Direct</span>
<span class="text-theme-primary">27%</span>
</div>
</div>
</div>
</div>
{/if}
{#if selectedFeature === 'qr'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">QR-Code Optionen</h4>
<div class="flex justify-center">
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2"
>
<div class="h-full w-full rounded bg-black"></div>
</div>
<p class="text-xs text-theme-text">Schwarz</p>
</div>
<div class="text-center">
<div
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-black p-2"
>
<div class="h-full w-full rounded bg-white"></div>
</div>
<p class="text-xs text-theme-text">Weiß</p>
</div>
<div class="text-center">
<div
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2"
>
<div class="h-full w-full rounded bg-yellow-500"></div>
</div>
<p class="text-xs text-theme-text">Gold</p>
</div>
</div>
</div>
<div class="mt-4 space-y-2">
<p class="text-sm font-medium text-theme-text">Formate:</p>
<div class="flex gap-2">
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
>PNG</span
>
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
>SVG</span
>
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
>JPG</span
>
</div>
</div>
</div>
{/if}
{#if selectedFeature === 'team'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">Team Workspace</h4>
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
<div class="flex items-center gap-3">
<div class="bg-theme-primary/20 h-8 w-8 rounded-full"></div>
<div>
<p class="text-sm font-medium text-theme-text">Max Mustermann</p>
<p class="text-xs text-theme-text-muted">Admin</p>
</div>
</div>
<span
class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-400"
>Full Access</span
>
</div>
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-blue-500/20"></div>
<div>
<p class="text-sm font-medium text-theme-text">Anna Schmidt</p>
<p class="text-xs text-theme-text-muted">Editor</p>
</div>
</div>
<span
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>Edit Links</span
>
</div>
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-purple-500/20"></div>
<div>
<p class="text-sm font-medium text-theme-text">Tom Weber</p>
<p class="text-xs text-theme-text-muted">Viewer</p>
</div>
</div>
<span
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-900/20 dark:text-gray-400"
>View Only</span
>
</div>
</div>
</div>
{/if}
{#if selectedFeature === 'templates'}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
<h4 class="mb-4 text-lg font-semibold text-theme-text">Template Gallery</h4>
<div class="grid grid-cols-2 gap-3">
<div
class="rounded-lg border border-theme-border bg-gradient-to-br from-pink-500/10 to-purple-500/10 p-4"
>
<p class="mb-2 text-xs font-medium text-theme-text">Creator Pro</p>
<div class="h-20 rounded bg-white/50"></div>
</div>
<div
class="rounded-lg border border-theme-border bg-gradient-to-br from-blue-500/10 to-cyan-500/10 p-4"
>
<p class="mb-2 text-xs font-medium text-theme-text">Business</p>
<div class="h-20 rounded bg-white/50"></div>
</div>
<div
class="rounded-lg border border-theme-border bg-gradient-to-br from-green-500/10 to-emerald-500/10 p-4"
>
<p class="mb-2 text-xs font-medium text-theme-text">Restaurant</p>
<div class="h-20 rounded bg-white/50"></div>
</div>
<div
class="rounded-lg border border-theme-border bg-gradient-to-br from-indigo-500/10 to-purple-500/10 p-4"
>
<p class="mb-2 text-xs font-medium text-theme-text">Portfolio</p>
<div class="h-20 rounded bg-white/50"></div>
</div>
</div>
<button
class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-4 w-full rounded-lg py-2 text-sm font-medium text-theme-primary"
>
Alle Templates ansehen →
</button>
</div>
{/if}
</div>
</div>
</div>
</div>
</section>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View file

@ -1,228 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from '../../../routes/$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let isSubmitting = $state(false);
let inputUrl = $state('');
</script>
<section
class="from-theme-primary/5 relative overflow-hidden bg-gradient-to-br via-theme-background to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
>
<!-- Background decoration -->
<div class="absolute inset-0 -z-10">
<div
class="bg-theme-primary/10 absolute left-1/2 top-0 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl"
></div>
<div
class="absolute bottom-0 right-0 h-96 w-96 translate-x-1/3 translate-y-1/3 rounded-full bg-purple-600/10 blur-3xl"
></div>
</div>
<div class="mx-auto max-w-7xl">
<div class="text-center">
<!-- Trust badges -->
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-theme-text-muted">
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
DSGVO-konform
</span>
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
Blitzschnell
</span>
<span class="flex items-center gap-1">
<svg
class="h-4 w-4 text-purple-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
100% Sicher
</span>
</div>
<!-- Main headline -->
<h1 class="mb-4 text-4xl font-bold tracking-tight text-theme-text sm:text-5xl lg:text-6xl">
More than links.
<span
class="bg-gradient-to-r from-theme-primary to-purple-600 bg-clip-text text-transparent"
>
Your digital identity.
</span>
</h1>
<p class="mx-auto mb-8 max-w-2xl text-lg text-theme-text-muted sm:text-xl">
Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
beeindruckende Profilkarten und manage alles im Team.
</p>
<!-- CTA Buttons -->
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
<a
href="#url-form"
class="rounded-lg bg-theme-primary px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-theme-primary-hover hover:shadow-xl"
>
Kostenlos starten →
</a>
<a
href="#features"
class="rounded-lg border-2 border-theme-border bg-theme-surface px-8 py-3 font-semibold text-theme-text transition hover:border-theme-primary hover:shadow-lg"
>
Live Demo ansehen
</a>
</div>
<!-- Quick demo form -->
<div class="mx-auto max-w-2xl">
<form
method="POST"
action="/?/create"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="bg-theme-surface/80 flex flex-col gap-3 rounded-xl border border-theme-border p-4 backdrop-blur sm:flex-row sm:p-2"
>
<input
type="url"
name="url"
required
bind:value={inputUrl}
placeholder="Deine lange URL hier einfügen..."
class="flex-1 rounded-lg border-0 bg-transparent px-4 py-3 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-primary sm:py-2"
/>
<button
type="submit"
disabled={isSubmitting || !inputUrl}
class="rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50 sm:py-2"
>
{#if isSubmitting}
<svg class="mx-auto 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>
{:else}
Kürzen →
{/if}
</button>
</form>
<p class="mt-2 text-sm text-theme-text-muted">
Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
</p>
</div>
</div>
<!-- Visual preview -->
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
<!-- Link shortening preview -->
<div
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
>
<div class="bg-theme-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-lg">
<svg
class="h-6 w-6 text-theme-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Smart Links</h3>
<p class="text-sm text-theme-text-muted">
Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
</p>
<div class="mt-4 text-xs text-theme-primary group-hover:underline">Mehr erfahren →</div>
</div>
<!-- Profile cards preview -->
<div
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-600/10">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Profile Cards</h3>
<p class="text-sm text-theme-text-muted">
Beeindruckende Profilseiten mit Drag & Drop Builder
</p>
<div class="mt-4 text-xs text-purple-600 group-hover:underline">Templates ansehen →</div>
</div>
<!-- Team collaboration preview -->
<div
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-600/10">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Team Workspace</h3>
<p class="text-sm text-theme-text-muted">
Gemeinsam Links verwalten mit granularen Berechtigungen
</p>
<div class="mt-4 text-xs text-green-600 group-hover:underline">Für Teams →</div>
</div>
</div>
</div>
</section>

View file

@ -1,270 +0,0 @@
<script lang="ts">
let billingCycle = $state<'monthly' | 'yearly'>('monthly');
let hoveredPlan = $state<string | null>(null);
const plans = [
{
id: 'free',
name: 'Free',
price: { monthly: 0, yearly: 0 },
description: 'Perfekt zum Ausprobieren',
features: [
'10 Links pro Monat',
'Basis Analytics',
'QR-Code Generator',
'Link Anpassung',
'Standard Support',
],
limitations: ['Limitierte Links', 'Keine API', 'Standard Support'],
cta: 'Kostenlos starten',
highlighted: false,
color: 'gray',
},
{
id: 'pro-monthly',
name: 'Pro Monatlich',
price: { monthly: 4.99, yearly: 4.99 },
description: 'Für Freelancer & Creators',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'Keine Werbung',
'API Zugang',
],
limitations: [],
cta: 'Pro wählen',
highlighted: false,
color: 'theme-primary',
},
{
id: 'pro-yearly',
name: 'Pro Jährlich',
price: { monthly: 3.33, yearly: 39.99 },
description: 'Beste Wahl für Power User',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'Keine Werbung',
'API Zugang',
],
limitations: [],
cta: 'Jährlich sparen',
highlighted: true,
color: 'purple',
badge: 'Spare 20€/Jahr',
},
{
id: 'lifetime',
name: 'Pro Lifetime',
price: { monthly: 129.99, yearly: 129.99 },
description: 'Einmalig zahlen, für immer nutzen',
features: [
'Alle Pro Features',
'Lebenslanger Zugang',
'Unbegrenzte Links',
'Alle zukünftigen Features',
'Priority Support',
'Early Access zu neuen Features',
'API Zugang',
],
limitations: [],
cta: 'Lifetime sichern',
highlighted: false,
color: 'indigo',
badge: 'Einmalig',
},
];
function formatPrice(price: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: price % 1 === 0 ? 0 : 2,
}).format(price);
}
function getYearlySavings(monthly: number, yearly: number): number {
return Math.round(((monthly * 12 - yearly) / (monthly * 12)) * 100);
}
</script>
<section id="pricing" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">
Transparente Preise, keine versteckten Kosten
</h2>
<p class="mx-auto mb-8 max-w-2xl text-lg text-theme-text-muted">
Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
</p>
<!-- Billing Toggle -->
<div class="mb-12 inline-flex items-center rounded-lg bg-theme-surface p-1">
<button
onclick={() => (billingCycle = 'monthly')}
class="rounded-md px-6 py-2 text-sm font-medium transition {billingCycle === 'monthly'
? 'bg-theme-primary text-white'
: 'hover:text-theme-text/80 text-theme-text'}"
>
Monatlich
</button>
<button
onclick={() => (billingCycle = 'yearly')}
class="relative rounded-md px-6 py-2 text-sm font-medium transition {billingCycle ===
'yearly'
? 'bg-theme-primary text-white'
: 'hover:text-theme-text/80 text-theme-text'}"
>
Jährlich
<span
class="absolute -right-12 -top-2 rounded bg-green-500 px-2 py-0.5 text-xs text-white"
>
-17%
</span>
</button>
</div>
</div>
<!-- Pricing Cards -->
<div class="grid gap-8 lg:grid-cols-4">
{#each plans as plan}
<div
class="relative rounded-xl border-2 transition-all duration-300 {plan.highlighted
? 'scale-105 border-theme-primary shadow-2xl'
: 'hover:border-theme-primary/50 border-theme-border hover:shadow-xl'} bg-theme-surface"
onmouseenter={() => (hoveredPlan = plan.id)}
onmouseleave={() => (hoveredPlan = null)}
>
{#if plan.badge}
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
<span
class="rounded-full bg-theme-primary px-4 py-1 text-xs font-semibold text-white"
>
{plan.badge}
</span>
</div>
{/if}
<div class="p-6">
<h3 class="mb-2 text-xl font-bold text-theme-text">{plan.name}</h3>
<p class="mb-4 text-sm text-theme-text-muted">{plan.description}</p>
<div class="mb-6">
<div class="flex items-baseline">
<span class="text-4xl font-bold text-theme-text">
{formatPrice(
billingCycle === 'monthly' ? plan.price.monthly : plan.price.yearly / 12
)}
</span>
<span class="ml-2 text-theme-text-muted">/Monat</span>
</div>
{#if billingCycle === 'yearly' && plan.price.yearly > 0}
<p class="mt-1 text-sm text-green-600 dark:text-green-400">
Spare {getYearlySavings(plan.price.monthly, plan.price.yearly)}% jährlich
</p>
{/if}
</div>
<button
class="mb-6 w-full rounded-lg py-3 font-semibold transition {plan.highlighted
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
: 'hover:bg-theme-primary/5 border-2 border-theme-border bg-theme-surface text-theme-text hover:border-theme-primary'}"
>
{plan.cta}
</button>
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wide text-theme-text-muted">
Inklusive:
</p>
{#each plan.features as feature}
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-sm text-theme-text">{feature}</span>
</div>
{/each}
{#if plan.limitations.length > 0}
<div class="mt-4 border-t border-theme-border pt-4">
{#each plan.limitations as limitation}
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span class="text-sm text-theme-text-muted">{limitation}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- FAQ or Additional Info -->
<div class="mt-16 rounded-xl border border-theme-border bg-theme-surface p-8">
<div class="grid gap-8 lg:grid-cols-3">
<div>
<h4 class="mb-2 font-semibold text-theme-text">💳 Keine Kreditkarte erforderlich</h4>
<p class="text-sm text-theme-text-muted">
Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-theme-text">🔄 Jederzeit kündbar</h4>
<p class="text-sm text-theme-text-muted">
Keine Vertragsbindung. Kündige monatlich ohne Probleme.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-theme-text">🚀 Sofort startklar</h4>
<p class="text-sm text-theme-text-muted">
Nach der Anmeldung kannst du sofort alle Features nutzen.
</p>
</div>
</div>
</div>
<!-- Enterprise CTA -->
<div class="mt-12 text-center">
<p class="mb-4 text-theme-text">
Benötigst du eine maßgeschneiderte Lösung für dein Unternehmen?
</p>
<a href="/contact" class="inline-flex items-center gap-2 text-theme-primary hover:underline">
Kontaktiere uns für Enterprise-Lösungen
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</section>

View file

@ -1,487 +0,0 @@
<script lang="ts">
let activeTab = $state<'creators' | 'teams' | 'business' | 'events'>('creators');
</script>
<section id="target-audience" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">
Für jeden die richtige Lösung
</h2>
<p class="mx-auto mb-12 max-w-2xl text-lg text-theme-text-muted">
Egal ob Creator, Team oder Unternehmen - wir haben die passenden Features für dich
</p>
</div>
<!-- Tab Navigation -->
<div class="mb-8 flex flex-wrap justify-center gap-2">
<button
onclick={() => (activeTab = 'creators')}
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'creators'
? 'bg-theme-primary text-white'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
📱 Creators & Influencer
</button>
<button
onclick={() => (activeTab = 'teams')}
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'teams'
? 'bg-theme-primary text-white'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
💼 Teams & Agenturen
</button>
<button
onclick={() => (activeTab = 'business')}
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'business'
? 'bg-theme-primary text-white'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
🏢 KMU & Startups
</button>
<button
onclick={() => (activeTab = 'events')}
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'events'
? 'bg-theme-primary text-white'
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
>
🎯 Events & Gastro
</button>
</div>
<!-- Tab Content -->
<div class="rounded-xl border border-theme-border bg-theme-surface p-8">
{#if activeTab === 'creators'}
<div class="grid gap-8 lg:grid-cols-2">
<div>
<h3 class="mb-4 text-2xl font-bold text-theme-text">Ein Link für alle deine Kanäle</h3>
<p class="mb-6 text-theme-text-muted">
Perfekt für Instagram, TikTok und YouTube. Erstelle beeindruckende Link-in-Bio Seiten,
tracke deine Klicks und verstehe deine Audience besser.
</p>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Anpassbare Profilseiten mit deinem Branding</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">QR-Codes für Offline-zu-Online Verbindung</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Detaillierte Analytics zu Klicks und Herkunft</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Social Media Icons und Integrationen</span>
</li>
</ul>
<div class="mt-6">
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
>
Jetzt starten
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-center">
<div class="relative">
<div
class="absolute -inset-4 rounded-xl bg-gradient-to-r from-pink-500/20 to-purple-500/20 blur-xl"
></div>
<img
src="/images/creator-mockup.png"
alt="Creator Profile Preview"
class="relative rounded-xl shadow-2xl"
onerror={() => {}}
/>
<!-- Fallback illustration if image doesn't exist -->
<div
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-pink-500 to-purple-600 text-white"
>
<div class="text-center">
<div class="mb-4 text-6xl">📱</div>
<p class="text-xl font-bold">Creator Profile</p>
<p class="mt-2 text-sm opacity-90">Coming Soon</p>
</div>
</div>
</div>
</div>
</div>
{/if}
{#if activeTab === 'teams'}
<div class="grid gap-8 lg:grid-cols-2">
<div>
<h3 class="mb-4 text-2xl font-bold text-theme-text">Gemeinsam mehr erreichen</h3>
<p class="mb-6 text-theme-text-muted">
Perfekte Kollaboration für Marketing-Teams und Agenturen. Verwaltet Links gemeinsam,
teilt Analytics und arbeitet effizienter zusammen.
</p>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Team-Workspaces mit granularen Berechtigungen</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Multi-Client Management für Agenturen</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Gemeinsame Analytics und Reporting</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Bulk-Operationen und CSV-Import</span>
</li>
</ul>
<div class="mt-6">
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
>
Team Plan wählen
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-center">
<div
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white"
>
<div class="text-center">
<div class="mb-4 text-6xl">💼</div>
<p class="text-xl font-bold">Team Dashboard</p>
<p class="mt-2 text-sm opacity-90">10 Mitglieder • Unbegrenzte Links</p>
</div>
</div>
</div>
</div>
{/if}
{#if activeTab === 'business'}
<div class="grid gap-8 lg:grid-cols-2">
<div>
<h3 class="mb-4 text-2xl font-bold text-theme-text">Professionelles Link-Management</h3>
<p class="mb-6 text-theme-text-muted">
Die kostengünstige Alternative zu Enterprise-Lösungen. Perfekt für KMUs und Startups,
die ihre digitale Präsenz professionell verwalten wollen.
</p>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Custom Domains für deine Marke (coming soon)</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">API-Zugang für Automatisierung</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Erweiterte Analytics und Exporte</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">DSGVO-konform und hosted in Germany</span>
</li>
</ul>
<div class="mt-6">
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
>
Business Plan starten
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-center">
<div
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white"
>
<div class="text-center">
<div class="mb-4 text-6xl">🏢</div>
<p class="text-xl font-bold">Enterprise Ready</p>
<p class="mt-2 text-sm opacity-90">API • Custom Domain • SSO</p>
</div>
</div>
</div>
</div>
{/if}
{#if activeTab === 'events'}
<div class="grid gap-8 lg:grid-cols-2">
<div>
<h3 class="mb-4 text-2xl font-bold text-theme-text">QR-Codes die funktionieren</h3>
<p class="mb-6 text-theme-text-muted">
Ideal für Restaurants, Events und Veranstaltungen. Erstelle QR-Codes für Speisekarten,
Event-Infos oder zeitlich begrenzte Aktionen.
</p>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">QR-Codes in verschiedenen Farben und Formaten</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Zeitlich begrenzte Links für Aktionen</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Passwortgeschützte Inhalte für VIPs</span>
</li>
<li class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-theme-text">Echtzeit-Updates ohne QR-Code Neudruck</span>
</li>
</ul>
<div class="mt-6">
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
>
QR-Codes erstellen
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-center">
<div
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white"
>
<div class="text-center">
<div class="mb-4 text-6xl">🎯</div>
<p class="text-xl font-bold">Event QR-Codes</p>
<p class="mt-2 text-sm opacity-90">Dynamisch • Trackbar • Aktualisierbar</p>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</section>

View file

@ -1,221 +0,0 @@
<script lang="ts">
const testimonials = [
{
id: 1,
name: 'Sarah M.',
role: 'Content Creator',
avatar: '👩‍🎨',
content:
'Uload hat meine Social Media Präsenz komplett transformiert. Die Profilkarten sind genial und die Analytics helfen mir, meine Audience besser zu verstehen.',
rating: 5,
platform: 'Instagram',
},
{
id: 2,
name: 'Michael K.',
role: 'Marketing Manager',
avatar: '👨‍💼',
content:
'Endlich ein Tool, das Link-Management und Team-Kollaboration perfekt vereint. Die API-Integration war super einfach.',
rating: 5,
platform: 'LinkedIn',
},
{
id: 3,
name: 'Lisa T.',
role: 'Restaurant-Inhaberin',
avatar: '👩‍🍳',
content:
'Die QR-Codes für unsere Speisekarte sind ein Game-Changer. Änderungen sind sofort live, ohne neue Codes drucken zu müssen.',
rating: 5,
platform: 'Google',
},
{
id: 4,
name: 'Tom S.',
role: 'Freelance Designer',
avatar: '🎨',
content:
'Der Card-Builder ist intuitiv und die Templates sparen mir Stunden. Meine Kunden lieben die professionellen Profilseiten.',
rating: 5,
platform: 'Twitter',
},
{
id: 5,
name: 'Anna B.',
role: 'Event Managerin',
avatar: '🎭',
content:
'Perfekt für Events! Zeitlich begrenzte Links und Passwortschutz für VIP-Bereiche. Genau was wir gebraucht haben.',
rating: 5,
platform: 'Trustpilot',
},
{
id: 6,
name: 'David R.',
role: 'Startup Founder',
avatar: '🚀',
content:
'Preis-Leistung ist unschlagbar. Wir haben von Bitly gewechselt und sparen 70% bei mehr Features.',
rating: 5,
platform: 'ProductHunt',
},
];
const stats = [
{ label: 'Beta Launch', value: '2024', icon: '🚀' },
{ label: 'Made in', value: 'Germany', icon: '🇩🇪' },
{ label: 'Support Response', value: '<2h', icon: '⚡' },
{ label: 'Uptime', value: '99.9%', icon: '✅' },
];
</script>
<section id="testimonials" class="bg-theme-surface/50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">Was Beta-Tester sagen</h2>
<p class="mx-auto mb-12 max-w-2xl text-lg text-theme-text-muted">
Erste Stimmen aus unserem exklusiven Beta-Programm
</p>
</div>
<!-- Stats Grid -->
<div class="mb-16 grid grid-cols-2 gap-4 lg:grid-cols-4">
{#each stats as stat}
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 text-center">
<div class="mb-2 text-3xl">{stat.icon}</div>
<div class="text-2xl font-bold text-theme-text">{stat.value}</div>
<div class="text-sm text-theme-text-muted">{stat.label}</div>
</div>
{/each}
</div>
<!-- Testimonials Grid -->
<div class="grid gap-6 lg:grid-cols-3">
{#each testimonials as testimonial}
<div
class="group rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
>
<div class="mb-4 flex items-start justify-between">
<div class="flex items-center gap-3">
<div
class="bg-theme-primary/10 flex h-12 w-12 items-center justify-center rounded-full text-2xl"
>
{testimonial.avatar}
</div>
<div>
<p class="font-semibold text-theme-text">{testimonial.name}</p>
<p class="text-sm text-theme-text-muted">{testimonial.role}</p>
</div>
</div>
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary">
{testimonial.platform}
</span>
</div>
<div class="mb-4 flex gap-1">
{#each Array(testimonial.rating) as _}
<svg class="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<p class="text-theme-text-muted">
"{testimonial.content}"
</p>
</div>
{/each}
</div>
<!-- Use Cases -->
<div
class="from-theme-primary/5 mt-16 rounded-xl border border-theme-border bg-gradient-to-r to-purple-600/5 p-8"
>
<h3 class="mb-8 text-center text-2xl font-bold text-theme-text">
Perfekt für diese Use Cases
</h3>
<div class="grid gap-8 lg:grid-cols-2">
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-pink-500/10">
<span class="text-2xl">📱</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Social Media Bio Links</h4>
<p class="text-sm text-theme-text-muted">Instagram & TikTok</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem Drag &
Drop Builder und tracke jeden Klick in Echtzeit.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
<span class="text-2xl">🍽️</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Digitale Speisekarten</h4>
<p class="text-sm text-theme-text-muted">Restaurants & Cafés</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte ohne neue
Codes drucken zu müssen.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
<span class="text-2xl">📊</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Marketing Kampagnen</h4>
<p class="text-sm text-theme-text-muted">Performance Tracking</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau, welche
Kanäle am besten performen.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/10">
<span class="text-2xl">🎯</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Event Management</h4>
<p class="text-sm text-theme-text-muted">Tickets & Info-Links</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für exklusive
Inhalte und VIP-Bereiche.
</p>
</div>
</div>
</div>
<!-- CTA -->
<div class="mt-12 text-center">
<p class="mb-6 text-lg text-theme-text">Sei einer der Ersten - starte jetzt kostenlos!</p>
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-theme-primary-hover hover:shadow-xl"
>
Beta-Zugang sichern
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</section>

View file

@ -1,270 +0,0 @@
<script lang="ts">
const trustBadges = [
{
icon: '🔒',
title: 'SSL-verschlüsselt',
description: 'Alle Daten werden sicher übertragen',
},
{
icon: '🇩🇪',
title: 'Hosted in Germany',
description: 'Server in deutschen Rechenzentren',
},
{
icon: '🛡️',
title: 'DSGVO-konform',
description: 'Vollständig GDPR-compliant',
},
{
icon: '⚡',
title: '99.9% Uptime',
description: 'Garantierte Verfügbarkeit',
},
];
const securityFeatures = [
{
title: 'Datenschutz First',
items: [
'Keine Weitergabe an Dritte',
'Verschlüsselte Datenbanken',
'Regelmäßige Sicherheits-Audits',
'HTTPS für alle Links',
],
},
{
title: 'Zuverlässigkeit',
items: [
'Redundante Server-Infrastruktur',
'Automatische Backups',
'DDoS-Schutz',
'24/7 Monitoring',
],
},
{
title: 'Compliance',
items: [
'DSGVO/GDPR konform',
'Cookie-Richtlinien',
'Recht auf Löschung',
'Datenportabilität',
],
},
];
</script>
<section id="trust" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div class="mx-auto max-w-7xl">
<!-- Trust Badges -->
<div class="mb-16 grid grid-cols-2 gap-4 lg:grid-cols-4">
{#each trustBadges as badge}
<div
class="group rounded-xl border border-theme-border bg-theme-surface p-6 text-center transition hover:shadow-lg"
>
<div class="mb-3 text-4xl">{badge.icon}</div>
<h3 class="mb-1 font-semibold text-theme-text">{badge.title}</h3>
<p class="text-sm text-theme-text-muted">{badge.description}</p>
</div>
{/each}
</div>
<!-- Security Features -->
<div
class="to-theme-primary/5 rounded-xl border border-theme-border bg-gradient-to-br from-theme-surface p-8"
>
<div class="mb-8 text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text">
Sicherheit und Datenschutz an erster Stelle
</h2>
<p class="mx-auto max-w-2xl text-lg text-theme-text-muted">
Wir nehmen den Schutz deiner Daten ernst. Deshalb setzen wir auf höchste
Sicherheitsstandards.
</p>
</div>
<div class="grid gap-8 lg:grid-cols-3">
{#each securityFeatures as feature}
<div>
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<svg
class="h-5 w-5 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
{feature.title}
</h3>
<ul class="space-y-2">
{#each feature.items as item}
<li class="flex items-start gap-2">
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-theme-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span class="text-sm text-theme-text-muted">{item}</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
<!-- Infrastructure Info -->
<div class="mt-12 grid gap-6 lg:grid-cols-2">
<div class="rounded-xl border border-theme-border bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
<svg
class="h-6 w-6 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</div>
<div>
<h3 class="font-semibold text-theme-text">Premium Infrastructure</h3>
<p class="text-sm text-theme-text-muted">Hetzner Cloud Servers</p>
</div>
</div>
<p class="mb-4 text-sm text-theme-text-muted">
Unsere Server laufen auf modernster Hetzner-Infrastruktur in deutschen Rechenzentren. Mit
automatischer Skalierung und Load Balancing gewährleisten wir beste Performance.
</p>
<div class="flex flex-wrap gap-2">
<span
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
Frankfurt
</span>
<span
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
Nürnberg
</span>
<span
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
>
Falkenstein
</span>
</div>
</div>
<div class="rounded-xl border border-theme-border bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
<svg
class="h-6 w-6 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
<h3 class="font-semibold text-theme-text">Status & Monitoring</h3>
<p class="text-sm text-theme-text-muted">Transparente Verfügbarkeit</p>
</div>
</div>
<p class="mb-4 text-sm text-theme-text-muted">
Wir überwachen unsere Systeme 24/7 und informieren proaktiv über Wartungen. Unser
öffentliches Status-Dashboard zeigt die aktuelle Verfügbarkeit aller Services.
</p>
<a
href="https://status.ulo.ad"
class="inline-flex items-center gap-2 text-sm text-theme-primary hover:underline"
>
Status-Seite besuchen
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<!-- Certifications -->
<div class="bg-theme-surface/50 mt-12 rounded-xl border border-theme-border p-8">
<h3 class="mb-6 text-center text-xl font-semibold text-theme-text">
Zertifizierungen & Standards
</h3>
<div class="flex flex-wrap justify-center gap-8">
<div class="flex flex-col items-center">
<div class="mb-2 text-3xl">🔐</div>
<span class="text-sm font-medium text-theme-text">SSL/TLS</span>
<span class="text-xs text-theme-text-muted">Let's Encrypt</span>
</div>
<div class="flex flex-col items-center">
<div class="mb-2 text-3xl">📋</div>
<span class="text-sm font-medium text-theme-text">DSGVO</span>
<span class="text-xs text-theme-text-muted">EU Compliant</span>
</div>
<div class="flex flex-col items-center">
<div class="mb-2 text-3xl">🛡️</div>
<span class="text-sm font-medium text-theme-text">ISO 27001</span>
<span class="text-xs text-theme-text-muted">In Progress</span>
</div>
<div class="flex flex-col items-center">
<div class="mb-2 text-3xl"></div>
<span class="text-sm font-medium text-theme-text">PCI DSS</span>
<span class="text-xs text-theme-text-muted">Level 1</span>
</div>
</div>
</div>
<!-- Support Info -->
<div class="mt-12 text-center">
<h3 class="mb-4 text-xl font-semibold text-theme-text">Fragen zur Sicherheit?</h3>
<p class="mb-6 text-theme-text-muted">
Unser Security-Team beantwortet gerne alle deine Fragen zum Datenschutz und zur Sicherheit.
</p>
<a
href="mailto:security@ulo.ad"
class="bg-theme-primary/10 inline-flex items-center gap-2 rounded-lg border-2 border-theme-primary px-6 py-3 font-medium text-theme-primary transition hover:bg-theme-primary hover:text-white"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
security@ulo.ad
</a>
</div>
</div>
</section>

View file

@ -1,533 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import {
generateQRCodeURL,
downloadQRCode,
type QRCodeColor,
type QRCodeFormat,
type QRCodeRotation,
} from '$lib/qrcode';
import TagBadge from '$lib/components/TagBadge.svelte';
import Button from '$lib/components/Button.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import { trackEvent, trackLinkClick, EVENTS } from '$lib/analytics';
import type { Tag } from '$lib/pocketbase';
import { toastMessages } from '$lib/services/toast';
interface Link {
id: string;
short_code: string;
original_url: string;
title?: string;
description?: string;
clicks: number;
is_active: boolean;
expires_at?: string;
max_clicks?: number;
password?: string;
created: string;
// use_username removed - now handled by short_code format
folder?: string;
expand?: {
folder?: {
id: string;
name: string;
display_name: string;
icon: string;
color: string;
};
'link_tags(link_id)'?: Array<{
expand?: {
tag_id?: Tag;
};
}>;
};
}
interface Props {
link: Link;
username?: string;
onCopy?: (text: string, id: string, shortCode?: string) => void;
copiedStates?: Record<string, boolean>;
}
let { link, username, onCopy = () => {}, copiedStates = {} }: Props = $props();
let showQRCode = $state(false);
let qrColor: QRCodeColor = $state('black');
let qrFormat: QRCodeFormat = $state('png');
let qrRotation: QRCodeRotation = $state(0);
const isExpired = link.expires_at ? new Date(link.expires_at) < new Date() : false;
const isNearLimit = link.max_clicks ? link.clicks >= link.max_clicks * 0.8 : false;
function formatUrl(shortCode: string) {
if (typeof window === 'undefined') return shortCode;
// Short codes with slashes are already username-prefixed custom codes
// Random codes don't have slashes
return `${window.location.origin}/${shortCode}`;
}
function copyToClipboard(text: string, id: string, shortCode?: string) {
navigator.clipboard.writeText(text);
onCopy(text, id, shortCode);
toastMessages.linkCopied();
}
function toggleQRCode() {
if (showQRCode) {
showQRCode = false;
} else {
showQRCode = true;
qrColor = 'black';
qrFormat = 'png';
qrRotation = 0;
trackEvent(EVENTS.LINK_QR_GENERATED, { short_code: link.short_code });
}
}
function downloadQR() {
const url = formatUrl(link.short_code);
downloadQRCode(url, `qrcode-${link.short_code}`, 400, qrColor, qrFormat, qrRotation);
trackEvent(EVENTS.LINK_QR_DOWNLOADED, {
short_code: link.short_code,
format: qrFormat,
color: qrColor,
rotation: qrRotation,
});
}
function rotateQR(degrees: QRCodeRotation) {
qrRotation = degrees;
}
</script>
<div
class="hover:from-theme-surface/50 group relative p-6 transition-all duration-200 hover:bg-gradient-to-br hover:to-transparent"
>
<div class="flex flex-col gap-4">
<!-- Header with Title and Actions -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold text-theme-text">
{link.title || link.short_code}
</h3>
{#if link.description}
<p class="mt-1 line-clamp-2 text-sm text-theme-text-muted">{link.description}</p>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-1 opacity-60 transition-opacity group-hover:opacity-100">
<Dropdown
items={[
{
label: 'Copy Link',
icon: '<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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>',
color: '#6366f1',
action: () => {
copyToClipboard(formatUrl(link.short_code), link.id, link.short_code);
},
},
{
label: 'QR Code',
icon: '<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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h8m-4 0v8m-4-8h.01M8 8h8M4 20h2m0-4v4m12-4v4" /></svg>',
color: '#10b981',
action: toggleQRCode,
},
{
label: 'Analytics',
href: `/my/analytics/${link.short_code}`,
icon: '<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="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>',
color: '#2563eb',
},
{
label: 'Edit',
icon: '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: () => {
window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
},
},
{
label: link.is_active ? 'Deactivate' : 'Activate',
type: 'form',
formAction: '?/toggle',
formData: { id: link.id, is_active: String(link.is_active) },
icon: link.is_active
? '<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
: '<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
color: link.is_active ? '#ea580c' : '#16a34a',
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: link.id },
enhanceOptions: () => {
return async ({ update, result, cancel }) => {
if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
cancel();
return;
}
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code,
});
toastMessages.linkDeleted();
}
};
},
},
]}
buttonClass="!p-2"
/>
</div>
</div>
<!-- URL Display Box -->
<div
class="border-theme-border/50 rounded-lg border bg-gradient-to-r from-blue-50/50 to-purple-50/50 p-3 dark:from-blue-950/20 dark:to-purple-950/20"
>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="mb-1 text-[10px] font-medium uppercase tracking-wider text-theme-text-muted">
Short URL
</p>
{#if link.short_code.includes('/')}
<a
href="/{link.short_code}"
target="_blank"
class="block truncate font-mono text-sm font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: link.short_code.split('/')[0],
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
>
ulo.ad/{link.short_code}
</a>
{:else}
<a
href="/{link.short_code}"
target="_blank"
class="block truncate font-mono text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
>
ulo.ad/{link.short_code}
</a>
{/if}
</div>
<button
onclick={() => {
const url = formatUrl(link.short_code);
copyToClipboard(url, `${link.id}-url`, link.short_code);
}}
class="rounded-md p-1.5 transition-colors hover:bg-white/50 dark:hover:bg-black/20"
title="Copy URL"
>
<svg
class="h-4 w-4 {copiedStates[`${link.id}-url`]
? 'text-green-600'
: 'text-theme-text-muted'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if copiedStates[`${link.id}-url`]}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
{/if}
</svg>
</button>
</div>
</div>
<!-- Original URL -->
<div class="truncate text-xs text-theme-text-muted">
<span class="font-medium">Destination:</span>
<a
href={link.original_url}
target="_blank"
rel="noopener noreferrer"
class="ml-1 hover:text-blue-600"
>
{link.original_url}
</a>
</div>
<!-- Tags and Badges -->
<div class="flex flex-wrap items-center gap-2">
{#if link.expand?.folder}
<span
class="inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium shadow-sm"
style="background-color: {link.expand.folder.color}15; color: {link.expand.folder
.color}; border: 1px solid {link.expand.folder.color}30"
>
<span class="text-sm">{link.expand.folder.icon}</span>
{link.expand.folder.display_name}
</span>
{/if}
{#if link.expand?.['link_tags(link_id)'] && link.expand['link_tags(link_id)'].length > 0}
{#each link.expand['link_tags(link_id)'] as linkTag}
{#if linkTag.expand?.tag_id}
<TagBadge tag={linkTag.expand.tag_id} size="sm" />
{/if}
{/each}
{/if}
{#if !link.is_active}
<span
class="inline-flex items-center gap-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/10 dark:bg-red-900/20 dark:text-red-400 dark:ring-red-500/20"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 8 8"
><circle cx="4" cy="4" r="3" /></svg
>
Inactive
</span>
{/if}
{#if isExpired}
<span
class="inline-flex items-center gap-1 rounded-md bg-orange-50 px-2 py-1 text-xs font-medium text-orange-700 ring-1 ring-inset ring-orange-600/10 dark:bg-orange-900/20 dark:text-orange-400 dark:ring-orange-500/20"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Expired
</span>
{/if}
{#if link.password}
<span
class="inline-flex items-center gap-1 rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/10 dark:bg-yellow-900/20 dark:text-yellow-400 dark:ring-yellow-500/20"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
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"
/>
</svg>
Protected
</span>
{/if}
</div>
<!-- Stats Bar -->
<div class="border-theme-border/30 flex flex-wrap items-center gap-4 border-t pt-3">
<div class="flex items-center gap-1.5">
<svg
class="h-3.5 w-3.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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span class="text-xs font-medium {isNearLimit ? 'text-orange-600' : 'text-theme-text'}">
{link.clicks || 0}
</span>
<span class="text-xs text-theme-text-muted">clicks</span>
</div>
{#if link.max_clicks}
<div class="flex items-center gap-1.5">
<svg
class="h-3.5 w-3.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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs font-medium {isNearLimit ? 'text-orange-600' : 'text-purple-600'}">
{link.max_clicks}
</span>
<span class="text-xs text-theme-text-muted">max</span>
</div>
{/if}
{#if link.expires_at}
<div class="flex items-center gap-1.5">
<svg
class="h-3.5 w-3.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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs font-medium {isExpired ? 'text-red-600' : 'text-orange-600'}">
{new Date(link.expires_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
})}
</span>
</div>
{/if}
<div class="ml-auto flex items-center gap-1.5">
<svg
class="h-3.5 w-3.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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="text-xs text-theme-text-muted">
{new Date(link.created).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{#if showQRCode}
<div class="border-theme-border/50 mt-4 rounded-lg border bg-theme-surface p-4">
<div class="flex flex-col items-center gap-4">
<div
class="relative rounded border-2 border-theme-border p-4"
style="background: {qrColor === 'white' ? '#000' : qrColor === 'gold' ? '#000' : '#fff'}"
>
<img
src={generateQRCodeURL(formatUrl(link.short_code), 200, qrColor, 'png')}
alt="QR Code for {link.short_code}"
style="transform: rotate({qrRotation}deg); transition: transform 0.3s ease;"
/>
</div>
<div class="flex flex-wrap justify-center gap-4">
<div>
<label class="mb-1 block text-xs text-theme-text">Color</label>
<div class="flex gap-2">
<button
onclick={() => (qrColor = 'black')}
class="h-8 w-8 rounded border-2 bg-black {qrColor === 'black'
? 'border-blue-500'
: 'border-theme-border'}"
title="Black"
></button>
<button
onclick={() => (qrColor = 'white')}
class="h-8 w-8 rounded border-2 bg-white {qrColor === 'white'
? 'border-blue-500'
: 'border-theme-border'}"
title="White"
></button>
<button
onclick={() => (qrColor = 'gold')}
class="h-8 w-8 rounded border-2 {qrColor === 'gold'
? 'border-blue-500'
: 'border-theme-border'}"
style="background: #f8d62b"
title="Gold"
></button>
</div>
</div>
<div>
<label class="mb-1 block text-xs text-theme-text">Format</label>
<select
bind:value={qrFormat}
class="rounded border border-theme-border px-2 py-1 text-sm"
>
<option value="png">PNG</option>
<option value="svg">SVG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs text-theme-text">Rotation</label>
<div class="flex gap-1">
{#each [0, 45, 90, 135, 180, 225, 270, 315] as angle}
<button
onclick={() => rotateQR(angle)}
class="h-8 w-8 rounded border-2 text-xs font-bold {qrRotation === angle
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-theme-border'}"
title="{angle}°"
>
{angle}°
</button>
{/each}
</div>
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Button onclick={downloadQR} variant="primary" size="lg">
Download{qrRotation !== 0 ? ` (${qrRotation}°)` : ''} as {qrFormat.toUpperCase()}
</Button>
<Button
onclick={() => copyToClipboard(formatUrl(link.short_code), 'qr-copy', link.short_code)}
variant="secondary"
size="lg"
>
Copy URL
</Button>
</div>
</div>
</div>
{/if}
</div>

View file

@ -1,234 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TagBadge from '$lib/components/TagBadge.svelte';
import { trackEvent, trackLinkClick, EVENTS } from '$lib/analytics';
import type { Tag } from '$lib/pocketbase';
import { toastMessages } from '$lib/services/toast';
interface Link {
id: string;
short_code: string;
original_url: string;
title?: string;
description?: string;
clicks: number;
is_active: boolean;
expires_at?: string;
max_clicks?: number;
password?: string;
created: string;
// use_username removed - now handled by short_code format
folder?: string;
expand?: {
folder?: {
id: string;
name: string;
display_name: string;
icon: string;
color: string;
};
'link_tags(link_id)'?: Array<{
expand?: {
tag_id?: Tag;
};
}>;
};
}
interface Props {
link: Link;
username?: string;
onCopy?: (text: string, id: string, shortCode?: string) => void;
copiedStates?: Record<string, boolean>;
}
let { link, username, onCopy = () => {}, copiedStates = {} }: Props = $props();
let dropdownOpen = $state(false);
function formatUrl(shortCode: string) {
if (typeof window === 'undefined') return shortCode;
// Short codes with slashes are already username-prefixed custom codes
return `${window.location.origin}/${shortCode}`;
}
function copyToClipboard(text: string, id: string, shortCode?: string) {
navigator.clipboard.writeText(text);
onCopy(text, id, shortCode);
toastMessages.linkCopied();
}
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
}
</script>
<div
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-transform hover:scale-105"
>
<div class="flex flex-col space-y-3">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{#if link.title}
<h3 class="truncate text-lg font-semibold text-theme-text">{link.title}</h3>
{:else}
<h3 class="truncate text-lg font-semibold text-theme-text">Untitled Link</h3>
{/if}
{#if !link.is_active}
<span
class="rounded bg-red-100 px-2 py-1 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400"
>Inactive</span
>
{/if}
</div>
{#if link.expand?.folder}
<span
class="mt-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style="background-color: {link.expand.folder.color}20; color: {link.expand.folder
.color}"
>
{link.expand.folder.icon}
{link.expand.folder.display_name}
</span>
{/if}
{#if link.expand?.['link_tags(link_id)'] && link.expand['link_tags(link_id)'].length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each link.expand['link_tags(link_id)'] as linkTag}
{#if linkTag.expand?.tag_id}
<TagBadge tag={linkTag.expand.tag_id} size="sm" />
{/if}
{/each}
</div>
{/if}
</div>
<div class="relative ml-2">
<button
onclick={toggleDropdown}
class="rounded-lg bg-theme-surface-hover p-2 transition-colors hover:bg-theme-border"
>
<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 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zM12 13a1 1 0 110-2 1 1 0 010 2zM12 20a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if dropdownOpen}
<div
class="absolute right-0 z-10 mt-2 w-48 rounded-lg border border-theme-border bg-white shadow-lg"
>
<a
href="/my/analytics/{link.short_code}"
class="block px-4 py-2 text-sm text-blue-600 transition-colors hover:bg-blue-50"
onclick={toggleDropdown}
>
📊 Analytics
</a>
<form method="POST" action="?/toggle" use:enhance>
<input type="hidden" name="id" value={link.id} />
<input type="hidden" name="is_active" value={link.is_active} />
<button
type="submit"
onclick={toggleDropdown}
class="w-full px-4 py-2 text-left text-sm {link.is_active
? 'text-orange-600 hover:bg-orange-50'
: 'text-green-600 hover:bg-green-50'} transition-colors"
>
{link.is_active ? '⏸️ Deactivate' : '▶️ Activate'}
</button>
</form>
<div class="border-t border-theme-border">
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ update, result }) => {
if (confirm('Möchtest du diesen Link wirklich löschen?')) {
toggleDropdown();
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code,
});
toastMessages.linkDeleted();
}
}
};
}}
>
<input type="hidden" name="id" value={link.id} />
<button
type="submit"
class="w-full px-4 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50"
>
🗑️ Delete
</button>
</form>
</div>
</div>
{/if}
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm text-theme-text-muted">Short URL:</span>
<a
href="/{link.short_code}"
target="_blank"
class="font-mono text-sm text-blue-600 hover:text-blue-800"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: username || 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
>
{formatUrl(link.short_code)}
</a>
</div>
<p class="truncate text-sm text-theme-text-muted">
{link.original_url}
</p>
{#if link.description}
<p class="text-sm text-theme-text">{link.description}</p>
{/if}
</div>
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-4 text-xs text-theme-text-muted">
<span>Clicks: {link.clicks || 0}</span>
{#if link.expires_at}
<span class="text-orange-600">
Expires: {new Date(link.expires_at).toLocaleDateString()}
</span>
{/if}
{#if link.max_clicks}
<span class="text-purple-600">Max: {link.max_clicks}</span>
{/if}
{#if link.password}
<span class="text-red-600">🔒</span>
{/if}
</div>
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="rounded-lg bg-theme-primary px-3 py-1 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
{copiedStates[link.id] ? '✓ Copied' : '📋 Copy'}
</button>
</div>
</div>
</div>

View file

@ -1,340 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import LinkCreationForm from './LinkCreationForm.svelte';
import { toastMessages, notify } from '$lib/services/toast';
import { trackLinkCreated } from '$lib/analytics';
import { goto } from '$app/navigation';
import type { Tag } from '$lib/pocketbase';
import * as m from '$paraglide/messages';
interface Props {
user?: {
id: string;
username?: string;
};
folders?: Array<{
id: string;
icon: string;
display_name: string;
}>;
tags?: Tag[];
workspace?: any;
defaultOpen?: boolean;
editingLink?: any;
onSuccess?: (link: any, shortUrl: string) => void;
refreshOnSuccess?: boolean;
}
let {
user,
folders = [],
tags = [],
workspace,
defaultOpen = true,
editingLink,
onSuccess,
refreshOnSuccess = true,
}: Props = $props();
let isOpen = $state(defaultOpen);
// Update isOpen when defaultOpen changes
$effect(() => {
isOpen = defaultOpen;
});
let showBulkCreate = $state(false);
let isSubmitting = $state(false);
let bulkUrls = $state('');
let bulkFolder = $state('');
let bulkUseUsername = $state(false);
let createdLinks = $state<Array<{ url: string; shortCode: string }>>([]);
let copiedStates = $state<Record<string, boolean>>({});
function handleSingleSuccess(link: any, shortUrl: string) {
console.log('✅ LinkCreationCard: Single link created successfully');
console.log('Link:', link);
console.log('Short URL:', shortUrl);
toastMessages.linkCreated();
if (onSuccess) {
onSuccess(link, shortUrl);
}
if (refreshOnSuccess) {
// Immediately invalidate to refresh the data
goto(window.location.pathname, { invalidateAll: true });
}
}
function copyToClipboard(text: string, id: string) {
navigator.clipboard.writeText(text);
copiedStates[id] = true;
toastMessages.linkCopied();
setTimeout(() => (copiedStates[id] = false), 2000);
}
function handleBulkSubmit() {
console.log('📦 LinkCreationCard: Bulk submit initiated');
console.log('Bulk URLs:', bulkUrls);
console.log('Bulk Folder:', bulkFolder);
console.log('Use Username:', bulkUseUsername);
isSubmitting = true;
return async ({ result, update }: any) => {
console.log('📦 LinkCreationCard: Bulk form enhance callback triggered');
console.log('Result type:', result.type);
console.log('Result data:', result.data);
if (result.type === 'success') {
const urls = bulkUrls.split('\n').filter((line) => line.trim());
console.log('✅ LinkCreationCard: Bulk creation successful');
console.log('Created links:', result.data?.links);
notify.success(`${urls.length} Links erfolgreich erstellt!`);
// Store created links for display
if (result.data?.links) {
createdLinks = result.data.links.map((link: any) => ({
url: link.shortUrl,
shortCode: link.short_code,
}));
}
// Clear form
bulkUrls = '';
bulkFolder = '';
bulkUseUsername = false;
if (refreshOnSuccess) {
setTimeout(() => {
goto(window.location.pathname, { invalidateAll: true });
}, 2000);
}
} else if (result.type === 'failure' && result.data?.error) {
console.error('❌ LinkCreationCard: Bulk creation failed');
console.error('Error:', result.data.error);
notify.error(m.error_link_creation(), result.data.error);
} else {
console.warn('⚠️ LinkCreationCard: Unexpected result type');
console.warn('Full result:', result);
}
await update();
isSubmitting = false;
console.log('🏁 LinkCreationCard: Bulk submit complete');
};
}
</script>
<div
class="mb-8 transition-all duration-500 ease-in-out {isOpen
? 'max-h-[3000px] opacity-100'
: 'max-h-0 overflow-hidden opacity-0'}"
>
<div
class="border-theme-accent/30 to-theme-accent/5 rounded-xl border-2 bg-gradient-to-br from-theme-surface via-theme-surface p-6 shadow-2xl sm:p-8"
>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 text-xl font-bold text-white"
>
+
</div>
<h2 class="text-2xl font-bold text-theme-text">
{showBulkCreate
? 'Mehrere Links erstellen'
: editingLink
? 'Link bearbeiten'
: 'Neuen Link erstellen'}
</h2>
</div>
<div class="flex items-center gap-2">
<!-- Toggle Single/Bulk -->
<button
type="button"
onclick={() => (showBulkCreate = !showBulkCreate)}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all {showBulkCreate
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}"
>
{showBulkCreate ? '← Einzelner Link' : 'Mehrere Links →'}
</button>
<!-- Close Button -->
<button
onclick={() => (isOpen = false)}
class="rounded-lg p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
title="Formular ausblenden"
>
<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="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
</div>
<!-- Content -->
{#if showBulkCreate}
<!-- Bulk Create Form -->
<form method="POST" action="?/bulk_create" use:enhance={handleBulkSubmit} class="space-y-6">
<div>
<label for="bulk_urls" class="mb-2 block text-lg font-medium text-theme-text">
<span class="mr-2 text-theme-accent">1.</span>
URLs eingeben (eine pro Zeile)
</label>
<textarea
id="bulk_urls"
name="bulk_urls"
rows="6"
required
placeholder="https://beispiel.de&#10;https://google.com&#10;https://github.com"
bind:value={bulkUrls}
class="w-full rounded-lg border-2 border-theme-border bg-theme-surface px-4 py-3 font-mono text-sm text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
></textarea>
<p class="mt-2 flex items-center gap-2 text-sm text-theme-text-muted">
<svg
class="h-4 w-4 text-theme-accent"
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"
></path>
</svg>
{bulkUrls.split('\n').filter((line) => line.trim()).length} URLs erkannt
</p>
</div>
{#if user}
<div class="space-y-4">
<label class="block text-lg font-medium text-theme-text">
<span class="mr-2 text-theme-accent">2.</span>
Optionen für alle Links
</label>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Folder Selection -->
{#if folders.length > 0}
<div>
<label for="bulk_folder" class="mb-1 block text-sm font-medium text-theme-text">
Ordner zuweisen
</label>
<select
id="bulk_folder"
name="bulk_folder"
bind:value={bulkFolder}
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="">Kein Ordner</option>
{#each folders as folder}
<option value={folder.id}>
{folder.icon}
{folder.display_name}
</option>
{/each}
</select>
</div>
{/if}
<!-- Username Prefix -->
<div>
<div class="mb-1 block text-sm font-medium text-theme-text">URL-Format</div>
<label
class="flex cursor-pointer items-center space-x-2 rounded-lg bg-theme-surface-hover p-3"
>
<input
type="checkbox"
name="bulk_use_username"
bind:checked={bulkUseUsername}
class="h-4 w-4 rounded border-theme-border text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-theme-text">
Mit Benutzername: /u/{user.username}/[code]
</span>
</label>
</div>
</div>
</div>
{/if}
<!-- Submit Button -->
<button
type="submit"
disabled={isSubmitting || !bulkUrls.trim()}
class="flex w-full transform items-center justify-center rounded-lg bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-4 font-semibold text-white shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:from-indigo-700 hover:to-purple-700 hover:shadow-xl 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>
Erstelle Links...
{:else}
🚀 Alle Links erstellen
{/if}
</button>
<!-- Created Links Display -->
{#if createdLinks.length > 0}
<div
class="mt-6 rounded-lg border border-green-300 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
>
<h3 class="mb-3 font-semibold text-green-800 dark:text-green-300">
{createdLinks.length} Links erfolgreich erstellt:
</h3>
<div class="max-h-60 space-y-2 overflow-y-auto">
{#each createdLinks as link, i}
<div class="flex items-center gap-2 rounded bg-white p-2 dark:bg-theme-surface">
<code class="flex-1 font-mono text-sm text-green-700 dark:text-green-400">
{link.url}
</code>
<button
type="button"
onclick={() => copyToClipboard(link.url, `bulk-${i}`)}
class="rounded px-2 py-1 text-xs font-medium transition-colors {copiedStates[
`bulk-${i}`
]
? 'bg-green-600 text-white'
: 'bg-green-700 text-white hover:bg-green-800'}"
>
{copiedStates[`bulk-${i}`] ? '✓' : '📋'}
</button>
</div>
{/each}
</div>
</div>
{/if}
</form>
{:else}
<!-- Single Link Progressive Form -->
<LinkCreationForm
{user}
{tags}
{folders}
{workspace}
{editingLink}
mode="advanced"
onSuccess={handleSingleSuccess}
/>
{/if}
</div>
</div>

View file

@ -1,200 +0,0 @@
<script lang="ts">
import LinkCard from './LinkCard.svelte';
import LinkListItem from './LinkListItem.svelte';
import type { Tag } from '$lib/pocketbase';
import type { ViewMode } from '$lib/stores/viewModes';
import { toastMessages } from '$lib/services/toast';
interface Link {
id: string;
short_code: string;
original_url: string;
title?: string;
description?: string;
clicks: number;
is_active: boolean;
expires_at?: string;
max_clicks?: number;
password?: string;
created: string;
use_username?: boolean;
folder?: string;
expand?: {
folder?: {
id: string;
name: string;
display_name: string;
icon: string;
color: string;
};
'link_tags(link_id)'?: Array<{
expand?: {
tag_id?: Tag;
};
}>;
};
}
interface PaginatedLinks {
items: Link[];
page: number;
totalPages: number;
totalItems: number;
}
interface Props {
links: PaginatedLinks;
username?: string;
viewMode: ViewMode;
onPageChange?: (page: number) => void;
isSelectMode?: boolean;
selectedLinks?: Set<string>;
onToggleSelect?: (linkId: string) => void;
}
let {
links,
username,
viewMode,
onPageChange = () => {},
isSelectMode = false,
selectedLinks = new Set<string>(),
onToggleSelect = () => {},
}: Props = $props();
let copiedStates = $state<Record<string, boolean>>({});
function handleCopy(text: string, id: string, shortCode?: string) {
copiedStates[id] = true;
setTimeout(() => (copiedStates[id] = false), 2000);
if (shortCode) {
import('$lib/analytics').then(({ trackEvent, EVENTS }) => {
trackEvent(EVENTS.LINK_COPIED, { short_code: shortCode });
});
}
}
</script>
{#if links && links.items && links.items.length > 0}
{#if viewMode === 'cards'}
<div class="space-y-6">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{#each links.items as link}
<div
class="relative overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-sm transition-shadow duration-200 hover:shadow-lg {isSelectMode &&
selectedLinks.has(link.id)
? 'ring-2 ring-theme-primary'
: ''}"
>
{#if isSelectMode}
<div class="absolute left-3 top-3 z-10">
<input
type="checkbox"
checked={selectedLinks.has(link.id)}
onchange={() => onToggleSelect(link.id)}
class="h-5 w-5 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
<LinkCard {link} {username} onCopy={handleCopy} {copiedStates} />
</div>
{/each}
</div>
</div>
{:else}
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
<!-- Desktop Table Header -->
<div
class="hidden lg:grid {isSelectMode
? 'grid-cols-[40px_200px_200px_1fr_100px_120px_180px]'
: 'grid-cols-[200px_200px_1fr_100px_120px_180px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
>
{#if isSelectMode}<div></div>{/if}
<div>Title</div>
<div>Short URL</div>
<div>Destination</div>
<div>Clicks</div>
<div>Created</div>
<div class="text-right">Actions</div>
</div>
<!-- Tablet Table Header -->
<div
class="hidden md:grid lg:hidden {isSelectMode
? 'grid-cols-[40px_minmax(200px,1fr)_200px_100px_140px]'
: 'grid-cols-[minmax(200px,1fr)_200px_100px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text"
>
{#if isSelectMode}<div></div>{/if}
<div>Title</div>
<div>Short URL</div>
<div>Clicks</div>
<div class="text-right">Actions</div>
</div>
<!-- Table Body -->
<div>
{#each links.items as link}
<LinkListItem
{link}
{username}
onCopy={handleCopy}
{copiedStates}
{isSelectMode}
isSelected={selectedLinks.has(link.id)}
onToggleSelect={() => onToggleSelect(link.id)}
/>
{/each}
</div>
</div>
{/if}
{#if links.totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
{#if links.page > 1}
<button
onclick={() => onPageChange(links.page - 1)}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Previous
</button>
{/if}
{#each Array(Math.min(5, links.totalPages)) as _, i}
{@const pageNum = Math.max(1, links.page - 2) + i}
{#if pageNum <= links.totalPages}
<button
onclick={() => onPageChange(pageNum)}
class="rounded-lg px-3 py-2 transition-colors {pageNum === links.page
? 'bg-theme-primary text-white'
: 'border border-theme-border bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
{pageNum}
</button>
{/if}
{/each}
{#if links.page < links.totalPages}
<button
onclick={() => onPageChange(links.page + 1)}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Next
</button>
{/if}
</div>
{/if}
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text">
Keine Links gefunden. Versuchen Sie Ihre Filter anzupassen oder erstellen Sie Ihren ersten
Link!
</p>
<slot name="empty-action">
<button
onclick={() => window.dispatchEvent(new CustomEvent('show-create-form'))}
class="mt-4 inline-block rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Ersten Link erstellen
</button>
</slot>
</div>
{/if}

View file

@ -1,541 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TagBadge from '$lib/components/TagBadge.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import { trackEvent, trackLinkClick, EVENTS } from '$lib/analytics';
import { toastMessages } from '$lib/services/toast';
import { MousePointer, Calendar, Lock, Link as LinkIcon } from 'lucide-svelte';
import type { Tag } from '$lib/pocketbase';
interface Link {
id: string;
short_code: string;
original_url: string;
title?: string;
description?: string;
clicks: number;
is_active: boolean;
expires_at?: string;
max_clicks?: number;
password?: string;
created: string;
// use_username removed - now handled by short_code format
folder?: string;
last_clicked_at?: string;
expand?: {
folder?: {
id: string;
name: string;
display_name: string;
icon: string;
color: string;
};
'link_tags(link_id)'?: Array<{
expand?: {
tag_id?: Tag;
};
}>;
};
}
interface Props {
link: Link;
username?: string;
onCopy?: (text: string, id: string, shortCode?: string) => void;
copiedStates?: Record<string, boolean>;
isSelectMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
}
let {
link,
username,
onCopy = () => {},
copiedStates = {},
isSelectMode = false,
isSelected = false,
onToggleSelect = () => {},
}: Props = $props();
const isExpired = link.expires_at ? new Date(link.expires_at) < new Date() : false;
const isNearLimit = link.max_clicks ? link.clicks >= link.max_clicks * 0.8 : false;
function formatUrl(shortCode: string) {
if (typeof window === 'undefined') return shortCode;
// Short codes with slashes are already username-prefixed custom codes
return `${window.location.origin}/${shortCode}`;
}
function copyToClipboard(text: string, id: string, shortCode?: string) {
navigator.clipboard.writeText(text);
onCopy(text, id, shortCode);
toastMessages.linkCopied();
}
</script>
<!-- Desktop View -->
<div
class="hidden lg:grid {isSelectMode
? 'grid-cols-[40px_200px_200px_1fr_100px_120px_180px]'
: 'grid-cols-[200px_200px_1fr_100px_120px_180px]'} items-center gap-4 border-b border-theme-border {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} px-6 py-4 transition-colors hover:bg-theme-surface-hover"
>
<!-- Checkbox Column -->
{#if isSelectMode}
<div>
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
<!-- Title Column -->
<div class="font-medium text-theme-text">
<div class="truncate" title={link.title || link.short_code}>
{link.title || link.short_code}
</div>
</div>
<!-- Short URL Column -->
<div>
<a
href={formatUrl(link.short_code)}
target="_blank"
class="block truncate text-sm text-theme-primary hover:underline"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: username || 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
title="ulo.ad/{link.short_code}"
>
ulo.ad/{link.short_code}
</a>
</div>
<!-- Destination Column -->
<div class="min-w-0">
<div class="flex items-center gap-2 text-sm text-theme-text-muted">
<span class="truncate" title={link.original_url}>
{link.original_url}
</span>
{#if !link.is_active}
<span class="flex-shrink-0 text-xs font-medium text-red-600">Inactive</span>
{/if}
{#if link.password}
<Lock class="h-3 w-3 flex-shrink-0 text-yellow-600" title="Password protected" />
{/if}
</div>
</div>
<!-- Clicks Column -->
<div class="text-sm text-theme-text-muted">
<div class="flex items-center gap-1">
<MousePointer class="h-3 w-3" />
<span class={isNearLimit ? 'font-medium text-orange-600' : ''}>
{link.clicks || 0}
</span>
</div>
</div>
<!-- Created Column -->
<div class="text-xs text-theme-text-muted">
<div>{new Date(link.created).toLocaleDateString('de-DE')}</div>
{#if link.last_clicked_at}
<div class="mt-1 text-xs text-theme-accent">
Last: {new Date(link.last_clicked_at).toLocaleDateString('de-DE')}
</div>
{/if}
</div>
<!-- Actions Column -->
<div class="flex items-center justify-end gap-2">
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded-lg px-3 py-1.5 text-sm font-medium text-theme-primary transition"
title="Copy URL"
>
{#if copiedStates[link.id]}
✓ Copied
{:else}
Copy URL
{/if}
</button>
<Dropdown
items={[
{
label: 'Analytics',
href: `/my/analytics/${link.short_code}`,
icon: '<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="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>',
color: '#2563eb',
},
{
label: 'QR Code',
icon: '<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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h8m-4 0v8m-4-8h.01M8 8h8M4 20h2m0-4v4m12-4v4" /></svg>',
color: '#16a34a',
action: () => {
window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
},
},
{
label: 'Edit',
icon: '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: () => {
window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
},
},
{
label: link.is_active ? 'Deactivate' : 'Activate',
type: 'form',
formAction: '?/toggle',
formData: { id: link.id, is_active: String(link.is_active) },
icon: link.is_active
? '<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
: '<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
color: link.is_active ? '#ea580c' : '#16a34a',
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: link.id },
enhanceOptions: () => {
return async ({ update, result, cancel }) => {
if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
cancel();
return;
}
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code,
});
toastMessages.linkDeleted();
}
};
},
},
]}
buttonText="Actions"
size="sm"
/>
</div>
</div>
<!-- Tablet View -->
<div
class="hidden md:grid lg:hidden {isSelectMode
? 'grid-cols-[40px_minmax(200px,1fr)_200px_100px_140px]'
: 'grid-cols-[minmax(200px,1fr)_200px_100px_140px]'} items-center gap-4 border-b border-theme-border {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} px-4 py-4 transition-colors hover:bg-theme-surface-hover"
>
<!-- Checkbox Column -->
{#if isSelectMode}
<div>
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
</div>
{/if}
<!-- Title Column -->
<div class="font-medium text-theme-text">
<div class="truncate" title={link.title || link.short_code}>
{link.title || link.short_code}
</div>
{#if link.expand?.['link_tags(link_id)']?.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each link.expand['link_tags(link_id)'].slice(0, 2) as linkTag}
{#if linkTag.expand?.tag_id}
<TagBadge tag={linkTag.expand.tag_id} size="xs" />
{/if}
{/each}
</div>
{/if}
</div>
<!-- Short URL Column -->
<div>
<a
href={formatUrl(link.short_code)}
target="_blank"
class="block truncate text-sm text-theme-primary hover:underline"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: username || 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
title="ulo.ad/{link.short_code}"
>
ulo.ad/{link.short_code}
</a>
</div>
<!-- Clicks Column -->
<div class="text-sm text-theme-text-muted">
<div class="flex items-center gap-1">
<MousePointer class="h-3 w-3" />
<span class={isNearLimit ? 'font-medium text-orange-600' : ''}>
{link.clicks || 0}
</span>
</div>
</div>
<!-- Actions Column -->
<div class="flex items-center justify-end gap-2">
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded-lg px-3 py-1.5 text-sm font-medium text-theme-primary transition"
title="Copy URL"
>
{#if copiedStates[link.id]}
{:else}
Copy
{/if}
</button>
<Dropdown
items={[
{
label: 'Analytics',
href: `/my/analytics/${link.short_code}`,
icon: '<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="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>',
color: '#2563eb',
},
{
label: 'QR Code',
icon: '<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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h8m-4 0v8m-4-8h.01M8 8h8M4 20h2m0-4v4m12-4v4" /></svg>',
color: '#16a34a',
action: () => {
window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
},
},
{
label: 'Edit',
icon: '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: () => {
window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
},
},
{
label: link.is_active ? 'Deactivate' : 'Activate',
type: 'form',
formAction: '?/toggle',
formData: { id: link.id, is_active: String(link.is_active) },
icon: link.is_active
? '<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
: '<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
color: link.is_active ? '#ea580c' : '#16a34a',
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: link.id },
enhanceOptions: () => {
return async ({ update, result, cancel }) => {
if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
cancel();
return;
}
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code,
});
toastMessages.linkDeleted();
}
};
},
},
]}
buttonText="•••"
size="sm"
/>
</div>
</div>
<!-- Mobile View -->
<div
class="border-b border-theme-border md:hidden {isSelected
? 'bg-theme-primary/5'
: 'bg-theme-surface'} p-4 transition-colors hover:bg-theme-surface-hover"
>
<div class="space-y-3">
<!-- Checkbox for mobile -->
{#if isSelectMode}
<div class="flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onchange={onToggleSelect}
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary"
/>
<span class="text-sm text-theme-text">Select</span>
</label>
</div>
{/if}
<!-- Title and URL -->
<div>
<div class="mb-1 font-medium text-theme-text">
{link.title || link.short_code}
</div>
<a
href={formatUrl(link.short_code)}
target="_blank"
class="text-sm text-theme-primary hover:underline"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: username || 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at,
})}
>
ulo.ad/{link.short_code}
</a>
</div>
<!-- Destination -->
<div class="text-sm text-theme-text-muted">
<div class="truncate">{link.original_url}</div>
</div>
<!-- Tags -->
{#if link.expand?.['link_tags(link_id)']?.length > 0}
<div class="flex flex-wrap gap-1">
{#each link.expand['link_tags(link_id)'] as linkTag}
{#if linkTag.expand?.tag_id}
<TagBadge tag={linkTag.expand.tag_id} size="xs" />
{/if}
{/each}
</div>
{/if}
<!-- Stats -->
<div class="flex items-center justify-between text-sm text-theme-text-muted">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<MousePointer class="h-3 w-3" />
{link.clicks || 0} clicks
</span>
<span>
<Calendar class="inline h-3 w-3" />
{new Date(link.created).toLocaleDateString('de-DE')}
</span>
</div>
<div class="flex items-center gap-2">
{#if !link.is_active}
<span class="text-xs font-medium text-red-600">Inactive</span>
{/if}
{#if link.password}
<Lock class="h-3 w-3 text-yellow-600" title="Password protected" />
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded-lg px-3 py-2 text-sm font-medium text-theme-primary transition"
>
{#if copiedStates[link.id]}
✓ Copied
{:else}
Copy URL
{/if}
</button>
<a
href="/my/analytics/{link.short_code}"
class="flex-1 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-center text-sm font-medium text-theme-text transition hover:bg-theme-surface-hover"
>
Analytics
</a>
<Dropdown
items={[
{
label: 'QR Code',
icon: '<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 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h8m-4 0v8m-4-8h.01M8 8h8M4 20h2m0-4v4m12-4v4" /></svg>',
color: '#16a34a',
action: () => {
window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
},
},
{
label: 'Edit',
icon: '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: () => {
window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
},
},
{
label: link.is_active ? 'Deactivate' : 'Activate',
type: 'form',
formAction: '?/toggle',
formData: { id: link.id, is_active: String(link.is_active) },
icon: link.is_active
? '<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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
: '<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
color: link.is_active ? '#ea580c' : '#16a34a',
},
{
divider: true,
},
{
label: 'Delete',
icon: '<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="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>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: link.id },
enhanceOptions: () => {
return async ({ update, result, cancel }) => {
if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
cancel();
return;
}
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code,
});
toastMessages.linkDeleted();
}
};
},
},
]}
buttonText="•••"
size="sm"
/>
</div>
</div>
</div>

View file

@ -1,314 +0,0 @@
<script lang="ts">
import {
TrendingUp,
TrendingDown,
Users,
MousePointer,
Globe,
Clock,
BarChart3,
Activity,
Link,
ExternalLink,
ArrowUp,
ArrowDown,
Minus,
} from 'lucide-svelte';
import type { Link as LinkType } from '$lib/types/links';
interface Props {
links: LinkType[];
totalClicks?: number;
period?: '7d' | '30d' | '90d' | 'all';
}
let { links = [], totalClicks = 0, period = '30d' }: Props = $props();
// Calculate statistics
let stats = $derived(() => {
const now = new Date();
const activeLinkCount = links.filter((l) => !l.archived).length;
const allClicks = links.reduce((sum, link) => sum + (link.clicks || 0), 0);
// Calculate average CTR (simplified)
const avgCtr =
activeLinkCount > 0 ? ((allClicks / (activeLinkCount * 100)) * 100).toFixed(1) : '0';
// Get top performing links
const topLinks = [...links]
.filter((l) => !l.archived)
.sort((a, b) => (b.clicks || 0) - (a.clicks || 0))
.slice(0, 5);
// Get recent links
const recentLinks = [...links]
.filter((l) => !l.archived)
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.slice(0, 5);
// Calculate trend (mock data for now)
const previousPeriodClicks = Math.floor(allClicks * 0.8);
const clickTrend =
previousPeriodClicks > 0
? (((allClicks - previousPeriodClicks) / previousPeriodClicks) * 100).toFixed(0)
: '0';
// Device breakdown (mock data)
const deviceBreakdown = {
desktop: Math.floor(allClicks * 0.55),
mobile: Math.floor(allClicks * 0.4),
tablet: Math.floor(allClicks * 0.05),
};
// Time distribution (mock data for visualization)
const hourlyDistribution = Array.from({ length: 24 }, (_, i) => ({
hour: i,
clicks: Math.floor(Math.random() * 100),
}));
return {
totalLinks: links.length,
activeLinks: activeLinkCount,
totalClicks: allClicks,
avgCtr,
clickTrend,
topLinks,
recentLinks,
deviceBreakdown,
hourlyDistribution,
};
});
function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
function getChangeIcon(trend: string) {
const value = parseFloat(trend);
if (value > 0) return ArrowUp;
if (value < 0) return ArrowDown;
return Minus;
}
function getChangeColor(trend: string) {
const value = parseFloat(trend);
if (value > 0) return 'text-green-600 dark:text-green-400';
if (value < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-600 dark:text-gray-400';
}
</script>
<div class="space-y-6">
<!-- Key Metrics Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Total Clicks -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<MousePointer class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex items-center gap-1 text-sm {getChangeColor(stats().clickTrend)}">
<svelte:component this={getChangeIcon(stats().clickTrend)} class="h-3 w-3" />
<span>{Math.abs(parseFloat(stats().clickTrend))}%</span>
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{formatNumber(stats().totalClicks)}</p>
<p class="mt-1 text-sm text-theme-text-muted">Total Clicks</p>
</div>
</div>
<!-- Active Links -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Link class="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{stats().activeLinks}</p>
<p class="mt-1 text-sm text-theme-text-muted">Active Links</p>
</div>
</div>
<!-- Average CTR -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Activity class="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{stats().avgCtr}%</p>
<p class="mt-1 text-sm text-theme-text-muted">Avg. Engagement</p>
</div>
</div>
<!-- Link Efficiency -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<BarChart3 class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">
{stats().activeLinks > 0 ? Math.floor(stats().totalClicks / stats().activeLinks) : 0}
</p>
<p class="mt-1 text-sm text-theme-text-muted">Clicks per Link</p>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Hourly Activity Chart -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800 lg:col-span-2">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Clock class="h-5 w-5 text-theme-text-muted" />
Click Activity (24h)
</h3>
<div class="flex h-48 items-end gap-1">
{#each stats().hourlyDistribution as hour}
<div
class="group relative flex-1 rounded-t bg-blue-500 opacity-80 transition-opacity hover:opacity-100 dark:bg-blue-600"
style="height: {hour.clicks}%"
title="{hour.hour}:00 - {hour.clicks} clicks"
>
<div
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
>
{hour.hour}:00
</div>
</div>
{/each}
</div>
<div class="mt-2 flex justify-between text-xs text-theme-text-muted">
<span>00:00</span>
<span>06:00</span>
<span>12:00</span>
<span>18:00</span>
<span>23:00</span>
</div>
</div>
<!-- Device Breakdown -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Users class="h-5 w-5 text-theme-text-muted" />
Device Types
</h3>
<div class="space-y-4">
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-theme-text">Desktop</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.desktop}</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-2 rounded-full bg-blue-500" style="width: 55%"></div>
</div>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-theme-text">Mobile</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.mobile}</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-2 rounded-full bg-green-500" style="width: 40%"></div>
</div>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="text-theme-text">Tablet</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.tablet}</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-2 rounded-full bg-purple-500" style="width: 5%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Top Links Table -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Top Performing Links -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
Top Performing Links
</h3>
<div class="space-y-3">
{#each stats().topLinks as link, i}
<div
class="flex items-center justify-between rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex items-center gap-3">
<span class="w-6 text-sm font-medium text-theme-text-muted">#{i + 1}</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-theme-text">
{link.title || link.short_url}
</p>
<p class="truncate text-xs text-theme-text-muted">
{link.short_url}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-theme-text">{link.clicks || 0}</span>
<span class="text-xs text-theme-text-muted">clicks</span>
<a
href={`/${link.short_url}`}
target="_blank"
class="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-600"
>
<ExternalLink class="h-3 w-3 text-theme-text-muted" />
</a>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No links with clicks yet</p>
{/each}
</div>
</div>
<!-- Recent Links -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Clock class="h-5 w-5 text-blue-600 dark:text-blue-400" />
Recently Created
</h3>
<div class="space-y-3">
{#each stats().recentLinks as link}
<div
class="flex items-center justify-between rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-theme-text">
{link.title || link.short_url}
</p>
<p class="text-xs text-theme-text-muted">
{new Date(link.created).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-theme-text">{link.clicks || 0}</span>
<span class="text-xs text-theme-text-muted">clicks</span>
<a
href={`/${link.short_url}`}
target="_blank"
class="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-600"
>
<ExternalLink class="h-3 w-3 text-theme-text-muted" />
</a>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No links created yet</p>
{/each}
</div>
</div>
</div>
</div>

View file

@ -1,233 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { installPWA, isInstallableStore, isStandaloneStore } from '$lib/pwa';
import { ripple } from '$lib/actions/touch';
let showBanner = $state(false);
let isInstalling = $state(false);
let isInstallable = $state(false);
let isStandalone = $state(false);
onMount(() => {
// Subscribe to stores
const unsubInstallable = isInstallableStore.subscribe((value) => {
isInstallable = value;
});
const unsubStandalone = isStandaloneStore.subscribe((value) => {
isStandalone = value;
});
// Zeige Banner nur wenn app installierbar ist und nicht bereits im standalone modus
const checkShowBanner = () => {
showBanner = isInstallable && !isStandalone;
};
// Initial check
checkShowBanner();
// Warte auf beforeinstallprompt event
const handleBeforeInstallPrompt = () => {
setTimeout(checkShowBanner, 100);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
unsubInstallable();
unsubStandalone();
};
});
async function handleInstall() {
if (isInstalling) return;
isInstalling = true;
try {
const installed = await installPWA();
if (installed) {
showBanner = false;
}
} catch (error) {
console.error('Installation failed:', error);
} finally {
isInstalling = false;
}
}
function dismissBanner() {
showBanner = false;
// Merke dass User Banner dismissed hat (für diese Session)
sessionStorage.setItem('pwa-banner-dismissed', 'true');
}
// Prüfe ob Banner bereits dismissed wurde
onMount(() => {
const dismissed = sessionStorage.getItem('pwa-banner-dismissed');
if (dismissed) {
showBanner = false;
}
});
</script>
{#if showBanner}
<!-- PWA Install Banner -->
<div class="fixed bottom-4 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm">
<div
class="rounded-lg border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-800"
>
<!-- Header -->
<div class="mb-3 flex items-start justify-between">
<div class="flex items-center space-x-3">
<!-- App Icon -->
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-blue-600"
>
<svg class="h-6 w-6 text-white" 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>
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Install uLoad</h3>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Add to home screen for quick access
</p>
</div>
</div>
<!-- Dismiss Button -->
<button
onclick={dismissBanner}
class="rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Dismiss install banner"
>
<svg class="h-4 w-4 text-gray-400" 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>
<!-- Benefits -->
<div class="mb-4 space-y-1">
<div class="flex items-center text-xs text-gray-600 dark:text-gray-300">
<svg
class="mr-2 h-3 w-3 flex-shrink-0 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<span>Works offline</span>
</div>
<div class="flex items-center text-xs text-gray-600 dark:text-gray-300">
<svg
class="mr-2 h-3 w-3 flex-shrink-0 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<span>Fast loading</span>
</div>
<div class="flex items-center text-xs text-gray-600 dark:text-gray-300">
<svg
class="mr-2 h-3 w-3 flex-shrink-0 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<span>Native app feel</span>
</div>
</div>
<!-- Actions -->
<div class="flex space-x-2">
<button
onclick={handleInstall}
disabled={isInstalling}
use:ripple={{ color: 'rgba(59, 130, 246, 0.3)' }}
class="relative flex-1 overflow-hidden rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isInstalling}
<span class="flex items-center justify-center">
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
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>
Installing...
</span>
{:else}
Install
{/if}
</button>
<button
onclick={dismissBanner}
class="px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100"
>
Not now
</button>
</div>
</div>
</div>
{/if}
<style>
/* Smooth animations for banner */
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
div[class*='fixed bottom-4'] > div {
animation: slideUp 0.3s ease-out;
}
</style>

View file

@ -1,411 +0,0 @@
<script>
import { onMount } from 'svelte';
import {
generateSecret,
generateQRCodeURL,
generateTOTP,
verifyTOTP,
generateBackupCodes,
} from '$lib/security/totp';
import { toast } from 'svelte-sonner';
interface Props {
userEmail: string;
onComplete?: (secret: string, backupCodes: string[]) => void;
onCancel?: () => void;
}
let { userEmail, onComplete, onCancel }: Props = $props();
// State
let step = $state(1); // 1: QR Code, 2: Verification, 3: Backup Codes
let secret = $state('');
let qrCodeURL = $state('');
let verificationCode = $state('');
let backupCodes = $state<string[]>([]);
let isVerifying = $state(false);
let currentToken = $state('');
let timeRemaining = $state(30);
// Timer für TOTP Updates
let interval: ReturnType<typeof setInterval>;
onMount(() => {
// Generiere Secret und QR Code
secret = generateSecret();
qrCodeURL = generateQRCodeURL(secret, userEmail, 'uLoad');
backupCodes = generateBackupCodes();
// Starte Timer für aktuellen TOTP
updateCurrentToken();
interval = setInterval(updateCurrentToken, 1000);
return () => {
if (interval) clearInterval(interval);
};
});
function updateCurrentToken() {
if (!secret) return;
const result = generateTOTP({ secret });
currentToken = result.token;
timeRemaining = result.timeRemaining;
}
async function verifyCode() {
if (!verificationCode || verificationCode.length !== 6) {
toast.error('Bitte geben Sie einen 6-stelligen Code ein');
return;
}
isVerifying = true;
try {
const isValid = verifyTOTP(verificationCode.replace(/\s/g, ''), { secret });
if (isValid) {
toast.success('2FA erfolgreich eingerichtet!');
step = 3; // Zeige Backup Codes
} else {
toast.error('Ungültiger Code. Bitte versuchen Sie es erneut.');
}
} catch (error) {
console.error('TOTP verification error:', error);
toast.error('Fehler bei der Verifizierung');
} finally {
isVerifying = false;
}
}
function completeSetup() {
onComplete?.(secret, backupCodes);
}
function handleCancel() {
onCancel?.();
}
function copyBackupCodes() {
const codesText = backupCodes.join('\n');
navigator.clipboard.writeText(codesText);
toast.success('Backup-Codes in Zwischenablage kopiert');
}
function downloadBackupCodes() {
const codesText = `uLoad 2FA Backup Codes
Generated: ${new Date().toLocaleDateString()}
Account: ${userEmail}
${backupCodes.join('\n')}
⚠️ WICHTIG:
- Bewahren Sie diese Codes an einem sicheren Ort auf
- Jeder Code kann nur einmal verwendet werden
- Verwenden Sie diese Codes nur, wenn Sie keinen Zugang zu Ihrer Authenticator-App haben
`;
const blob = new Blob([codesText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'uload-2fa-backup-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Backup-Codes heruntergeladen');
}
// Format verification code input
function formatVerificationCode(value: string) {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 3) {
return cleaned;
}
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`;
}
function handleVerificationInput(event: Event) {
const target = event.target as HTMLInputElement;
const formatted = formatVerificationCode(target.value);
verificationCode = formatted;
// Auto-submit wenn 6 Ziffern eingegeben
const digits = formatted.replace(/\s/g, '');
if (digits.length === 6) {
setTimeout(verifyCode, 100);
}
}
</script>
<div class="mx-auto max-w-2xl p-6">
<!-- Header -->
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
<svg class="h-8 w-8 text-blue-600" 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"
/>
</svg>
</div>
<h2 class="mb-2 text-2xl font-bold text-gray-900">Zwei-Faktor-Authentifizierung einrichten</h2>
<p class="text-gray-600">Erhöhen Sie die Sicherheit Ihres Kontos mit 2FA</p>
</div>
<!-- Progress Steps -->
<div class="mb-8 flex items-center justify-center">
<div class="flex items-center space-x-4">
{#each [1, 2, 3] as stepNumber}
<div class="flex items-center">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium
{step >= stepNumber ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-500'}"
>
{stepNumber}
</div>
{#if stepNumber < 3}
<div class="h-0.5 w-12 {step > stepNumber ? 'bg-blue-600' : 'bg-gray-200'} mx-2"></div>
{/if}
</div>
{/each}
</div>
</div>
{#if step === 1}
<!-- Schritt 1: QR Code scannen -->
<div class="rounded-xl border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-lg font-semibold">1. Authenticator-App einrichten</h3>
<div class="mb-6 text-center">
<!-- QR Code Placeholder -->
<div class="mx-auto mb-4 flex h-48 w-48 items-center justify-center rounded-lg bg-gray-100">
{#if qrCodeURL}
<!-- In Produktion würde hier ein echter QR Code generiert werden -->
<div class="text-center">
<div
class="mb-2 flex h-32 w-32 items-center justify-center rounded-lg border-2 border-gray-300 bg-white"
>
<span class="text-xs text-gray-500">QR Code</span>
</div>
<p class="text-xs text-gray-500">
Scannen Sie diesen Code mit Ihrer Authenticator-App
</p>
</div>
{:else}
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"
></div>
{/if}
</div>
<p class="mb-4 text-sm text-gray-600">
Scannen Sie den QR-Code mit einer Authenticator-App wie Google Authenticator, Authy oder
1Password
</p>
<!-- Manual entry option -->
<details class="text-left">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-700">
Manueller Setup-Code
</summary>
<div class="mt-3 rounded-lg bg-gray-50 p-3">
<p class="mb-2 text-xs text-gray-600">Falls Sie den QR-Code nicht scannen können:</p>
<code class="block break-all rounded border bg-white p-2 font-mono text-sm">
{secret}
</code>
<button
onclick={() => navigator.clipboard.writeText(secret)}
class="mt-2 text-xs text-blue-600 hover:text-blue-700"
>
Code kopieren
</button>
</div>
</details>
</div>
<div class="flex space-x-3">
<button
onclick={handleCancel}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-50"
>
Abbrechen
</button>
<button
onclick={() => (step = 2)}
disabled={!qrCodeURL}
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Weiter
</button>
</div>
</div>
{:else if step === 2}
<!-- Schritt 2: Code verifizieren -->
<div class="rounded-xl border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-lg font-semibold">2. Code verifizieren</h3>
<div class="mb-6 text-center">
<p class="mb-6 text-gray-600">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:
</p>
<!-- Verification Code Input -->
<div class="mb-4">
<input
type="text"
bind:value={verificationCode}
oninput={handleVerificationInput}
placeholder="000 000"
maxlength="7"
class="w-40 rounded-lg border-2 border-gray-300 px-4 py-3 text-center font-mono text-2xl focus:border-blue-500 focus:outline-none"
autocomplete="off"
spellcheck="false"
/>
</div>
<!-- Current TOTP for debugging (in dev only) -->
{#if import.meta.env.DEV}
<div class="mb-4 text-xs text-gray-400">
<p>Aktueller Code: <span class="font-mono">{currentToken}</span></p>
<p>Läuft ab in: {timeRemaining}s</p>
</div>
{/if}
<p class="text-sm text-gray-500">Der Code ändert sich alle 30 Sekunden</p>
</div>
<div class="flex space-x-3">
<button
onclick={() => (step = 1)}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-50"
>
Zurück
</button>
<button
onclick={verifyCode}
disabled={isVerifying || verificationCode.replace(/\s/g, '').length !== 6}
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isVerifying}
<span class="flex items-center justify-center">
<svg class="-ml-1 mr-2 h-4 w-4 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>
Verifiziere...
</span>
{:else}
Verifizieren
{/if}
</button>
</div>
</div>
{:else if step === 3}
<!-- Schritt 3: Backup Codes -->
<div class="rounded-xl border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-lg font-semibold">3. Backup-Codes sichern</h3>
<div class="mb-6">
<div class="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div class="flex">
<svg
class="mr-3 mt-0.5 h-5 w-5 flex-shrink-0 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<div>
<h4 class="font-medium text-yellow-800">Wichtig: Backup-Codes sichern</h4>
<p class="mt-1 text-sm text-yellow-700">
Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können verwendet werden,
wenn Sie keinen Zugang zu Ihrer Authenticator-App haben.
</p>
</div>
</div>
</div>
<!-- Backup Codes Grid -->
<div class="mb-6 grid grid-cols-2 gap-3">
{#each backupCodes as code}
<div class="rounded-lg bg-gray-50 p-3 text-center">
<code class="font-mono text-sm">{code}</code>
</div>
{/each}
</div>
<!-- Action Buttons -->
<div class="mb-6 flex space-x-3">
<button
onclick={copyBackupCodes}
class="flex flex-1 items-center justify-center rounded-lg border border-gray-300 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-50"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Kopieren
</button>
<button
onclick={downloadBackupCodes}
class="flex flex-1 items-center justify-center rounded-lg border border-gray-300 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-50"
>
<svg class="mr-2 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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Download
</button>
</div>
</div>
<div class="border-t pt-6">
<button
onclick={completeSetup}
class="w-full rounded-lg bg-green-600 px-4 py-3 font-medium text-white transition-colors hover:bg-green-700"
>
2FA-Einrichtung abschließen
</button>
</div>
</div>
{/if}
</div>
<style>
/* Custom styles für bessere UX */
input[type='text']:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
code {
letter-spacing: 0.05em;
}
</style>

View file

@ -1,409 +0,0 @@
<script lang="ts">
import {
Tag,
TrendingUp,
TrendingDown,
Link,
MousePointer,
Hash,
Layers,
Activity,
BarChart3,
ExternalLink,
ArrowUp,
ArrowDown,
Minus,
Zap,
Target,
Award,
} from 'lucide-svelte';
import type { Tag as TagType } from '$lib/pocketbase';
interface EnhancedTag extends TagType {
linkCount?: number;
totalClicks?: number;
}
interface Props {
tags: EnhancedTag[];
totalLinks?: number;
period?: '7d' | '30d' | '90d' | 'all';
}
let { tags = [], totalLinks = 0, period = '30d' }: Props = $props();
// Calculate statistics
let stats = $derived(() => {
const totalTags = tags.length;
const usedTags = tags.filter((t) => (t.linkCount || 0) > 0).length;
const totalClicks = tags.reduce((sum, tag) => sum + (tag.totalClicks || 0), 0);
const avgLinksPerTag =
usedTags > 0
? (tags.reduce((sum, tag) => sum + (tag.linkCount || 0), 0) / usedTags).toFixed(1)
: '0';
// Get top performing tags by clicks
const topByClicks = [...tags]
.filter((t) => (t.totalClicks || 0) > 0)
.sort((a, b) => (b.totalClicks || 0) - (a.totalClicks || 0))
.slice(0, 5);
// Get most used tags by link count
const mostUsedTags = [...tags]
.filter((t) => (t.linkCount || 0) > 0)
.sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0))
.slice(0, 5);
// Get recently created tags
const recentTags = [...tags]
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
.slice(0, 5);
// Calculate tag usage distribution
const distribution = {
highUsage: tags.filter((t) => (t.linkCount || 0) > 10).length,
mediumUsage: tags.filter((t) => (t.linkCount || 0) >= 5 && (t.linkCount || 0) <= 10).length,
lowUsage: tags.filter((t) => (t.linkCount || 0) > 0 && (t.linkCount || 0) < 5).length,
unused: tags.filter((t) => (t.linkCount || 0) === 0).length,
};
// Calculate engagement rate
const avgEngagement =
usedTags > 0 && totalClicks > 0 ? ((totalClicks / (usedTags * 100)) * 100).toFixed(1) : '0';
// Tag color distribution
const colorDistribution = tags.reduce(
(acc, tag) => {
const color = tag.color || 'default';
acc[color] = (acc[color] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
return {
totalTags,
usedTags,
unusedTags: totalTags - usedTags,
totalClicks,
avgLinksPerTag,
avgEngagement,
topByClicks,
mostUsedTags,
recentTags,
distribution,
colorDistribution,
};
});
function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
}
function getTagColorClass(color?: string): string {
const colorMap: Record<string, string> = {
red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
orange: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
purple: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
pink: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400',
gray: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
};
return colorMap[color || 'gray'] || colorMap.gray;
}
function getUsageLevel(linkCount: number): { label: string; color: string; icon: any } {
if (linkCount > 10)
return { label: 'High', color: 'text-green-600 dark:text-green-400', icon: TrendingUp };
if (linkCount >= 5)
return { label: 'Medium', color: 'text-blue-600 dark:text-blue-400', icon: Activity };
if (linkCount > 0)
return { label: 'Low', color: 'text-amber-600 dark:text-amber-400', icon: TrendingDown };
return { label: 'Unused', color: 'text-gray-600 dark:text-gray-400', icon: Minus };
}
</script>
<div class="space-y-6">
<!-- Key Metrics Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Total Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Hash class="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{stats().totalTags}</p>
<p class="mt-1 text-sm text-theme-text-muted">Total Tags</p>
</div>
</div>
<!-- Active Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Zap class="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{stats().usedTags}</p>
<p class="mt-1 text-sm text-theme-text-muted">Active Tags</p>
</div>
</div>
<!-- Total Clicks via Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<MousePointer class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{formatNumber(stats().totalClicks)}</p>
<p class="mt-1 text-sm text-theme-text-muted">Tag Clicks</p>
</div>
</div>
<!-- Average Links per Tag -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<Layers class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
</div>
<div class="mt-3">
<p class="text-2xl font-bold text-theme-text">{stats().avgLinksPerTag}</p>
<p class="mt-1 text-sm text-theme-text-muted">Links per Tag</p>
</div>
</div>
</div>
<!-- Usage Distribution and Color Distribution -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Tag Usage Distribution -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<BarChart3 class="h-5 w-5 text-theme-text-muted" />
Usage Distribution
</h3>
<div class="space-y-4">
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="flex items-center gap-2 text-theme-text">
<TrendingUp class="h-4 w-4 text-green-600 dark:text-green-400" />
High Usage (10+ links)
</span>
<span class="font-semibold text-theme-text-muted">{stats().distribution.highUsage}</span
>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-green-500"
style="width: {((stats().distribution.highUsage / stats().totalTags) * 100).toFixed(
0
)}%"
></div>
</div>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="flex items-center gap-2 text-theme-text">
<Activity class="h-4 w-4 text-blue-600 dark:text-blue-400" />
Medium Usage (5-10 links)
</span>
<span class="font-semibold text-theme-text-muted"
>{stats().distribution.mediumUsage}</span
>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-blue-500"
style="width: {((stats().distribution.mediumUsage / stats().totalTags) * 100).toFixed(
0
)}%"
></div>
</div>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="flex items-center gap-2 text-theme-text">
<TrendingDown class="h-4 w-4 text-amber-600 dark:text-amber-400" />
Low Usage (1-4 links)
</span>
<span class="font-semibold text-theme-text-muted">{stats().distribution.lowUsage}</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-amber-500"
style="width: {((stats().distribution.lowUsage / stats().totalTags) * 100).toFixed(
0
)}%"
></div>
</div>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span class="flex items-center gap-2 text-theme-text">
<Minus class="h-4 w-4 text-gray-600 dark:text-gray-400" />
Unused
</span>
<span class="font-semibold text-theme-text-muted">{stats().distribution.unused}</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-gray-500"
style="width: {((stats().distribution.unused / stats().totalTags) * 100).toFixed(0)}%"
></div>
</div>
</div>
</div>
</div>
<!-- Tag Color Distribution -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Tag class="h-5 w-5 text-theme-text-muted" />
Color Distribution
</h3>
<div class="grid grid-cols-4 gap-3">
{#each Object.entries(stats().colorDistribution).slice(0, 8) as [color, count]}
<div class="text-center">
<div
class="mx-auto mb-2 h-12 w-12 rounded-lg {getTagColorClass(
color
)} flex items-center justify-center"
>
<span class="text-xs font-bold">{count}</span>
</div>
<p class="text-xs capitalize text-theme-text-muted">{color}</p>
</div>
{/each}
</div>
</div>
</div>
<!-- Top Tags Tables -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Top Performing Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Award class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
Top by Clicks
</h3>
<div class="space-y-3">
{#each stats().topByClicks as tag, i}
<div
class="flex items-center justify-between rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex items-center gap-3">
<span class="w-6 text-sm font-medium text-theme-text-muted">#{i + 1}</span>
<span
class="rounded-full px-2 py-1 text-xs font-medium {getTagColorClass(tag.color)}"
>
{tag.name}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-theme-text"
>{formatNumber(tag.totalClicks || 0)}</span
>
<span class="text-xs text-theme-text-muted">clicks</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No tags with clicks yet</p>
{/each}
</div>
</div>
<!-- Most Used Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Target class="h-5 w-5 text-blue-600 dark:text-blue-400" />
Most Used
</h3>
<div class="space-y-3">
{#each stats().mostUsedTags as tag}
{@const usage = getUsageLevel(tag.linkCount || 0)}
<div
class="flex items-center justify-between rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-1 text-xs font-medium {getTagColorClass(tag.color)}"
>
{tag.name}
</span>
</div>
<div class="flex items-center gap-2">
<svelte:component this={usage.icon} class="h-3 w-3 {usage.color}" />
<span class="text-sm font-semibold text-theme-text">{tag.linkCount || 0}</span>
<span class="text-xs text-theme-text-muted">links</span>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No tags used yet</p>
{/each}
</div>
</div>
<!-- Recently Created Tags -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Hash class="h-5 w-5 text-purple-600 dark:text-purple-400" />
Recently Created
</h3>
<div class="space-y-3">
{#each stats().recentTags as tag}
<div
class="flex items-center justify-between rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-1 text-xs font-medium {getTagColorClass(tag.color)}"
>
{tag.name}
</span>
</div>
<p class="text-xs text-theme-text-muted">
{new Date(tag.created).toLocaleDateString()}
</p>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No tags created yet</p>
{/each}
</div>
</div>
</div>
<!-- Engagement Insights -->
<div class="rounded-lg border border-theme-border bg-white p-6 dark:bg-gray-800">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-theme-text">
<Activity class="h-5 w-5 text-theme-text-muted" />
Tag Performance Insights
</h3>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="text-center">
<p class="text-3xl font-bold text-theme-text">
{((stats().usedTags / stats().totalTags) * 100).toFixed(0)}%
</p>
<p class="mt-1 text-sm text-theme-text-muted">Tag Utilization Rate</p>
</div>
<div class="text-center">
<p class="text-3xl font-bold text-theme-text">{stats().avgEngagement}%</p>
<p class="mt-1 text-sm text-theme-text-muted">Average Engagement</p>
</div>
<div class="text-center">
<p class="text-3xl font-bold text-theme-text">
{stats().usedTags > 0 ? Math.floor(stats().totalClicks / stats().usedTags) : 0}
</p>
<p class="mt-1 text-sm text-theme-text-muted">Clicks per Active Tag</p>
</div>
</div>
</div>
</div>

View file

@ -1,322 +0,0 @@
<script lang="ts">
import { unifiedCardService } from '$lib/services/unifiedCardService';
import type { Card } from '$lib/components/cards/types';
import { isBeginnerCard } from '$lib/components/cards/types';
import CardRenderer from '$lib/components/cards/CardRenderer.svelte';
interface Props {
card: Card | null;
show: boolean;
onClose: () => void;
onSuccess: (template: Card) => void;
}
let { card, show, onClose, onSuccess }: Props = $props();
// Form state
let templateName = $state('');
let templateDescription = $state('');
let templateCategory = $state('');
let templateTags = $state('');
let templateVisibility = $state<'public' | 'unlisted'>('public');
let allowDuplication = $state(true);
let loading = $state(false);
let error = $state('');
// Available categories
const categories = [
{ value: '', label: 'Select Category' },
{ value: 'personal', label: 'Personal' },
{ value: 'creative', label: 'Creative' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'social', label: 'Social' },
{ value: 'portfolio', label: 'Portfolio' },
{ value: 'other', label: 'Other' },
];
// Reset form when card changes
$effect(() => {
if (card) {
templateName = card.metadata?.name ? `${card.metadata.name} Template` : '';
templateDescription = card.metadata?.description || '';
templateCategory = '';
templateTags = '';
templateVisibility = 'public';
allowDuplication = true;
error = '';
}
});
// Handle form submission
async function handleSubmit() {
if (!card) return;
// Validation
if (!templateName.trim()) {
error = 'Template name is required';
return;
}
if (!templateCategory) {
error = 'Please select a category';
return;
}
loading = true;
error = '';
try {
const tags = templateTags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
const template = await unifiedCardService.createTemplate(card.id!, {
name: templateName.trim(),
description: templateDescription.trim() || undefined,
category: templateCategory,
tags: tags.length > 0 ? tags : undefined,
visibility: templateVisibility,
allow_duplication: allowDuplication,
});
if (template) {
onSuccess(template);
onClose();
} else {
error = 'Failed to create template. Please try again.';
}
} catch (err) {
console.error('Error creating template:', err);
error = 'An error occurred while creating the template.';
} finally {
loading = false;
}
}
// Handle backdrop click
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
// Close modal on escape key
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
{#if show && card}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div
class="max-h-[90vh] w-full max-w-2xl overflow-auto rounded-lg bg-theme-surface shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<!-- Modal Header -->
<div class="flex items-center justify-between border-b border-theme-border p-4">
<div>
<h2 id="modal-title" class="text-xl font-bold text-theme-text">Create Template</h2>
<p class="text-sm text-theme-text-muted">Share your card design with the community</p>
</div>
<button
onclick={onClose}
class="rounded-lg p-2 transition-colors hover:bg-theme-surface-hover"
aria-label="Close modal"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<div class="grid gap-6 md:grid-cols-2">
<!-- Form -->
<div class="space-y-4">
<!-- Template Name -->
<div>
<label for="template-name" class="mb-1 block text-sm font-medium text-theme-text">
Template Name *
</label>
<input
id="template-name"
type="text"
bind:value={templateName}
placeholder="Enter a descriptive name"
class="w-full rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
required
/>
</div>
<!-- Description -->
<div>
<label
for="template-description"
class="mb-1 block text-sm font-medium text-theme-text"
>
Description
</label>
<textarea
id="template-description"
bind:value={templateDescription}
placeholder="Describe what makes this template special"
rows="3"
class="w-full resize-none rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
></textarea>
</div>
<!-- Category -->
<div>
<label for="template-category" class="mb-1 block text-sm font-medium text-theme-text">
Category *
</label>
<select
id="template-category"
bind:value={templateCategory}
class="w-full rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
required
>
{#each categories as category}
<option value={category.value}>{category.label}</option>
{/each}
</select>
</div>
<!-- Tags -->
<div>
<label for="template-tags" class="mb-1 block text-sm font-medium text-theme-text">
Tags
</label>
<input
id="template-tags"
type="text"
bind:value={templateTags}
placeholder="minimal, professional, clean (comma separated)"
class="w-full rounded-lg border border-theme-border bg-theme-background px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
/>
<p class="mt-1 text-xs text-theme-text-muted">
Separate tags with commas. Help others find your template!
</p>
</div>
<!-- Visibility -->
<div>
<label class="mb-1 block text-sm font-medium text-theme-text"> Visibility </label>
<div class="space-y-2">
<label class="flex items-center">
<input type="radio" bind:group={templateVisibility} value="public" class="mr-2" />
<span class="text-sm text-theme-text">Public - Anyone can find and use</span>
</label>
<label class="flex items-center">
<input
type="radio"
bind:group={templateVisibility}
value="unlisted"
class="mr-2"
/>
<span class="text-sm text-theme-text"
>Unlisted - Only accessible via direct link</span
>
</label>
</div>
</div>
<!-- Allow Duplication -->
<div>
<label class="flex items-center">
<input type="checkbox" bind:checked={allowDuplication} class="mr-2" />
<span class="text-sm text-theme-text">Allow others to duplicate this template</span>
</label>
</div>
<!-- Error Message -->
{#if error}
<div class="rounded-lg border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-700">{error}</p>
</div>
{/if}
</div>
<!-- Preview -->
<div>
<h3 class="mb-3 text-sm font-medium text-theme-text">Preview</h3>
<div class="rounded-lg border border-theme-border bg-theme-background p-4">
<CardRenderer
card={{
id: card.id,
config: card.config,
metadata: card.metadata || {},
constraints: card.constraints || {},
}}
readonly={true}
compact={true}
/>
</div>
<!-- Template Info -->
<div class="mt-4 space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-theme-text-muted">Mode:</span>
<span class="capitalize text-theme-text">{card.config?.mode || 'Unknown'}</span>
</div>
{#if isBeginnerCard(card.config)}
<div class="flex justify-between">
<span class="text-theme-text-muted">Modules:</span>
<span class="text-theme-text">{card.config.modules.length}</span>
</div>
{/if}
{#if card.variant}
<div class="flex justify-between">
<span class="text-theme-text-muted">Variant:</span>
<span class="capitalize text-theme-text">{card.variant}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="border-t border-theme-border p-4">
<div class="flex justify-end gap-3">
<button
onclick={onClose}
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
disabled={loading}
>
Cancel
</button>
<button
onclick={handleSubmit}
class="hover:bg-theme-primary/90 flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading || !templateName.trim() || !templateCategory}
>
{#if loading}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
{/if}
{loading ? 'Creating...' : 'Create Template'}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -1,227 +0,0 @@
<script lang="ts">
import type { Card } from '$lib/components/cards/types';
import CardRenderer from '$lib/components/cards/CardRenderer.svelte';
interface Props {
template: Card;
onUse?: (template: Card) => void;
onPreview?: (template: Card) => void;
onLike?: (template: Card) => void;
onDuplicate?: (template: Card) => void;
onShare?: (template: Card) => void;
compact?: boolean;
}
let {
template,
onUse = () => {},
onPreview = () => {},
onLike = () => {},
onDuplicate = () => {},
onShare = () => {},
compact = false,
}: Props = $props();
</script>
<div
class="group relative overflow-hidden rounded-lg border border-theme-border bg-theme-surface transition-all hover:shadow-lg"
>
<!-- Template Preview -->
<div
class="relative {compact
? 'h-32'
: 'h-48'} from-theme-primary/10 to-theme-accent/10 overflow-hidden bg-gradient-to-br"
>
<div class="p-2 {compact ? 'scale-75' : ''}">
<CardRenderer
card={{
id: template.id,
config: template.config,
metadata: template.metadata || {},
constraints: template.constraints || {},
}}
editable={false}
/>
</div>
<!-- Badges -->
<div class="absolute left-2 top-2 flex gap-1">
{#if template.is_featured}
<span class="rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white">
Featured
</span>
{/if}
</div>
<div class="absolute right-2 top-2">
{#if template.category}
<span
class="rounded-full bg-white/90 px-2 py-1 text-xs font-medium capitalize text-gray-700 backdrop-blur-sm"
>
{template.category}
</span>
{/if}
</div>
<!-- Hover Overlay -->
<div
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => onPreview(template)}
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100"
>
Quick Preview
</button>
</div>
</div>
<!-- Template Info -->
<div class="space-y-3 p-4">
<div>
<h3 class="font-semibold text-theme-text {compact ? 'text-sm' : 'text-base'}">
{template.metadata?.name || 'Unnamed Template'}
</h3>
{#if template.metadata?.description && !compact}
<p class="mt-1 line-clamp-2 text-sm text-theme-text-muted">
{template.metadata.description}
</p>
{/if}
</div>
<!-- Tags -->
{#if template.tags && template.tags.length > 0 && !compact}
<div class="flex flex-wrap gap-1">
{#each template.tags.slice(0, 3) as tag}
<span class="rounded-full bg-theme-surface-hover px-2 py-0.5 text-xs text-theme-text">
{tag}
</span>
{/each}
{#if template.tags.length > 3}
<span
class="rounded-full bg-theme-surface-hover px-2 py-0.5 text-xs text-theme-text-muted"
>
+{template.tags.length - 3}
</span>
{/if}
</div>
{/if}
<!-- Stats -->
<div class="flex items-center justify-between text-xs text-theme-text-muted">
<div class="flex items-center gap-3">
<!-- Usage Count -->
<span class="flex items-center gap-1" title="Times used">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
/>
</svg>
{template.usage_count || 0}
</span>
<!-- Likes -->
<span class="flex items-center gap-1" title="Likes">
<svg class="h-3.5 w-3.5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clip-rule="evenodd"
/>
</svg>
{template.likes_count || 0}
</span>
</div>
<!-- Created Date -->
{#if template.created && !compact}
<span>{new Date(template.created).toLocaleDateString()}</span>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-2 pt-2">
{#if !compact}
<button
onclick={() => onPreview(template)}
class="flex-1 rounded-lg bg-theme-surface-hover px-3 py-2 text-sm font-medium text-theme-text hover:bg-theme-border"
>
Preview
</button>
{/if}
<button
onclick={() => onUse(template)}
class="{compact
? 'flex-1'
: 'flex-1'} hover:bg-theme-primary/90 rounded-lg bg-theme-primary px-3 py-2 text-sm font-medium text-white"
>
Use Template
</button>
</div>
<!-- Additional Actions -->
{#if !compact}
<div class="flex items-center justify-between border-t border-theme-border pt-2">
<!-- Like Button -->
<button
onclick={() => onLike(template)}
class="flex items-center gap-1 rounded p-1.5 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-red-500"
title="Like this template"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Quick Actions -->
<div class="flex gap-1">
<button
onclick={() => onDuplicate(template)}
title="Add to my collection"
class="rounded p-1.5 transition-colors hover:bg-theme-surface-hover"
>
<svg
class="h-4 w-4 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V2"
/>
</svg>
</button>
<button
onclick={() => onShare(template)}
title="Share template"
class="rounded p-1.5 transition-colors hover:bg-theme-surface-hover"
>
<svg
class="h-4 w-4 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m9.032 4.026a3 3 0 10-4.516-3.95l-4.516 3.95a3 3 0 000 4.243l4.516 3.95a3 3 0 104.516-3.95l-4.516-3.95z"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>
</div>

View file

@ -1,267 +0,0 @@
<script lang="ts">
import type { Card } from '$lib/components/cards/types';
import { isBeginnerCard } from '$lib/components/cards/types';
import CardRenderer from '$lib/components/cards/CardRenderer.svelte';
interface Props {
template: Card | null;
show: boolean;
onClose: () => void;
onUse: (template: Card) => void;
onDuplicate: (template: Card) => void;
onLike?: (template: Card) => void;
}
let { template, show, onClose, onUse, onDuplicate, onLike = () => {} }: Props = $props();
// Close modal on escape key
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
// Handle backdrop click
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
</script>
{#if show && template}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-lg bg-theme-surface shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<!-- Modal Header -->
<div
class="sticky top-0 z-10 flex items-center justify-between border-b border-theme-border bg-theme-surface p-4"
>
<div>
<h2 id="modal-title" class="text-xl font-bold text-theme-text">
{template.metadata?.name || 'Template Preview'}
</h2>
{#if template.metadata?.description}
<p class="text-sm text-theme-text-muted">{template.metadata.description}</p>
{/if}
</div>
<button
onclick={onClose}
class="rounded-lg p-2 transition-colors hover:bg-theme-surface-hover"
aria-label="Close modal"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="space-y-6 p-6">
<!-- Template Info Bar -->
<div class="flex items-center justify-between rounded-lg bg-theme-background p-4">
<div class="flex items-center gap-4">
{#if template.category}
<span class="rounded-full bg-theme-primary px-3 py-1 text-sm font-medium text-white">
{template.category}
</span>
{/if}
{#if template.is_featured}
<span class="rounded-full bg-yellow-500 px-3 py-1 text-sm font-medium text-white">
Featured
</span>
{/if}
</div>
<div class="flex items-center gap-4 text-sm text-theme-text-muted">
<span class="flex items-center gap-1">
<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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
/>
</svg>
{template.usage_count || 0} uses
</span>
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clip-rule="evenodd"
/>
</svg>
{template.likes_count || 0} likes
</span>
</div>
</div>
<!-- Live Preview -->
<div>
<h3 class="mb-3 font-semibold text-theme-text">Live Preview</h3>
<div
class="rounded-lg border-2 border-dashed border-theme-border bg-theme-background p-6"
>
<div class="flex justify-center">
<div class="w-full max-w-md">
<CardRenderer
card={{
id: template.id,
config: template.config,
metadata: template.metadata || {},
constraints: template.constraints || {},
}}
editable={false}
/>
</div>
</div>
</div>
</div>
<!-- Template Details -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Configuration Info -->
<div>
<h3 class="mb-3 font-semibold text-theme-text">Configuration</h3>
<div class="space-y-2 rounded-lg bg-theme-background p-4">
<div class="flex justify-between">
<span class="text-sm text-theme-text-muted">Mode:</span>
<span class="text-sm capitalize text-theme-text"
>{template.config?.mode || 'Unknown'}</span
>
</div>
{#if isBeginnerCard(template.config)}
<div class="flex justify-between">
<span class="text-sm text-theme-text-muted">Modules:</span>
<span class="text-sm text-theme-text">{template.config.modules.length}</span>
</div>
{/if}
{#if template.variant}
<div class="flex justify-between">
<span class="text-sm text-theme-text-muted">Variant:</span>
<span class="text-sm capitalize text-theme-text">{template.variant}</span>
</div>
{/if}
{#if template.created}
<div class="flex justify-between">
<span class="text-sm text-theme-text-muted">Created:</span>
<span class="text-sm text-theme-text"
>{new Date(template.created).toLocaleDateString()}</span
>
</div>
{/if}
</div>
</div>
<!-- Stats -->
<div>
<h3 class="mb-3 font-semibold text-theme-text">Statistics</h3>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg bg-theme-background p-4 text-center">
<p class="text-2xl font-bold text-theme-text">{template.usage_count || 0}</p>
<p class="text-sm text-theme-text-muted">Times Used</p>
</div>
<div class="rounded-lg bg-theme-background p-4 text-center">
<p class="text-2xl font-bold text-theme-text">{template.likes_count || 0}</p>
<p class="text-sm text-theme-text-muted">Likes</p>
</div>
</div>
</div>
</div>
<!-- Module Details -->
{#if isBeginnerCard(template.config) && template.config.modules.length > 0}
<div>
<h3 class="mb-3 font-semibold text-theme-text">Included Modules</h3>
<div class="grid gap-2 sm:grid-cols-2">
{#each template.config.modules as module, index}
<div
class="flex items-center gap-3 rounded-lg border border-theme-border bg-theme-background p-3"
>
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-primary text-xs font-medium text-white"
>
{index + 1}
</span>
<div class="flex-1">
<p class="font-medium capitalize text-theme-text">{module.type} Module</p>
{#if module.props?.title}
<p class="text-xs text-theme-text-muted">{module.props.title}</p>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Tags -->
{#if template.tags && template.tags.length > 0}
<div>
<h3 class="mb-2 font-semibold text-theme-text">Tags</h3>
<div class="flex flex-wrap gap-2">
{#each template.tags as tag}
<span class="rounded-full bg-theme-surface-hover px-3 py-1 text-sm text-theme-text">
{tag}
</span>
{/each}
</div>
</div>
{/if}
</div>
<!-- Modal Footer -->
<div class="sticky bottom-0 border-t border-theme-border bg-theme-surface p-4">
<div class="flex gap-3">
<button
onclick={() => onLike(template)}
class="flex items-center gap-2 rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text transition-colors hover:bg-theme-border"
>
<svg class="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clip-rule="evenodd"
/>
</svg>
Like
</button>
<button
onclick={() => {
onDuplicate(template);
onClose();
}}
class="flex-1 rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text transition-colors hover:bg-theme-border"
>
Add to Collection
</button>
<button
onclick={() => {
onUse(template);
onClose();
}}
class="hover:bg-theme-primary/90 flex-1 rounded-lg bg-theme-primary px-4 py-2 font-medium text-white transition-colors"
>
Use This Template
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -1,186 +0,0 @@
import {
blogSchema,
authorSchema,
type BlogPost,
type Author,
type BlogPostWithMeta,
} from '../../content/config';
import { error } from '@sveltejs/kit';
import { dev } from '$app/environment';
// Cache für Performance
const contentCache = new Map<string, any>();
const CACHE_DURATION = dev ? 0 : 1000 * 60 * 5; // 5 Min in Production
export async function getCollection<T>(collection: 'blog' | 'authors'): Promise<T[]> {
const cacheKey = `collection-${collection}`;
const cached = contentCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data;
}
let items: T[] = [];
if (collection === 'blog') {
items = (await getBlogPosts()) as T[];
} else if (collection === 'authors') {
items = (await getAuthors()) as T[];
}
contentCache.set(cacheKey, {
data: items,
timestamp: Date.now(),
});
return items;
}
async function getBlogPosts(): Promise<BlogPostWithMeta[]> {
const postModules = import.meta.glob('/src/content/blog/**/*.md');
const posts: BlogPostWithMeta[] = [];
for (const [path, resolver] of Object.entries(postModules)) {
// Skip drafts in production
if (!dev && path.includes('_drafts')) continue;
try {
const module = (await resolver()) as any;
const { metadata } = module;
// Validiere mit Zod Schema
const validatedPost = blogSchema.parse(metadata);
// Skip drafts based on frontmatter
if (!dev && validatedPost.draft) continue;
// Füge zusätzliche Metadaten hinzu
const slug = path
.split('/')
.pop()
?.replace('.md', '')
.replace(/^\d{4}-\d{2}-\d{2}-/, ''); // Datum aus Filename entfernen
if (!slug) continue;
posts.push({
...validatedPost,
slug,
readingTime: calculateReadingTime(module.default?.default || module.default || ''),
path,
});
} catch (err) {
console.error(`Error loading ${path}:`, err);
if (dev) throw err; // In Dev Fehler werfen
}
}
// Sortiere nach Datum (neueste zuerst)
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
async function getAuthors(): Promise<Author[]> {
const authorModules = import.meta.glob('/src/content/authors/*.json', {
import: 'default',
});
const authors: Author[] = [];
for (const [path, resolver] of Object.entries(authorModules)) {
try {
const data = (await resolver()) as any;
const validated = authorSchema.parse(data);
authors.push(validated);
} catch (err) {
console.error(`Error loading author ${path}:`, err);
}
}
return authors;
}
export async function getEntry<T>(collection: 'blog' | 'authors', slug: string): Promise<T | null> {
const items = await getCollection<T>(collection);
if (collection === 'blog') {
return (items as any[]).find((item) => item.slug === slug) || null;
}
return (items as any[]).find((item) => item.id === slug) || null;
}
// Helper Functions
function calculateReadingTime(content: string): number {
const wordsPerMinute = 200;
const text = content.replace(/<[^>]*>/g, ''); // Strip HTML
const words = text.split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
}
// Blog-spezifische Helpers
export async function getBlogPostsByTag(tag: string): Promise<BlogPostWithMeta[]> {
const posts = await getCollection<BlogPostWithMeta>('blog');
return posts.filter((post) => post.tags.includes(tag));
}
export async function getBlogPostsByCategory(category: string): Promise<BlogPostWithMeta[]> {
const posts = await getCollection<BlogPostWithMeta>('blog');
return posts.filter((post) => post.category === category);
}
export async function getFeaturedPosts(): Promise<BlogPostWithMeta[]> {
const posts = await getCollection<BlogPostWithMeta>('blog');
return posts.filter((post) => post.featured);
}
export async function getRelatedPosts(currentSlug: string, limit = 3): Promise<BlogPostWithMeta[]> {
const posts = await getCollection<BlogPostWithMeta>('blog');
const current = posts.find((p) => p.slug === currentSlug);
if (!current) return [];
// Finde Posts mit ähnlichen Tags
const related = posts
.filter((p) => p.slug !== currentSlug)
.map((post) => ({
post,
score: post.tags.filter((tag) => current.tags.includes(tag)).length,
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.post);
return related;
}
// Categories und Tags
export async function getAllCategories() {
const posts = await getCollection<BlogPostWithMeta>('blog');
const categories = new Map<string, number>();
posts.forEach((post) => {
categories.set(post.category, (categories.get(post.category) || 0) + 1);
});
return Array.from(categories.entries()).map(([name, count]) => ({
name,
slug: name.toLowerCase(),
count,
}));
}
export async function getAllTags() {
const posts = await getCollection<BlogPostWithMeta>('blog');
const tags = new Map<string, number>();
posts.forEach((post) => {
post.tags.forEach((tag) => {
tags.set(tag, (tags.get(tag) || 0) + 1);
});
});
return Array.from(tags.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}

View file

@ -1,24 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Get connection string from environment
const connectionString =
process.env.DATABASE_URL || 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev';
// Connection pool for queries
export const client = postgres(connectionString, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
// Drizzle instance with schema
export const db = drizzle(client, { schema });
// Types for convenience
export type DB = typeof db;
export type TX = Parameters<Parameters<typeof db.transaction>[0]>[0];
// Export all schema tables and relations for easy access
export * from './schema';

View file

@ -1,413 +0,0 @@
import {
pgTable,
uuid,
text,
boolean,
integer,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// ============================================
// Users Table
// ============================================
export const users = pgTable(
'users',
{
id: uuid('id').primaryKey().defaultRandom(),
externalAuthId: text('external_auth_id').unique(), // For external auth provider
email: text('email').unique().notNull(),
username: text('username').unique().notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
bio: text('bio'),
location: text('location'),
website: text('website'),
github: text('github'),
twitter: text('twitter'),
linkedin: text('linkedin'),
instagram: text('instagram'),
publicProfile: boolean('public_profile').default(false),
showClickStats: boolean('show_click_stats').default(true),
emailNotifications: boolean('email_notifications').default(true),
defaultExpiry: integer('default_expiry'),
profileBackground: text('profile_background'),
verified: boolean('verified').default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
emailIdx: index('users_email_idx').on(table.email),
usernameIdx: index('users_username_idx').on(table.username),
externalAuthIdIdx: index('users_external_auth_id_idx').on(table.externalAuthId),
})
);
// ============================================
// Accounts Table (Business/Team Accounts)
// ============================================
export const accounts = pgTable(
'accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
owner: uuid('owner')
.references(() => users.id)
.notNull(),
isActive: boolean('is_active').default(true),
planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default('free'),
settings: jsonb('settings'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
ownerIdx: index('accounts_owner_idx').on(table.owner),
})
);
// ============================================
// Workspaces Table
// ============================================
export const workspaces = pgTable(
'workspaces',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').unique().notNull(),
type: text('type', { enum: ['personal', 'team'] }).notNull(),
owner: uuid('owner')
.references(() => users.id)
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
slugIdx: index('workspaces_slug_idx').on(table.slug),
ownerIdx: index('workspaces_owner_idx').on(table.owner),
})
);
// ============================================
// Links Table
// ============================================
export const links = pgTable(
'links',
{
id: uuid('id').primaryKey().defaultRandom(),
shortCode: text('short_code').unique().notNull(),
customCode: text('custom_code'),
originalUrl: text('original_url').notNull(),
title: text('title'),
description: text('description'),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
isActive: boolean('is_active').default(true),
password: text('password'), // hashed
maxClicks: integer('max_clicks'),
expiresAt: timestamp('expires_at'),
clickCount: integer('click_count').default(0),
qrCodeUrl: text('qr_code_url'), // File Storage URL
tags: jsonb('tags').$type<string[]>(),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
accountOwner: uuid('account_owner').references(() => accounts.id),
workspaceId: uuid('workspace_id').references(() => workspaces.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('links_user_id_idx').on(table.userId),
shortCodeIdx: index('links_short_code_idx').on(table.shortCode),
workspaceIdIdx: index('links_workspace_id_idx').on(table.workspaceId),
accountOwnerIdx: index('links_account_owner_idx').on(table.accountOwner),
isActiveIdx: index('links_is_active_idx').on(table.isActive),
})
);
// ============================================
// Clicks Table (Analytics)
// ============================================
export const clicks = pgTable(
'clicks',
{
id: uuid('id').primaryKey().defaultRandom(),
linkId: uuid('link_id')
.references(() => links.id, { onDelete: 'cascade' })
.notNull(),
ipHash: text('ip_hash'),
userAgent: text('user_agent'),
referer: text('referer'),
browser: text('browser'),
deviceType: text('device_type'),
os: text('os'),
country: text('country'),
city: text('city'),
clickedAt: timestamp('clicked_at').defaultNow().notNull(),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
linkIdIdx: index('clicks_link_id_idx').on(table.linkId),
clickedAtIdx: index('clicks_clicked_at_idx').on(table.clickedAt),
countryIdx: index('clicks_country_idx').on(table.country),
})
);
// ============================================
// Tags Table
// ============================================
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull(),
color: text('color'),
icon: text('icon'),
isPublic: boolean('is_public').default(false),
usageCount: integer('usage_count').default(0),
userId: uuid('user_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('tags_user_id_idx').on(table.userId),
slugIdx: index('tags_slug_idx').on(table.slug),
})
);
// ============================================
// Link-Tags Junction Table
// ============================================
export const linkTags = pgTable(
'link_tags',
{
id: uuid('id').primaryKey().defaultRandom(),
linkId: uuid('link_id')
.references(() => links.id, { onDelete: 'cascade' })
.notNull(),
tagId: uuid('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
linkIdIdx: index('link_tags_link_id_idx').on(table.linkId),
tagIdIdx: index('link_tags_tag_id_idx').on(table.tagId),
uniqueLinkTag: index('link_tags_unique_idx').on(table.linkId, table.tagId),
})
);
// ============================================
// Notifications Table
// ============================================
export const notifications = pgTable(
'notifications',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
type: text('type').notNull(),
title: text('title').notNull(),
message: text('message').notNull(),
data: jsonb('data'),
read: boolean('read').default(false),
actionUrl: text('action_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('notifications_user_id_idx').on(table.userId),
readIdx: index('notifications_read_idx').on(table.read),
})
);
// ============================================
// Shared Access Table (Team Invitations)
// ============================================
export const sharedAccess = pgTable(
'shared_access',
{
id: uuid('id').primaryKey().defaultRandom(),
owner: uuid('owner')
.references(() => users.id)
.notNull(),
userId: uuid('user_id').references(() => users.id),
permissions: jsonb('permissions'),
invitationStatus: text('invitation_status', {
enum: ['pending', 'accepted', 'declined'],
}).default('pending'),
acceptedAt: timestamp('accepted_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
ownerIdx: index('shared_access_owner_idx').on(table.owner),
userIdIdx: index('shared_access_user_id_idx').on(table.userId),
statusIdx: index('shared_access_status_idx').on(table.invitationStatus),
})
);
// ============================================
// Pending Invitations Table
// ============================================
export const pendingInvitations = pgTable(
'pending_invitations',
{
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull(),
token: text('token').unique().notNull(),
owner: uuid('owner')
.references(() => users.id)
.notNull(),
expiresAt: timestamp('expires_at').notNull(),
acceptedAt: timestamp('accepted_at'),
acceptedBy: uuid('accepted_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
emailIdx: index('pending_invitations_email_idx').on(table.email),
tokenIdx: index('pending_invitations_token_idx').on(table.token),
ownerIdx: index('pending_invitations_owner_idx').on(table.owner),
})
);
// ============================================
// Feature Requests Table
// ============================================
export const featureRequests = pgTable(
'feature_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description').notNull(),
userId: uuid('user_id')
.references(() => users.id)
.notNull(),
status: text('status', {
enum: ['pending', 'reviewing', 'planned', 'completed', 'rejected'],
}).default('pending'),
voteCount: integer('vote_count').default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('feature_requests_user_id_idx').on(table.userId),
statusIdx: index('feature_requests_status_idx').on(table.status),
voteCountIdx: index('feature_requests_vote_count_idx').on(table.voteCount),
})
);
// ============================================
// Feature Votes Table
// ============================================
export const featureVotes = pgTable(
'feature_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
featureRequestId: uuid('feature_request_id')
.references(() => featureRequests.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
featureRequestIdIdx: index('feature_votes_feature_request_id_idx').on(table.featureRequestId),
userIdIdx: index('feature_votes_user_id_idx').on(table.userId),
uniqueVote: index('feature_votes_unique_idx').on(table.featureRequestId, table.userId),
})
);
// ============================================
// Folders Table (minimal usage, keep for future)
// ============================================
export const folders = pgTable(
'folders',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('folders_user_id_idx').on(table.userId),
})
);
// ============================================
// Relations (for Drizzle Relational Queries)
// ============================================
export const usersRelations = relations(users, ({ many }) => ({
links: many(links),
tags: many(tags),
notifications: many(notifications),
ownedAccounts: many(accounts),
ownedWorkspaces: many(workspaces),
featureRequests: many(featureRequests),
featureVotes: many(featureVotes),
folders: many(folders),
}));
export const linksRelations = relations(links, ({ one, many }) => ({
user: one(users, { fields: [links.userId], references: [users.id] }),
account: one(accounts, { fields: [links.accountOwner], references: [accounts.id] }),
workspace: one(workspaces, { fields: [links.workspaceId], references: [workspaces.id] }),
clicks: many(clicks),
linkTags: many(linkTags),
}));
export const clicksRelations = relations(clicks, ({ one }) => ({
link: one(links, { fields: [clicks.linkId], references: [links.id] }),
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
user: one(users, { fields: [tags.userId], references: [users.id] }),
linkTags: many(linkTags),
}));
export const linkTagsRelations = relations(linkTags, ({ one }) => ({
link: one(links, { fields: [linkTags.linkId], references: [links.id] }),
tag: one(tags, { fields: [linkTags.tagId], references: [tags.id] }),
}));
export const accountsRelations = relations(accounts, ({ one, many }) => ({
owner: one(users, { fields: [accounts.owner], references: [users.id] }),
links: many(links),
}));
export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
owner: one(users, { fields: [workspaces.owner], references: [users.id] }),
links: many(links),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, { fields: [notifications.userId], references: [users.id] }),
}));
export const featureRequestsRelations = relations(featureRequests, ({ one, many }) => ({
user: one(users, { fields: [featureRequests.userId], references: [users.id] }),
votes: many(featureVotes),
}));
export const featureVotesRelations = relations(featureVotes, ({ one }) => ({
featureRequest: one(featureRequests, {
fields: [featureVotes.featureRequestId],
references: [featureRequests.id],
}),
user: one(users, { fields: [featureVotes.userId], references: [users.id] }),
}));
export const foldersRelations = relations(folders, ({ one }) => ({
user: one(users, { fields: [folders.userId], references: [users.id] }),
}));

View file

@ -1,222 +0,0 @@
import { Resend } from 'resend';
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';
// Initialize Resend client
const resend = new Resend(env.RESEND_API_KEY);
const FROM_EMAIL = env.RESEND_FROM_EMAIL || 'noreply@ulo.ad';
const APP_URL = publicEnv.PUBLIC_APP_URL || 'https://ulo.ad';
/**
* Send a team invitation email
*/
export async function sendTeamInvitationEmail(
recipientEmail: string,
inviterName: string,
inviteToken: string
): Promise<boolean> {
try {
const inviteUrl = `${APP_URL}/register?invite=${inviteToken}`;
await resend.emails.send({
from: `ulo.ad <${FROM_EMAIL}>`,
to: recipientEmail,
subject: `${inviterName} hat dich zu seinem Team eingeladen - ulo.ad`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f8fafc;">
<!-- Logo/Header -->
<div style="text-align: center; margin-bottom: 30px; padding: 20px;">
<h1 style="color: #0ea5e9; font-size: 36px; margin: 0; font-weight: 700;">
🔗 ulo.ad
</h1>
</div>
<!-- Main Content Card -->
<div style="background: #ffffff; border-radius: 16px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);">
<h2 style="color: #0f172a; font-size: 24px; margin-top: 0; margin-bottom: 16px; font-weight: 600;">
Du wurdest zum Team eingeladen! 🎉
</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-bottom: 24px;">
<strong>${inviterName}</strong> hat dich eingeladen, seinem Team bei ulo.ad beizutreten.
Als Team-Mitglied kannst du Links erstellen und verwalten.
</p>
<!-- What you can do -->
<div style="background: #f0f9ff; border-radius: 12px; padding: 16px; margin: 24px 0; border: 1px solid #bae6fd;">
<p style="color: #0369a1; font-size: 14px; margin: 0 0 12px 0; font-weight: 600;">
Als Team-Mitglied kannst du:
</p>
<ul style="color: #0c4a6e; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Links erstellen und verwalten</li>
<li>Deine eigenen Links bearbeiten und löschen</li>
<li>Mit dem Team zusammenarbeiten</li>
</ul>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 32px 0;">
<a href="${inviteUrl}"
style="display: inline-block; background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: white; padding: 16px 40px; border-radius: 10px;
text-decoration: none; font-weight: 600; font-size: 16px;
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.25);">
Einladung annehmen
</a>
</div>
<!-- Alternative Link -->
<div style="margin-top: 24px; padding-top: 20px; border-top: 1px solid #e2e8f0;">
<p style="color: #94a3b8; font-size: 13px; margin-bottom: 8px;">
Falls der Button nicht funktioniert, kopiere diesen Link:
</p>
<p style="background: #f8fafc; padding: 12px; border-radius: 6px; word-break: break-all; font-size: 12px; color: #0ea5e9;">
${inviteUrl}
</p>
</div>
<!-- Expiry Notice -->
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 16px; margin-top: 24px;">
<p style="color: #991b1b; font-size: 13px; margin: 0;">
Diese Einladung ist 7 Tage gültig
</p>
</div>
</div>
<!-- Footer -->
<div style="text-align: center; margin-top: 32px; padding: 20px;">
<p style="color: #94a3b8; font-size: 12px; margin: 8px 0;">
Diese Einladung wurde an <strong>${recipientEmail}</strong> gesendet.
</p>
<p style="color: #cbd5e1; font-size: 11px; margin-top: 20px;">
© ${new Date().getFullYear()} ulo.ad · <a href="https://ulo.ad" style="color: #0ea5e9; text-decoration: none;">ulo.ad</a>
</p>
</div>
</div>`,
});
console.log('[EMAIL] Team invitation sent to:', recipientEmail);
return true;
} catch (error) {
console.error('[EMAIL] Failed to send invitation email:', error);
return false;
}
}
/**
* Send notification when invitation is accepted
*/
export async function sendInvitationAcceptedEmail(
ownerEmail: string,
memberName: string
): Promise<boolean> {
try {
await resend.emails.send({
from: `ulo.ad <${FROM_EMAIL}>`,
to: ownerEmail,
subject: `${memberName} hat deine Einladung angenommen - ulo.ad`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f8fafc;">
<!-- Logo/Header -->
<div style="text-align: center; margin-bottom: 30px; padding: 20px;">
<h1 style="color: #0ea5e9; font-size: 36px; margin: 0; font-weight: 700;">
🔗 ulo.ad
</h1>
</div>
<!-- Main Content Card -->
<div style="background: #ffffff; border-radius: 16px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);">
<h2 style="color: #0f172a; font-size: 24px; margin-top: 0; margin-bottom: 16px; font-weight: 600;">
Neues Team-Mitglied! 🎊
</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-bottom: 24px;">
<strong>${memberName}</strong> hat deine Einladung angenommen und ist jetzt Teil deines Teams.
</p>
<!-- Success Box -->
<div style="background: #dcfce7; border: 1px solid #86efac; border-radius: 12px; padding: 16px; margin: 24px 0;">
<p style="color: #14532d; font-size: 14px; margin: 0;">
Das Team-Mitglied kann jetzt Links in deinem Account erstellen und verwalten.
</p>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 32px 0;">
<a href="${APP_URL}/settings/team"
style="display: inline-block; background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
color: white; padding: 16px 40px; border-radius: 10px;
text-decoration: none; font-weight: 600; font-size: 16px;
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.25);">
👥 Team verwalten
</a>
</div>
</div>
<!-- Footer -->
<div style="text-align: center; margin-top: 32px; padding: 20px;">
<p style="color: #cbd5e1; font-size: 11px; margin: 0;">
© ${new Date().getFullYear()} ulo.ad · <a href="https://ulo.ad" style="color: #0ea5e9; text-decoration: none;">ulo.ad</a>
</p>
</div>
</div>`,
});
console.log('[EMAIL] Acceptance notification sent to:', ownerEmail);
return true;
} catch (error) {
console.error('[EMAIL] Failed to send acceptance notification:', error);
return false;
}
}
/**
* Send welcome email to new users
*/
export async function sendWelcomeEmail(to: string, username: string): Promise<boolean> {
try {
await resend.emails.send({
from: `ulo.ad <${FROM_EMAIL}>`,
to,
subject: 'Willkommen bei ulo.ad!',
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f8fafc;">
<div style="text-align: center; margin-bottom: 30px; padding: 20px;">
<h1 style="color: #0ea5e9; font-size: 36px; margin: 0; font-weight: 700;">
🔗 ulo.ad
</h1>
</div>
<div style="background: #ffffff; border-radius: 16px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);">
<h1 style="color: #0f172a; font-size: 24px; margin-top: 0;">Willkommen, ${username}!</h1>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Danke, dass du bei ulo.ad dabei bist. Wir freuen uns, dich an Bord zu haben.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Mit ulo.ad kannst du:</p>
<ul style="color: #475569; font-size: 16px; line-height: 1.8;">
<li>URLs kürzen und anpassen</li>
<li>Click-Analytics verfolgen</li>
<li>Links mit Tags und Workspaces organisieren</li>
<li>QR-Codes generieren</li>
<li>Ablaufdaten und Click-Limits setzen</li>
</ul>
<div style="text-align: center; margin: 32px 0;">
<a href="${APP_URL}/my/links" style="display: inline-block; background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); color: white; padding: 16px 40px; border-radius: 10px; text-decoration: none; font-weight: 600; font-size: 16px;">
Los geht's
</a>
</div>
</div>
<div style="text-align: center; margin-top: 32px; padding: 20px;">
<p style="color: #cbd5e1; font-size: 11px;">
© ${new Date().getFullYear()} ulo.ad
</p>
</div>
</div>`,
});
return true;
} catch (error) {
console.error('[EMAIL] Failed to send welcome email:', error);
return false;
}
}

View file

@ -1,422 +0,0 @@
// GDPR Compliance Implementierung für uLoad
// Datenschutz-Grundverordnung (DSGVO) Konformität
export interface GDPRConsent {
necessary: boolean; // Immer true, technisch erforderlich
analytics: boolean;
marketing: boolean;
preferences: boolean;
timestamp: string;
version: string;
}
export interface DataProcessingPurpose {
id: string;
name: string;
description: string;
legalBasis:
| 'consent'
| 'contract'
| 'legal_obligation'
| 'vital_interests'
| 'public_task'
| 'legitimate_interests';
dataTypes: string[];
retention: string;
required: boolean;
}
// GDPR-konforme Datenverarbeitungszwecke für uLoad
export const DATA_PROCESSING_PURPOSES: DataProcessingPurpose[] = [
{
id: 'account_management',
name: 'Account-Verwaltung',
description: 'Bereitstellung und Verwaltung Ihres Benutzerkontos',
legalBasis: 'contract',
dataTypes: ['email', 'username', 'password_hash', 'profile_data'],
retention: 'Bis zur Kontolöschung',
required: true,
},
{
id: 'link_service',
name: 'Link-Verkürrungs-Service',
description: 'Erstellung und Verwaltung von kurzen Links',
legalBasis: 'contract',
dataTypes: ['original_urls', 'short_codes', 'link_metadata'],
retention: 'Bis zur manuellen Löschung oder Kontolöschung',
required: true,
},
{
id: 'click_analytics',
name: 'Click-Analytics',
description: 'Anonyme Analyse von Link-Klicks für Statistiken',
legalBasis: 'legitimate_interests',
dataTypes: ['anonymized_ip', 'user_agent', 'referer', 'timestamp'],
retention: '12 Monate',
required: false,
},
{
id: 'security',
name: 'Sicherheit und Betrug-Prävention',
description: 'Schutz vor Missbrauch und Sicherheit der Plattform',
legalBasis: 'legitimate_interests',
dataTypes: ['ip_address', 'user_agent', 'access_logs'],
retention: '6 Monate',
required: true,
},
{
id: 'communication',
name: 'Service-Kommunikation',
description: 'Wichtige Mitteilungen zum Service (Updates, Sicherheit)',
legalBasis: 'contract',
dataTypes: ['email', 'communication_preferences'],
retention: 'Bis zur Kontolöschung',
required: true,
},
{
id: 'marketing',
name: 'Marketing und Newsletter',
description: 'Produktneuigkeiten und Marketing-Kommunikation',
legalBasis: 'consent',
dataTypes: ['email', 'usage_patterns', 'preferences'],
retention: 'Bis zum Widerruf der Einwilligung',
required: false,
},
{
id: 'analytics',
name: 'Website-Analytics',
description: 'Analyse der Website-Nutzung zur Verbesserung',
legalBasis: 'consent',
dataTypes: ['anonymized_usage_data', 'page_views', 'session_data'],
retention: '14 Monate',
required: false,
},
];
// Standard GDPR Consent
export const DEFAULT_CONSENT: GDPRConsent = {
necessary: true,
analytics: false,
marketing: false,
preferences: false,
timestamp: new Date().toISOString(),
version: '1.0',
};
// GDPR Consent Manager
export class GDPRManager {
private static readonly CONSENT_KEY = 'gdpr_consent';
private static readonly CONSENT_VERSION = '1.0';
// Aktuelle Einwilligung laden
static getConsent(): GDPRConsent | null {
if (typeof localStorage === 'undefined') return null;
try {
const stored = localStorage.getItem(this.CONSENT_KEY);
if (!stored) return null;
const consent = JSON.parse(stored) as GDPRConsent;
// Prüfe Version - bei Änderungen neue Einwilligung erforderlich
if (consent.version !== this.CONSENT_VERSION) {
this.clearConsent();
return null;
}
return consent;
} catch (error) {
console.error('Error loading GDPR consent:', error);
return null;
}
}
// Einwilligung speichern
static setConsent(consent: Partial<GDPRConsent>): void {
if (typeof localStorage === 'undefined') return;
const fullConsent: GDPRConsent = {
...DEFAULT_CONSENT,
...consent,
timestamp: new Date().toISOString(),
version: this.CONSENT_VERSION,
};
try {
localStorage.setItem(this.CONSENT_KEY, JSON.stringify(fullConsent));
// Event für andere Teile der App
window.dispatchEvent(
new CustomEvent('gdpr:consent-updated', {
detail: fullConsent,
})
);
console.log('GDPR consent updated:', fullConsent);
} catch (error) {
console.error('Error saving GDPR consent:', error);
}
}
// Einwilligung löschen
static clearConsent(): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(this.CONSENT_KEY);
window.dispatchEvent(new CustomEvent('gdpr:consent-cleared'));
console.log('GDPR consent cleared');
}
// Prüfe ob Einwilligung erforderlich ist
static needsConsent(): boolean {
const consent = this.getConsent();
return consent === null;
}
// Prüfe spezifische Einwilligung
static hasConsent(type: keyof Omit<GDPRConsent, 'timestamp' | 'version'>): boolean {
const consent = this.getConsent();
if (!consent) return type === 'necessary'; // Nur notwendige Cookies ohne Einwilligung
return consent[type];
}
// Benutzerrechte verwalten
static async exerciseUserRights(request: UserRightRequest): Promise<UserRightResponse> {
switch (request.type) {
case 'access':
return this.handleDataAccess(request);
case 'rectification':
return this.handleDataRectification(request);
case 'erasure':
return this.handleDataErasure(request);
case 'portability':
return this.handleDataPortability(request);
case 'restriction':
return this.handleProcessingRestriction(request);
case 'objection':
return this.handleProcessingObjection(request);
default:
throw new Error('Unknown user right request');
}
}
// Recht auf Auskunft (Art. 15 DSGVO)
private static async handleDataAccess(request: UserRightRequest): Promise<UserRightResponse> {
// Sammle alle Benutzerdaten
const userData = {
account: {
email: request.userEmail,
created: request.accountCreated,
lastLogin: request.lastLogin,
},
links: request.userLinks || [],
analytics: request.userAnalytics || [],
consent: this.getConsent(),
purposes: DATA_PROCESSING_PURPOSES.filter((p) => p.required || this.hasConsent(p.id as any)),
};
return {
success: true,
type: 'access',
data: userData,
message: 'Ihre personenbezogenen Daten wurden zusammengestellt',
};
}
// Recht auf Berichtigung (Art. 16 DSGVO)
private static async handleDataRectification(
request: UserRightRequest
): Promise<UserRightResponse> {
// In einer echten Implementation würde hier eine API-Anfrage an den Server gehen
return {
success: true,
type: 'rectification',
message: 'Ihr Antrag auf Datenberichtigung wurde eingereicht',
};
}
// Recht auf Löschung (Art. 17 DSGVO)
private static async handleDataErasure(request: UserRightRequest): Promise<UserRightResponse> {
// Lokale Consent-Daten löschen
this.clearConsent();
return {
success: true,
type: 'erasure',
message: 'Ihr Antrag auf Datenlöschung wurde eingereicht',
};
}
// Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
private static async handleDataPortability(
request: UserRightRequest
): Promise<UserRightResponse> {
const exportData = {
links: request.userLinks || [],
analytics: request.userAnalytics || [],
profile: request.userProfile || {},
exportDate: new Date().toISOString(),
format: 'JSON',
};
return {
success: true,
type: 'portability',
data: exportData,
message: 'Ihre Daten wurden für den Export vorbereitet',
};
}
// Recht auf Einschränkung (Art. 18 DSGVO)
private static async handleProcessingRestriction(
request: UserRightRequest
): Promise<UserRightResponse> {
return {
success: true,
type: 'restriction',
message: 'Ihr Antrag auf Verarbeitungseinschränkung wurde eingereicht',
};
}
// Widerspruchsrecht (Art. 21 DSGVO)
private static async handleProcessingObjection(
request: UserRightRequest
): Promise<UserRightResponse> {
// Analytics und Marketing deaktivieren
this.setConsent({
...this.getConsent(),
analytics: false,
marketing: false,
});
return {
success: true,
type: 'objection',
message: 'Ihr Widerspruch wurde verarbeitet',
};
}
}
// Interfaces für Benutzerrechte
export interface UserRightRequest {
type: 'access' | 'rectification' | 'erasure' | 'portability' | 'restriction' | 'objection';
userEmail: string;
accountCreated?: string;
lastLogin?: string;
userLinks?: any[];
userAnalytics?: any[];
userProfile?: any;
reason?: string;
}
export interface UserRightResponse {
success: boolean;
type: string;
data?: any;
message: string;
error?: string;
}
// Cookie-Banner Utilities
export function shouldShowCookieBanner(): boolean {
return GDPRManager.needsConsent();
}
export function acceptAllCookies(): void {
GDPRManager.setConsent({
necessary: true,
analytics: true,
marketing: true,
preferences: true,
});
}
export function acceptNecessaryOnly(): void {
GDPRManager.setConsent({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
});
}
// Data Processing Record (Art. 30 DSGVO)
export function generateProcessingRecord(): any {
return {
controller: {
name: 'uLoad',
contact: 'privacy@ulo.ad',
representative: 'Till Schneider',
dpo: null, // Falls kein Datenschutzbeauftragter erforderlich
},
purposes: DATA_PROCESSING_PURPOSES,
categories: {
dataSubjects: ['users', 'visitors'],
personalData: ['identification', 'contact', 'usage', 'technical'],
recipients: ['hosting_provider', 'analytics_provider', 'payment_provider'],
transfers: ['within_eu'],
},
retention: {
criteria: 'Purpose-based retention',
periods: DATA_PROCESSING_PURPOSES.map((p) => ({
purpose: p.name,
period: p.retention,
})),
},
security: {
measures: ['encryption', 'access_control', 'regular_backups', 'monitoring'],
certifications: [],
},
lastUpdated: new Date().toISOString(),
};
}
// Anonymisierung von IP-Adressen (für Analytics)
export function anonymizeIP(ip: string): string {
if (ip.includes(':')) {
// IPv6 - entferne die letzten 80 Bits
const parts = ip.split(':');
return parts.slice(0, 5).join(':') + '::';
} else {
// IPv4 - entferne das letzte Oktett
const parts = ip.split('.');
return parts.slice(0, 3).join('.') + '.0';
}
}
// Daten-Minimierung prüfen
export function isDataMinimal(dataCollection: any): boolean {
const requiredFields = ['email', 'username'];
const optionalFields = ['name', 'bio', 'website'];
const collectedFields = Object.keys(dataCollection);
// Prüfe ob nur notwendige und explizit gewünschte Felder gesammelt werden
const unnecessary = collectedFields.filter(
(field) => !requiredFields.includes(field) && !optionalFields.includes(field)
);
return unnecessary.length === 0;
}
// Legal Basis Validation
export function validateLegalBasis(
purpose: string,
hasConsent: boolean,
isRequired: boolean
): boolean {
const purposeConfig = DATA_PROCESSING_PURPOSES.find((p) => p.id === purpose);
if (!purposeConfig) return false;
switch (purposeConfig.legalBasis) {
case 'consent':
return hasConsent;
case 'contract':
return isRequired;
case 'legitimate_interests':
return true; // Interessenabwägung bereits durchgeführt
default:
return false;
}
}

View file

@ -1,60 +0,0 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('it', () => import('./locales/it.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
// List of supported locales
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'en';
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Also export initI18n for backwards compatibility
export function initI18n() {
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -1,28 +0,0 @@
{
"nav_login": "Anmelden",
"nav_register": "Registrieren",
"nav_dashboard": "Dashboard",
"nav_folders": "Ordner",
"nav_profile": "Profil",
"nav_logout": "Abmelden",
"home_title": "Links intelligenter teilen",
"home_subtitle": "Erstelle verkürzte Links mit QR-Codes, benutzerdefinierten Namen und Analysen",
"home_url_label_qr": "URL zum Kodieren",
"home_url_label": "URL zum Kürzen",
"home_title_label": "Titel",
"home_title_placeholder": "Gib deinem Link einen Namen",
"home_description_label": "Beschreibung",
"home_description_placeholder": "Füge eine Beschreibung hinzu (optional)",
"home_expires_label": "Ablauf",
"home_expires_placeholder": "z.B. 7 Tage, 1 Monat",
"home_max_clicks_label": "Max. Klicks",
"home_max_clicks_placeholder": "Anzahl der Klicks begrenzen",
"home_password_label": "Passwort",
"home_password_placeholder": "Mit Passwort schützen",
"home_guest_info": "Du verwendest uload als Gast",
"auth_modal_signin": "Anmelden",
"home_guest_signin_hint": "um auf erweiterte Funktionen zuzugreifen",
"home_processing": "Verarbeitung...",
"home_submit_button_qr": "QR-Code generieren",
"home_submit_button": "Link erstellen"
}

View file

@ -1,144 +0,0 @@
{
"nav_login": "Login",
"nav_register": "Register",
"nav_dashboard": "Dashboard",
"nav_folders": "Folders",
"nav_profile": "Profile",
"nav_logout": "Logout",
"nav_pricing": "Pricing",
"home_title": "Share Links Smarter",
"home_subtitle": "Create shortened links with QR codes, custom names, and analytics",
"home_url_label_qr": "URL to encode",
"home_url_label": "URL to shorten",
"home_title_label": "Title",
"home_title_placeholder": "Give your link a name",
"home_description_label": "Description",
"home_description_placeholder": "Add a description (optional)",
"home_expires_label": "Expiration",
"home_expires_placeholder": "e.g., 7 days, 1 month",
"home_max_clicks_label": "Max clicks",
"home_max_clicks_placeholder": "Limit number of clicks",
"home_password_label": "Password",
"home_password_placeholder": "Protect with password",
"home_guest_info": "You're using uload as a guest",
"home_guest_signin_hint": "to access advanced features",
"home_processing": "Processing...",
"home_submit_button_qr": "Generate QR Code",
"home_submit_button": "Create Link",
"auth_modal_signin": "Sign in",
"auth_sign_in": "Sign In",
"auth_login_button": "Login",
"auth_login_button_loading": "Logging in...",
"auth_register_button": "Register",
"auth_register_button_loading": "Creating account...",
"auth_email_label": "Email",
"auth_email_placeholder": "Enter your email",
"auth_email_address_label": "Email Address",
"auth_password_label": "Password",
"auth_password_confirm_label": "Confirm Password",
"auth_forgot_password": "Forgot password?",
"auth_no_account": "Don't have an account?",
"auth_have_account": "Already have an account?",
"auth_create_account": "Create Account",
"auth_create_account_title": "Create Account",
"auth_create_account_subtitle": "Join us to start shortening links",
"auth_welcome_back": "Welcome Back",
"auth_welcome_back_subtitle": "Sign in to continue",
"auth_back_to_login": "Back to login",
"auth_go_to_login": "Go to login",
"auth_remember_password": "Remember your password?",
"auth_username_auto": "Username will be generated automatically",
"auth_registration_tip": "You'll receive a verification email",
"auth_registration_success": "Registration successful!",
"auth_registration_success_message": "Please check your email to verify your account.",
"auth_reset_password_title": "Reset Password",
"auth_reset_password_subtitle": "Enter your email to receive a reset link",
"auth_reset_password_button": "Reset Password",
"auth_reset_password_button_loading": "Resetting...",
"auth_send_reset_button": "Send Reset Link",
"auth_send_reset_button_loading": "Sending...",
"auth_reset_email_sent_title": "Email Sent",
"auth_reset_email_sent_message": "Check your inbox for the password reset link.",
"auth_request_new_reset_link": "Request new link",
"auth_set_new_password_title": "Set New Password",
"auth_set_new_password_subtitle": "Enter your new password below",
"auth_new_password_label": "New Password",
"auth_new_password_placeholder": "Enter new password",
"auth_confirm_new_password_label": "Confirm New Password",
"auth_confirm_new_password_placeholder": "Confirm new password",
"auth_password_reset_success": "Password Reset",
"auth_password_reset_success_message": "Your password has been successfully reset.",
"auth_invalid_reset_link": "Invalid Reset Link",
"auth_invalid_reset_link_message": "This password reset link is invalid or has expired.",
"auth_invalid_verification_link": "Invalid Verification Link",
"auth_invalid_verification_link_message": "This verification link is invalid or has expired.",
"auth_verification_link_expired": "Link Expired",
"auth_verification_link_expired_message": "This verification link has expired. Please request a new one.",
"auth_email_verified": "Email Verified",
"auth_email_verified_message": "Your email has been successfully verified.",
"auth_email_already_verified": "Already Verified",
"auth_email_already_verified_message": "Your email is already verified.",
"auth_email_already_verified_notify": "Already verified",
"auth_email_already_verified_notify_desc": "Your email was already verified. You can log in now.",
"auth_token_expired_notify": "Session Expired",
"auth_token_expired_notify_desc": "Your session has expired. Please log in again.",
"auth_add_account": "Add Account",
"auth_add_account_info": "Add another account to quickly switch between them",
"auth_add_account_subtitle": "Sign in with another account",
"auth_add_account_switch_info": "You can switch between accounts anytime",
"account_my_account": "My Account",
"account_add_account": "Add Account",
"account_team_accounts": "Team Accounts",
"account_no_team_accounts": "No team accounts",
"account_team_invite_info": "Invite team members to collaborate",
"account_team_member": "Team Member",
"workspace_switch": "Switch Workspace",
"workspace_personal": "Personal",
"workspace_create": "Create Workspace",
"hero_control_headline": "Share Links Smarter",
"hero_control_subheadline": "Create shortened links with analytics and QR codes",
"hero_control_cta": "Get Started",
"hero_free_text": "Free to start",
"hero_trust_badge_": "Trusted by thousands",
"hero_a": "Hero A",
"hero_b": "Hero B",
"hero_c": "Hero C",
"toast_login_success": "Login successful",
"toast_login_error": "Login failed",
"toast_logout_success": "Logged out successfully",
"toast_register_success": "Account created successfully",
"toast_link_created": "Link created successfully",
"toast_link_updated": "Link updated successfully",
"toast_link_deleted": "Link deleted successfully",
"toast_link_copied": "Link copied to clipboard",
"toast_profile_updated": "Profile updated successfully",
"toast_avatar_uploaded": "Avatar uploaded successfully",
"toast_password_changed": "Password changed successfully",
"toast_password_reset_sent": "Password reset email sent",
"toast_email_verified": "Email verified successfully",
"toast_session_expired": "Session expired",
"toast_session_expired_desc": "Please log in again to continue.",
"toast_network_error": "Network error",
"toast_network_error_desc": "Please check your connection and try again.",
"toast_permission_denied": "Permission denied",
"toast_payment_failed": "Payment failed",
"toast_payment_failed_desc": "Please try again or use a different payment method.",
"toast_subscription_upgraded": "Subscription upgraded",
"toast_subscription_cancelled": "Subscription cancelled",
"toast_unsupported_format": "Unsupported format",
"error_link_creation": "Failed to create links",
"error_link_creation_single": "Failed to create link",
"error_password_change": "Failed to change password",
"error_save": "Failed to save changes"
}

View file

@ -1,28 +0,0 @@
{
"nav_login": "Iniciar sesión",
"nav_register": "Registrarse",
"nav_dashboard": "Panel",
"nav_folders": "Carpetas",
"nav_profile": "Perfil",
"nav_logout": "Cerrar sesión",
"home_title": "Comparte Enlaces de Forma Inteligente",
"home_subtitle": "Crea enlaces acortados con códigos QR, nombres personalizados y análisis",
"home_url_label_qr": "URL para codificar",
"home_url_label": "URL para acortar",
"home_title_label": "Título",
"home_title_placeholder": "Dale un nombre a tu enlace",
"home_description_label": "Descripción",
"home_description_placeholder": "Añadir una descripción (opcional)",
"home_expires_label": "Vencimiento",
"home_expires_placeholder": "ej., 7 días, 1 mes",
"home_max_clicks_label": "Clics máximos",
"home_max_clicks_placeholder": "Limitar número de clics",
"home_password_label": "Contraseña",
"home_password_placeholder": "Proteger con contraseña",
"home_guest_info": "Estás usando uload como invitado",
"auth_modal_signin": "Iniciar sesión",
"home_guest_signin_hint": "para acceder a funciones avanzadas",
"home_processing": "Procesando...",
"home_submit_button_qr": "Generar Código QR",
"home_submit_button": "Crear Enlace"
}

View file

@ -1,28 +0,0 @@
{
"nav_login": "Connexion",
"nav_register": "S'inscrire",
"nav_dashboard": "Tableau de bord",
"nav_folders": "Dossiers",
"nav_profile": "Profil",
"nav_logout": "Déconnexion",
"home_title": "Partagez des Liens Intelligemment",
"home_subtitle": "Créez des liens raccourcis avec codes QR, noms personnalisés et analyses",
"home_url_label_qr": "URL à encoder",
"home_url_label": "URL à raccourcir",
"home_title_label": "Titre",
"home_title_placeholder": "Donnez un nom à votre lien",
"home_description_label": "Description",
"home_description_placeholder": "Ajouter une description (optionnel)",
"home_expires_label": "Expiration",
"home_expires_placeholder": "ex., 7 jours, 1 mois",
"home_max_clicks_label": "Clics maximum",
"home_max_clicks_placeholder": "Limiter le nombre de clics",
"home_password_label": "Mot de passe",
"home_password_placeholder": "Protéger avec mot de passe",
"home_guest_info": "Vous utilisez uload en tant qu'invité",
"auth_modal_signin": "Se connecter",
"home_guest_signin_hint": "pour accéder aux fonctionnalités avancées",
"home_processing": "Traitement...",
"home_submit_button_qr": "Générer Code QR",
"home_submit_button": "Créer Lien"
}

Some files were not shown because too many files have changed in this diff Show more