mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
82a4cb4c59
commit
3686926a8e
184 changed files with 530 additions and 38347 deletions
|
|
@ -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}...`);
|
||||
|
||||
|
|
|
|||
9
apps/uload/apps/server/src/routes/email.ts
Normal file
9
apps/uload/apps/server/src/routes/email.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
14
apps/uload/apps/server/src/routes/stripe.ts
Normal file
14
apps/uload/apps/server/src/routes/stripe.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1763571183375,
|
||||
"tag": "0000_material_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173,
|
||||
},
|
||||
testDir: 'e2e',
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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!
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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 || '',
|
||||
});
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 https://google.com 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>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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] }),
|
||||
}));
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue