feat: integrate uload and picture, unify package naming

- Add uload project with apps/web structure
  - Reorganize from flat to monorepo structure
  - Remove PocketBase binary and local data
  - Update to pnpm and @uload/web namespace

- Add picture project to monorepo
  - Remove embedded git repository

- Unify all package names to @{project}/{app} schema:
  - @maerchenzauber/* (was @storyteller/*)
  - @manacore/* (was manacore-*, manacore)
  - @manadeck/* (was web, backend, manadeck)
  - @memoro/* (was memoro-web, landing, memoro)
  - @picture/* (already unified)
  - @uload/web

- Add convenient dev scripts for all apps:
  - pnpm dev:{project}:web
  - pnpm dev:{project}:landing
  - pnpm dev:{project}:mobile
  - pnpm dev:{project}:backend

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 04:00:36 +01:00
parent c6c4c5a552
commit c712a2504a
1031 changed files with 189301 additions and 290 deletions

1
uload/apps/web/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View file

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

View file

@ -0,0 +1,75 @@
{
"name": "@uload/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "pnpm run paraglide:compile && vite dev",
"build": "pnpm run paraglide:compile && vite build",
"preview": "vite preview",
"paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide",
"test": "pnpm run test:unit && pnpm run test:e2e",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"type": "module",
"devDependencies": {
"@eslint/js": "^9.20.0",
"@inlang/paraglide-js": "^2.2.0",
"@playwright/test": "^1.51.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/forms": "^0.5.8",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/eslint__js": "^8.42.3",
"@types/node": "^24.3.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.7",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.35.0",
"globals": "^15.0.0",
"gray-matter": "^4.0.3",
"jsdom": "^26.1.0",
"mdsvex": "^0.12.6",
"playwright": "^1.51.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vitest": "^3.2.3",
"vitest-browser-svelte": "^0.1.0",
"zod": "^4.0.17"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.934.0",
"@aws-sdk/s3-request-presigner": "^3.934.0",
"drizzle-orm": "^0.44.7",
"ioredis": "^5.7.0",
"isomorphic-dompurify": "^2.26.0",
"lucide-svelte": "^0.539.0",
"pocketbase": "^0.26.2",
"postgres": "^3.4.7",
"resend": "^6.5.1",
"stripe": "^18.4.0",
"svelte-sonner": "^1.0.5"
}
}

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,28 @@
{
"nav_login": "Login",
"nav_register": "Register",
"nav_dashboard": "Dashboard",
"nav_folders": "Folders",
"nav_profile": "Profile",
"nav_logout": "Logout",
"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",
"auth_modal_signin": "Sign in",
"home_guest_signin_hint": "to access advanced features",
"home_processing": "Processing...",
"home_submit_button_qr": "Generate QR Code",
"home_submit_button": "Create Link"
}

View file

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

View file

@ -0,0 +1,28 @@
{
"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"
}

View file

@ -0,0 +1,28 @@
{
"nav_login": "Accedi",
"nav_register": "Registrati",
"nav_dashboard": "Dashboard",
"nav_folders": "Cartelle",
"nav_profile": "Profilo",
"nav_logout": "Esci",
"home_title": "Condividi Link in Modo Intelligente",
"home_subtitle": "Crea link abbreviati con codici QR, nomi personalizzati e analisi",
"home_url_label_qr": "URL da codificare",
"home_url_label": "URL da abbreviare",
"home_title_label": "Titolo",
"home_title_placeholder": "Dai un nome al tuo link",
"home_description_label": "Descrizione",
"home_description_placeholder": "Aggiungi una descrizione (opzionale)",
"home_expires_label": "Scadenza",
"home_expires_placeholder": "es., 7 giorni, 1 mese",
"home_max_clicks_label": "Click massimi",
"home_max_clicks_placeholder": "Limita il numero di click",
"home_password_label": "Password",
"home_password_placeholder": "Proteggi con password",
"home_guest_info": "Stai usando uload come ospite",
"auth_modal_signin": "Accedi",
"home_guest_signin_hint": "per accedere alle funzionalità avanzate",
"home_processing": "Elaborazione...",
"home_submit_button_qr": "Genera Codice QR",
"home_submit_button": "Crea Link"
}

View file

@ -0,0 +1 @@
vBR0K1t5zNgjHxICus

View file

@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "es", "fr", "it"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@latest/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@latest/dist/index.js"
],
"plugin.inlang.json": {
"pathPattern": "./messages/{locale}.json"
}
}

162
uload/apps/web/src/app.css Normal file
View file

@ -0,0 +1,162 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* Dark mode configuration */
@variant dark (&:where(.dark, .dark *));
/* Theme color utilities using CSS variables */
@theme {
--color-theme-primary: var(--theme-primary);
--color-theme-primary-hover: var(--theme-primary-hover);
--color-theme-background: var(--theme-background);
--color-theme-surface: var(--theme-surface);
--color-theme-surface-hover: var(--theme-surface-hover);
--color-theme-text: var(--theme-text);
--color-theme-text-muted: var(--theme-text-muted);
--color-theme-border: var(--theme-border);
--color-theme-accent: var(--theme-accent);
--color-theme-accent-hover: var(--theme-accent-hover);
}
/* Theme CSS Variables - will be overridden by theme presets */
:root {
--theme-primary: #171717;
--theme-primary-hover: #0a0a0a;
--theme-background: #fafafa;
--theme-surface: #ffffff;
--theme-surface-hover: #f5f5f5;
--theme-text: #171717;
--theme-text-muted: #737373;
--theme-border: #e5e5e5;
--theme-accent: #525252;
--theme-accent-hover: #404040;
--theme-font-family: Inter, system-ui, -apple-system, sans-serif;
/* Sonner Toast Styling - Light Mode */
--sonner-toast-gap: 8px;
--sonner-toast-padding: 16px;
--sonner-toast-border-radius: 12px;
--sonner-toast-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Apply theme font to body */
body {
font-family: var(--theme-font-family);
}
/* Theme transition animation */
.theme-transitioning,
.theme-transitioning * {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
fill 0.3s ease,
stroke 0.3s ease,
font-family 0.3s ease !important;
}
/* Ensure full viewport coverage */
html,
body {
@apply min-h-screen;
}
body {
font-family: var(--theme-font-family);
background-color: var(--theme-background);
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Primary button class with proper contrast */
.btn-primary {
@apply bg-theme-primary text-theme-background hover:bg-theme-primary-hover;
}
/* Sonner Toast Custom Styles */
.sonner-toast {
font-family: var(--theme-font-family) !important;
}
.sonner-toast[data-type='success'] {
background-color: #10b981 !important;
color: white !important;
border: 1px solid #059669 !important;
}
.sonner-toast[data-type='error'] {
background-color: #ef4444 !important;
color: white !important;
border: 1px solid #dc2626 !important;
}
.sonner-toast[data-type='info'] {
background-color: #3b82f6 !important;
color: white !important;
border: 1px solid #2563eb !important;
}
.sonner-toast[data-type='warning'] {
background-color: #f59e0b !important;
color: white !important;
border: 1px solid #d97706 !important;
}
/* Dark mode toast styles */
.dark .sonner-toast {
background-color: #374151 !important;
color: #f3f4f6 !important;
border: 1px solid #4b5563 !important;
}
.dark .sonner-toast[data-type='success'] {
background-color: #065f46 !important;
color: #d1fae5 !important;
border: 1px solid #10b981 !important;
}
.dark .sonner-toast[data-type='error'] {
background-color: #7f1d1d !important;
color: #fee2e2 !important;
border: 1px solid #ef4444 !important;
}
.dark .sonner-toast[data-type='info'] {
background-color: #1e3a8a !important;
color: #dbeafe !important;
border: 1px solid #3b82f6 !important;
}
.dark .sonner-toast[data-type='warning'] {
background-color: #78350f !important;
color: #fef3c7 !important;
border: 1px solid #f59e0b !important;
}

33
uload/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { DB } from '$lib/db';
import type { AvailableLanguageTag } from '$paraglide/runtime';
import type { ParaglideLocals } from '@inlang/paraglide-sveltekit';
// User type (will be replaced by external auth later)
export interface User {
id: string;
email: string;
username: string;
name: string | null;
avatarUrl: string | null;
verified: boolean;
}
declare global {
namespace App {
// interface Error {}
interface Locals {
db: DB;
user: User | null;
paraglide: ParaglideLocals<AvailableLanguageTag>;
}
interface PageData {
user: User | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

103
uload/apps/web/src/app.html Normal file
View file

@ -0,0 +1,103 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.dir%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#3b82f6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="uLoad" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192x192.png" />
<!-- Preconnect für Performance -->
<link rel="preconnect" href="https://pb.ulo.ad" />
<link rel="dns-prefetch" href="https://pb.ulo.ad" />
%sveltekit.head%
<!-- Umami Analytics -->
<script>
// Only load Umami if configured
if ('%sveltekit.env.PUBLIC_UMAMI_URL%'.startsWith('http')) {
const script = document.createElement('script');
script.defer = true;
script.src = '%sveltekit.env.PUBLIC_UMAMI_URL%/script.js';
script.dataset.websiteId = '%sveltekit.env.PUBLIC_UMAMI_WEBSITE_ID%';
document.head.appendChild(script);
}
</script>
<script>
// Initialize theme early to prevent flash
(function () {
// Load theme mode (light/dark/system)
const themeMode = localStorage.getItem('theme-mode') || 'system';
const themePreset = localStorage.getItem('theme-preset') || 'minimal';
// Determine if dark mode should be active
let isDark = false;
if (themeMode === 'dark') {
isDark = true;
} else if (themeMode === 'system') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Apply dark class if needed
if (isDark) {
document.documentElement.classList.add('dark');
}
// Load theme preset colors
const themes = {
minimal: {
light: {
primary: '#171717',
primaryHover: '#0a0a0a',
background: '#fafafa',
surface: '#ffffff',
surfaceHover: '#f5f5f5',
text: '#171717',
textMuted: '#737373',
border: '#e5e5e5',
accent: '#525252',
accentHover: '#404040'
},
dark: {
primary: '#fafafa',
primaryHover: '#ffffff',
background: '#0a0a0a',
surface: '#171717',
surfaceHover: '#262626',
text: '#fafafa',
textMuted: '#a3a3a3',
border: '#404040',
accent: '#d4d4d4',
accentHover: '#e5e5e5'
}
}
};
// Apply CSS variables for the theme
const preset = themes[themePreset] || themes.minimal;
const colors = isDark ? preset.dark : preset.light;
const root = document.documentElement;
Object.entries(colors).forEach(([key, value]) => {
const cssKey = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
root.style.setProperty(`--theme-${cssKey}`, value);
});
})();
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,153 @@
import type { Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { RateLimits } from '$lib/server/rate-limiter';
import { db } from '$lib/db';
export const handle: Handle = async ({ event, resolve }) => {
// Rate Limiting anwenden (nur in Produktion)
if (!dev) {
const rateLimitResponse = await applyRateLimit(event);
if (rateLimitResponse) {
return rateLimitResponse;
}
}
// Make database available in event.locals
event.locals.db = db;
// TODO: Implement external authentication
// For now, user is always null until auth is implemented
event.locals.user = null;
console.log('\n[HOOKS] === Request:', event.url.pathname);
console.log('[HOOKS] User:', event.locals.user?.id || 'Not authenticated');
const response = await resolve(event);
// Rate Limit Headers hinzufügen
if (event.locals.rateLimitHeaders) {
Object.entries(event.locals.rateLimitHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
}
// Security Headers (nur in Produktion)
if (!dev) {
addSecurityHeaders(response);
}
return response;
};
// Rate Limiting basierend auf Route anwenden
async function applyRateLimit(event: any): Promise<Response | null> {
const { pathname } = event.url;
const method = event.request.method;
// API Endpoints
if (pathname.startsWith('/api/')) {
// Spezifische Limits für verschiedene Endpoints
if (
pathname.includes('/auth') ||
pathname.includes('/login') ||
pathname.includes('/register')
) {
return await RateLimits.auth(event);
}
if (pathname.includes('/password-reset')) {
return await RateLimits.passwordReset(event);
}
if (pathname.includes('/register')) {
return await RateLimits.registration(event);
}
// Link-Operationen (POST für Creation)
if (pathname.includes('/links') && method === 'POST') {
return await RateLimits.linkCreation(event);
}
// General API Rate Limit
return await RateLimits.api(event);
}
// Link Clicks (Redirects)
if (
pathname.length > 1 &&
!pathname.startsWith('/api/') &&
!pathname.startsWith('/my/') &&
!pathname.startsWith('/admin/')
) {
// Könnte ein Short Link sein
return await RateLimits.clicks(event);
}
// Auth Pages
if (pathname === '/login' || pathname === '/register' || pathname === '/forgot-password') {
if (method === 'POST') {
return await RateLimits.auth(event);
}
}
// Kein Rate Limiting für andere Routen
return null;
}
// Security Headers hinzufügen
function addSecurityHeaders(response: Response) {
const headers = response.headers;
// Content Security Policy (angepasst für uLoad)
if (!headers.has('content-security-policy')) {
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.ulo.ad https://analytics.google.com https://www.googletagmanager.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https: blob:",
"media-src 'self' blob:",
"connect-src 'self' https://api.stripe.com https://js.stripe.com https://files.ulo.ad",
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
dev ? '' : 'upgrade-insecure-requests' // Only in production
]
.filter(Boolean)
.join('; ');
headers.set('content-security-policy', csp);
}
// HSTS (HTTP Strict Transport Security)
if (!headers.has('strict-transport-security')) {
headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains; preload');
}
// X-Frame-Options
if (!headers.has('x-frame-options')) {
headers.set('x-frame-options', 'DENY');
}
// X-Content-Type-Options
if (!headers.has('x-content-type-options')) {
headers.set('x-content-type-options', 'nosniff');
}
// Referrer Policy
if (!headers.has('referrer-policy')) {
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
}
// Permissions Policy
if (!headers.has('permissions-policy')) {
headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=()');
}
// X-XSS-Protection (für ältere Browser)
if (!headers.has('x-xss-protection')) {
headers.set('x-xss-protection', '1; mode=block');
}
}

View file

@ -0,0 +1,250 @@
<script lang="ts">
import { onMount } from 'svelte';
import { hashManager } from '../service/HashManager';
import {
getVariantContent,
getTrustBadges,
getFreeText,
type VariantContent
} from '../config/variants';
import { getLocale } from '$paraglide/runtime.js';
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:', getLocale());
}
});
// React to locale changes - use derived state
$effect(() => {
// This will re-run when locale changes
const currentLocale = getLocale();
// 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 top-20 right-4 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">{getLocale()}</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 -top-40 -right-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 sm:text-5xl md:text-6xl dark:text-white"
>
{#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 sm:text-xl dark:text-gray-300">
{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 rounded-lg px-8 py-4 font-semibold whitespace-nowrap text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl {content.ctaStyle ||
'bg-theme-primary hover:bg-theme-primary-hover'}"
>
{content.ctaText}
</a>
{#if !data.user}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{freeText}
</p>
{/if}
</div>
<!-- Trust Badges -->
<div
class="mt-12 flex flex-wrap justify-center gap-6 text-sm text-gray-500 dark:text-gray-400"
>
{#each trustBadges as badge}
<span class="flex items-center gap-1">
{badge.icon}
{badge.text}
</span>
{/each}
</div>
</div>
{:else}
<!-- Loading state -->
<div class="flex min-h-[400px] items-center justify-center">
<div class="text-gray-500">Loading...</div>
</div>
{/if}
</div>
</section>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

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

View file

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

View file

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

View file

@ -0,0 +1,167 @@
<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="flex-1 min-w-0">
<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="flex-1 min-w-0">
<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 dark:bg-purple-900/20 px-1.5 py-0.5 text-xs font-medium text-purple-600 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="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all hover:bg-theme-primary/10"
>
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary/10">
<UserPlus class="h-3.5 w-3.5 text-theme-primary" />
</div>
<span class="text-theme-text">{m.account_add_account()}</span>
</button>
</div>
</div>
{/if}
</div>
<style>
/* Custom styles if needed */
</style>

View file

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

View file

@ -0,0 +1,177 @@
<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="rounded-xl border border-theme-border bg-theme-surface shadow-xl overflow-hidden">
{#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 md:grid items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
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 md:grid items-center gap-4 px-6 py-4 transition-colors hover:bg-theme-surface-hover"
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="md:hidden p-4 space-y-3 bg-theme-surface hover:bg-theme-surface-hover transition-colors">
{#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 justify-between items-center text-sm">
<span class="font-medium text-theme-text-muted">{column.label}:</span>
<span class="text-theme-text">
{#if column.render}
{@html column.render(item)}
{:else if column.key.includes('.')}
{@const keys = column.key.split('.')}
{@const value = keys.reduce((obj, key) => obj?.[key], item)}
{value || '-'}
{:else}
{item[column.key] || '-'}
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text-muted">
{emptyMessage}
</p>
</div>
{/if}
<style>
/* Add any custom styles if needed */
</style>

View file

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

View file

@ -0,0 +1,637 @@
<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 top-4 bottom-4 left-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 text-theme-text-muted uppercase tracking-wider">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="mt-auto space-y-2 border-t border-theme-border/30 pt-4">
<!-- Theme Toggle -->
<div class="relative" bind:this={themeDropdownElement}>
<button
onclick={(e) => toggleThemeDropdown(e)}
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
title={collapsed ? 'Theme' : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if themeStore.isDark}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
{/if}
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Theme</span>
{/if}
</button>
{#if showThemeDropdown}
<div
class="absolute bottom-0 left-full z-50 ml-2 w-64 rounded-lg border border-theme-border bg-theme-surface shadow-lg"
>
<!-- Dark Mode Toggle -->
<div class="border-b border-theme-border p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-theme-text">Dark Mode</span>
<button
onclick={(e) => toggleDarkMode(e)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {themeStore.isDark
? 'bg-theme-accent'
: 'bg-theme-border'}"
aria-label="Toggle dark mode"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {themeStore.isDark
? 'translate-x-6'
: 'translate-x-1'}"
/>
</button>
</div>
</div>
<!-- Theme Selection -->
<div class="p-2">
<p class="mb-2 px-2 text-xs font-medium text-theme-text-muted">Choose Theme</p>
<div class="space-y-1">
{#each Object.values(themes) as theme}
<button
onclick={(e) => selectTheme(theme.id, e)}
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover {themeStore.preset ===
theme.id
? 'bg-theme-surface-hover'
: ''}"
>
<div class="flex items-center gap-3">
<!-- Theme Preview Colors -->
<div class="flex gap-1">
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.primary
: theme.colors.light.primary}"
/>
<div
class="h-4 w-4 rounded-full border border-theme-border"
style="background-color: {themeStore.isDark
? theme.colors.dark.accent
: theme.colors.light.accent}"
/>
</div>
<div>
<span class="block text-sm font-medium text-theme-text">{theme.name}</span
>
<span class="block text-xs text-theme-text-muted"
>{theme.description}</span
>
</div>
</div>
{#if themeStore.preset === theme.id}
<svg
class="h-4 w-4 text-theme-accent"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Settings -->
<a
href="/settings"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Settings' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Settings</span>
{/if}
</a>
<!-- Team -->
<a
href="/settings/team"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings/team'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? 'Team' : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">Team</span>
{/if}
</a>
<!-- Pricing -->
<a
href="/pricing"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/pricing'
)
? 'active bg-theme-surface-hover'
: ''}"
title={collapsed ? m.nav_pricing() : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text"
>{m.nav_pricing() || 'Pricing'}</span
>
{/if}
</a>
<!-- Profile -->
{#if user.username}
<a
href="/p/{user.username}"
target="_blank"
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
title={collapsed ? m.nav_profile() : undefined}
>
<span class="active-indicator"></span>
<svg
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium text-theme-text">{m.nav_profile()}</span>
{/if}
</a>
{/if}
<!-- User Info -->
{#if !collapsed}
<div class="truncate px-3 py-2 text-xs text-theme-text-muted">
{user.email}
</div>
{/if}
<!-- Logout -->
<form method="POST" action="/login?/logout" class="w-full">
<button
type="submit"
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10 dark:text-red-500"
title={collapsed ? m.nav_logout() : undefined}
>
<svg
class="h-5 w-5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{#if !collapsed}
<span class="text-transition font-medium">{m.nav_logout()}</span>
{/if}
</button>
</form>
</div>
</div>
</aside>
{/if}
<style>
.sidebar-transition {
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.icon-transition {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.text-transition {
transition:
opacity 0.2s ease-in-out,
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.active-indicator {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 70%;
background: var(--theme-primary);
border-radius: 0 4px 4px 0;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.active .active-indicator {
opacity: 1;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slideIn 0.4s ease-out;
}
</style>

View file

@ -0,0 +1,201 @@
<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 tracking-wider text-theme-text uppercase">
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 tracking-wider text-theme-text uppercase">
Rechtliches
</h3>
<ul class="space-y-3">
<li>
<a
href="/impressum"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Impressum
</a>
</li>
<li>
<a
href="/datenschutz"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Datenschutz
</a>
</li>
<li>
<a href="/agb" class="text-theme-text-muted transition-colors hover:text-theme-text">
AGB
</a>
</li>
<li>
<a
href="/sicherheit"
class="text-theme-text-muted transition-colors hover:text-theme-text"
>
Sicherheit
</a>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-8 border-t border-theme-border pt-8">
<div class="flex flex-col items-center justify-between space-y-4 sm:flex-row sm:space-y-0">
<p class="text-sm text-theme-text-muted">
© {currentYear} uload. Alle Rechte vorbehalten.
</p>
<!-- Social Links (optional) -->
<div class="flex space-x-6">
<a
href="https://twitter.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="Twitter"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
/>
</svg>
</a>
<a
href="https://github.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="GitHub"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd"
/>
</svg>
</a>
<a
href="https://linkedin.com"
class="text-theme-text-muted transition-colors hover:text-theme-text"
aria-label="LinkedIn"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</footer>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { browser } from '$app/environment';
import { setLocale, getLocale } from '$paraglide/runtime.js';
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 = getLocale();
currentLanguage = languages.find((lang) => lang.code === currentCode) || languages[0];
}
});
function changeLanguage(langCode: string) {
if (browser) {
// Save preference
localStorage.setItem('preferred-language', langCode);
// Update Paraglide locale
setLocale(langCode as any);
// Update current language display
currentLanguage = languages.find((lang) => lang.code === langCode) || languages[0];
// Close dropdown
showDropdown = false;
}
}
</script>
<div class="relative">
<button
onclick={() => (showDropdown = !showDropdown)}
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
aria-label="Change language"
>
<span class="text-lg">{currentLanguage.flag}</span>
<span class="hidden sm:inline">{currentLanguage.name}</span>
<svg
class="h-4 w-4 transition-transform {showDropdown ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 z-50 mt-2 w-48 rounded-lg border border-theme-border bg-white shadow-lg dark:bg-gray-800"
>
{#each languages as lang}
<button
onclick={() => changeLanguage(lang.code)}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 {lang.code ===
currentLanguage.code
? 'bg-gray-50 dark:bg-gray-700/50'
: ''}"
>
<span class="text-lg">{lang.flag}</span>
<span class="text-theme-text">{lang.name}</span>
{#if lang.code === currentLanguage.code}
<svg class="ml-auto h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<svelte:window
onclick={(e) => {
// Close dropdown when clicking outside
if (showDropdown && !(e.target as HTMLElement)?.closest('.relative')) {
showDropdown = false;
}
}}
/>

View file

@ -0,0 +1,83 @@
<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="flex items-center justify-between mb-2">
<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="w-full bg-gray-200 rounded-full h-2 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="text-red-600 dark:text-red-400 font-medium">
Monatslimit erreicht! Upgrade für mehr Links.
</span>
{:else if usageInfo.status === 'warning'}
<span class="text-yellow-600 dark:text-yellow-400 font-medium">
{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 text-green-600 dark:text-green-400 font-medium">
🎉 Du hast unbegrenzten Zugang zu allen Features!
</div>
{/if}
</div>

View file

@ -0,0 +1,306 @@
<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 top-0 bottom-0 left-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="my-2 border-t border-theme-border/30"></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="mt-auto space-y-2 border-t border-theme-border/30 pt-4">
<a
href="/settings"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span class="font-medium">Settings</span>
</a>
<a
href="/settings/team"
onclick={handleLinkClick}
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
'/settings/team'
)
? 'bg-theme-surface-hover text-theme-primary'
: 'text-theme-text'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span class="font-medium">Team</span>
</a>
<div class="px-3 py-2 text-sm text-theme-text-muted">
{user.email}
</div>
<form method="POST" action="/login?/logout" class="w-full">
<button
type="submit"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span class="font-medium">{m.nav_logout()}</span>
</button>
</form>
</div>
</div>
</aside>
{/if}
<style>
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.slide-in {
animation: slideInLeft 0.3s ease-out;
}
</style>

View file

@ -0,0 +1,840 @@
<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="sticky top-0 z-50 hidden border-b border-theme-border bg-theme-surface/80 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 top-1/2 left-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="relative z-20 flex overflow-hidden rounded-full border-2 border-theme-border/20 bg-theme-surface/95 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="relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors hover:bg-theme-surface-hover/50"
>
<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="relative z-10 w-px bg-theme-border/30"></div>
<!-- Right Half: Menu -->
<button
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors hover:bg-theme-surface-hover/50"
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="fixed inset-0 z-35 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:top-[65px] md:bottom-auto md:max-w-md"
>
<div
class="flex max-h-[60vh] w-full flex-col overflow-hidden rounded-2xl border border-theme-border/30 bg-theme-surface/95 shadow-2xl backdrop-blur-xl"
>
<div class="flex-1 overflow-y-auto p-3">
{#if user}
<!-- Main Navigation -->
<div class="pb-1">
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">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="pt-2 pb-1">
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">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-t border-theme-border/30 pt-2 pb-1">
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">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="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">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-t border-theme-border/30 pt-2 pb-1">
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">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="space-y-2 border-t border-theme-border/30 p-3">
{#if !user}
<a
href="/login"
class="block w-full rounded-lg bg-theme-surface-hover px-4 py-2 text-center font-medium text-theme-text transition-colors hover:bg-theme-border"
>
{m.nav_login()}
</a>
<a
href="/register"
class="block w-full rounded-lg bg-theme-primary px-4 py-2 text-center font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
>
{m.nav_register()}
</a>
{/if}
</div>
</div>
</div>
{/if}
<style>
.scroll-progress-indicator {
background: conic-gradient(
from -90deg at 50% 50%,
var(--progress-color, #ef4444) calc(var(--scroll-progress, 0) * 360deg),
transparent calc(var(--scroll-progress, 0) * 360deg)
);
transition: --progress-color 0.3s ease;
}
@keyframes slide-up {
from {
transform: translateX(-50%) translateY(20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
@keyframes slide-down {
from {
transform: translateX(-50%) translateY(-20px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
.animate-slide-down {
animation: slide-down 0.2s ease-out;
}
</style>

View file

@ -0,0 +1,258 @@
<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 hover:text-theme-text transition-colors"
aria-label="Benachrichtigungen"
aria-expanded={showDropdown}
aria-haspopup="true"
>
<Bell class="h-5 w-5" />
{#if $unreadCount > 0}
<span class="absolute -top-1 -right-1 bg-theme-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-bold">
{$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'} w-96 max-h-[600px] rounded-lg border border-theme-border bg-theme-surface shadow-xl overflow-hidden z-50">
<!-- Header -->
<div class="border-b border-theme-border p-2">
<div class="px-3 py-2 flex items-center justify-between">
<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 hover:text-theme-primary-hover transition-colors"
>
Alle als gelesen markieren
</button>
{/if}
<button
onclick={() => showDropdown = false}
class="p-1 rounded-md text-theme-text-muted hover:text-theme-text hover:bg-theme-surface-hover transition-colors"
>
<X class="h-4 w-4" />
</button>
</div>
</div>
</div>
<!-- Notifications List -->
<div class="overflow-y-auto max-h-[500px]">
{#if $notifications.loading}
<div class="p-8 text-center text-theme-text-muted">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-theme-primary mx-auto"></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="h-12 w-12 mx-auto mb-3 opacity-20" />
<p class="text-sm">Keine Benachrichtigungen</p>
</div>
{:else}
<div class="p-2">
{#each $notifications.notifications as notification, i}
<div
class="group rounded-md px-3 py-3 mb-1 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 items-center justify-center rounded-full flex-shrink-0 {getNotificationIconColor(notification.type)}">
<span class="text-base">
{getNotificationIcon(notification.type)}
</span>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<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="text-xs text-theme-text-muted mt-0.5">
{notification.message}
</p>
<p class="text-xs text-theme-text-muted mt-1.5">
{formatTime(notification.created)}
</p>
</button>
<!-- Actions -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{#if !notification.read}
<button
onclick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
class="p-1 rounded text-theme-text-muted hover:text-theme-primary hover:bg-theme-surface-hover transition-colors"
title="Als gelesen markieren"
>
<Check class="h-3.5 w-3.5" />
</button>
{/if}
<button
onclick={(e) => {
e.stopPropagation();
handleDelete(notification.id);
}}
class="p-1 rounded text-theme-text-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
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="mt-2 inline-flex items-center gap-1 px-2.5 py-1 bg-theme-primary/10 text-theme-primary text-xs font-medium rounded-md hover:bg-theme-primary/20 transition-colors"
>
Einladung annehmen
<ExternalLink class="h-3 w-3" />
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,144 @@
<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 hover:bg-theme-surface-hover transition-colors"
>
<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 hover:bg-theme-surface-hover transition-colors"
>
<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 hover:bg-theme-surface-hover transition-colors"
>
<div class="flex items-center gap-3">
<Users class="h-4 w-4 text-theme-text-muted" />
<div>
<p class="text-sm font-medium text-theme-text">
{shared.expand?.owner?.name || shared.expand?.owner?.username || 'Team Account'}
</p>
<p class="text-xs text-theme-text-muted">
{shared.expand?.owner?.email}
</p>
</div>
</div>
{#if currentAccount === shared.owner}
<Check class="h-4 w-4 text-theme-primary" />
{/if}
</button>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
{/if}

View file

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

View file

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

View file

@ -0,0 +1,169 @@
<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:ring-1 focus:ring-theme-accent focus:outline-none"
/>
<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="relative flex items-center gap-1.5 group/stat">
<Link class="h-3.5 w-3.5" />
<span>{tag.linkCount || 0} links</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
Used in {tag.linkCount || 0} links
</div>
</div>
<span class="text-theme-border"></span>
<div class="relative flex items-center gap-1.5 group/stat">
<MousePointer class="h-3.5 w-3.5" />
<span>{tag.totalClicks || 0} clicks</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
Total clicks: {tag.totalClicks || 0}
</div>
</div>
<span class="text-theme-border"></span>
<div class="relative flex items-center gap-1.5 group/stat">
<Hash class="h-3.5 w-3.5" />
<span>{tag.usage_count || 0} uses</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
Usage count: {tag.usage_count || 0}
</div>
</div>
{#if tag.is_public}
<span class="text-theme-border"></span>
<span class="text-green-600 dark:text-green-400 font-medium">Public</span>
{:else}
<span class="text-theme-border"></span>
<span class="font-medium">Private</span>
{/if}
</div>
</div>
<Dropdown
items={[
{
label: 'Edit',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: startEdit
},
{
label: 'View Links',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>',
color: '#2563eb',
href: `/my/links?tag=${tag.name}`
},
{
label: tag.is_public ? 'Make Private' : 'Make Public',
icon: tag.is_public
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
color: '#ea580c',
type: 'form',
formAction: '?/togglePublic',
formData: { id: tag.id, is_public: String(!tag.is_public) }
},
{
divider: true
},
{
label: 'Delete',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: tag.id },
enhanceOptions: () => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
}
}
]}
buttonText="Actions"
size="sm"
/>
</div>
{/if}
</div>

View file

@ -0,0 +1,90 @@
<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) ? 'ring-2 ring-theme-primary rounded-xl' : ''}">
{#if isSelectMode}
<div class="absolute top-3 left-3 z-10">
<input
type="checkbox"
checked={selectedTags.has(tag.id)}
onchange={() => onToggleSelect(tag.id)}
class="h-5 w-5 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer bg-white"
/>
</div>
{/if}
<TagCard {tag} />
</div>
{/each}
</div>
{:else}
<div class="rounded-xl border border-theme-border bg-theme-surface shadow-xl overflow-hidden">
<div class="border-b border-theme-border bg-theme-surface-hover px-4 sm:px-6 py-4">
<h2 class="text-lg sm:text-xl font-semibold text-theme-text">
Your Tags ({tags.length} total)
</h2>
</div>
<!-- Desktop Table Header -->
<div class="hidden lg:grid {isSelectMode ? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]' : 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text">
{#if isSelectMode}<div></div>{/if}
<div>Tag Name</div>
<div>Links</div>
<div>Clicks</div>
<div>Uses</div>
<div>Status</div>
<div class="text-right">Actions</div>
</div>
<!-- Tablet Table Header -->
<div class="hidden md:grid lg:hidden {isSelectMode ? 'grid-cols-[40px_1fr_100px_120px_140px]' : 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text">
{#if isSelectMode}<div></div>{/if}
<div>Tag Name</div>
<div>Links</div>
<div>Clicks</div>
<div class="text-right">Actions</div>
</div>
<!-- Table Body -->
<div>
{#each tags as tag}
<TagListItem
{tag}
{isSelectMode}
isSelected={selectedTags.has(tag.id)}
onToggleSelect={() => onToggleSelect(tag.id)}
/>
{/each}
</div>
</div>
{/if}
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text-muted">
No tags yet. Create your first tag to organize your links!
</p>
</div>
{/if}

View file

@ -0,0 +1,391 @@
<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 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
/>
</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:ring-1 focus:ring-theme-accent focus:outline-none"
/>
<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="text-sm text-theme-text-muted group/stat relative">
<span>{tag.linkCount || 0} links</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
Used in {tag.linkCount || 0} links
</div>
</div>
<!-- Clicks Column -->
<div class="text-sm text-theme-text-muted group/stat relative flex items-center gap-1">
<MousePointer class="h-3 w-3" />
<span>{tag.totalClicks || 0} clicks</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
Total clicks from all links: {tag.totalClicks || 0}
</div>
</div>
<!-- Uses Column -->
<div class="text-sm text-theme-text-muted group/stat relative">
<span>{tag.usage_count || 0} uses</span>
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
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 gap-2 justify-end">
<button
onclick={startEdit}
class="rounded bg-theme-primary/10 px-3 py-1 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
>
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 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
/>
</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:ring-1 focus:ring-theme-accent focus:outline-none"
/>
<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="text-sm text-theme-text-muted flex items-center gap-1">
<MousePointer class="h-3 w-3" />
<span>{tag.totalClicks || 0}</span>
</div>
<div class="flex gap-2 justify-end">
<button
onclick={startEdit}
class="rounded bg-theme-primary/10 px-3 py-1 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
>
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="md:hidden border-b border-theme-border {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:ring-1 focus:ring-theme-accent focus:outline-none"
/>
<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="flex items-center justify-between mb-2">
<label class="flex items-center gap-2 cursor-pointer">
<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 text-green-600 dark:text-green-400 font-medium">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="flex-1 rounded bg-theme-primary/10 px-3 py-2 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
>
Edit
</button>
<form
method="POST"
action="?/delete"
class="flex-1"
use:enhance={() => {
return async ({ update }) => {
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
await update();
}
};
}}
>
<input type="hidden" name="id" value={tag.id} />
<button
type="submit"
class="w-full rounded bg-red-100 px-3 py-2 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
>
Delete
</button>
</form>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,207 @@
<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:ring-2 focus:ring-theme-accent focus:outline-none"
/>
{#if isDropdownOpen && (filteredTags.length > 0 || canCreateNewTag || isCreatingTag)}
<div
class="absolute top-full right-0 left-0 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:ring-1 focus:ring-theme-accent focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
createNewTag();
} else if (e.key === 'Escape') {
isCreatingTag = false;
newTagName = '';
}
}}
/>
<button
onclick={createNewTag}
class="rounded bg-theme-primary px-3 py-1 text-sm text-white hover:bg-theme-primary-hover"
>
Add
</button>
<button
onclick={() => {
isCreatingTag = false;
newTagName = '';
}}
class="rounded bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
{/if}
{#each filteredTags as tag}
<button
onclick={() => toggleTag(tag)}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TagBadge {tag} size="sm" />
</button>
{/each}
{#if canCreateNewTag && !isCreatingTag}
<button
onclick={() => {
isCreatingTag = true;
newTagName = searchQuery;
}}
class="flex w-full items-center gap-2 border-t border-theme-border px-3 py-2 text-left text-sm text-theme-accent hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Create "{searchQuery}"
</button>
{/if}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,267 @@
<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 text-lg font-bold text-theme-text truncate">{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="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-theme-text truncate max-w-[200px]">
{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="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-theme-text truncate max-w-[200px]">
{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="rounded-xl border border-theme-border bg-theme-surface shadow-md overflow-hidden">
<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="hover:bg-theme-surface-hover transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<div
class="h-3 w-3 rounded-full"
style="background-color: {tag.color}"
></div>
<span class="font-medium text-theme-text">{tag.name}</span>
</div>
</td>
<td class="px-6 py-4 text-theme-text">
{tag.linkCount || 0}
</td>
<td class="px-6 py-4 text-theme-text">
{formatNumber(tag.totalClicks || 0)}
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400">
{calculateCTR(tag)}
</span>
</td>
<td class="px-6 py-4 text-theme-text">
{tag.usage_count || 0}
</td>
<td class="px-6 py-4">
{#if tag.is_public}
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400">
Öffentlich
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-900/20 dark:text-gray-400">
Privat
</span>
{/if}
</td>
<td class="px-6 py-4 text-sm text-theme-text-muted">
{new Date(tag.created).toLocaleDateString('de-DE')}
</td>
</tr>
{/each}
</tbody>
</table>
{#if tags.length === 0}
<div class="px-6 py-12 text-center">
<p class="text-theme-text-muted">Keine Tags vorhanden</p>
</div>
{/if}
</div>
</div>
</div>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,195 @@
<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="flex-1 min-w-0">
<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="flex-1 min-w-0">
<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="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all hover:bg-theme-primary/10"
>
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary/10">
<Plus class="h-3.5 w-3.5 text-theme-primary" />
</div>
<span class="text-theme-text">Create Workspace</span>
</button>
</div>
</div>
{/if}
</div>
<style>
/* Custom styles if needed */
</style>

View file

@ -0,0 +1,139 @@
<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:shadow-lg hover:border-theme-accent {cardClasses()} {featured ? 'ring-2 ring-theme-primary' : ''}"
onmouseenter={() => isHovered = true}
onmouseleave={() => isHovered = false}
>
{#if post.image && viewMode === 'cards'}
<div class="relative h-48 w-full overflow-hidden bg-gradient-to-br from-theme-primary/5 to-theme-accent/5">
<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 top-3 left-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="relative h-32 w-48 flex-shrink-0 overflow-hidden rounded-lg bg-gradient-to-br from-theme-primary/5 to-theme-accent/5">
<img
src={post.image}
alt={post.title}
class="h-full w-full object-cover"
loading="lazy"
/>
{#if featured}
<div class="absolute top-2 left-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="mb-2 inline-block rounded-full bg-theme-primary/10 px-3 py-1 text-xs font-semibold text-theme-primary">
Featured
</span>
{/if}
<h3 class="mb-2 text-lg font-semibold text-theme-text line-clamp-2">
<a
href="/blog/{post.slug}"
class="transition-colors hover:text-theme-primary"
>
{post.title}
</a>
</h3>
<p class="mb-4 text-sm text-theme-text-muted line-clamp-2">
{post.excerpt}
</p>
<div class="mt-auto flex items-center justify-between text-xs text-theme-text-muted">
<time datetime={post.date.toISOString()}>
{formattedDate}
</time>
<span class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{readingTimeText}
</span>
</div>
{#if post.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1.5">
{#each post.tags.slice(0, 3) as tag}
<a
href="/blog?tag={tag}"
class="inline-flex items-center rounded-full border border-theme-border bg-theme-background px-2 py-0.5 text-xs text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
>
#{tag}
</a>
{/each}
{#if post.tags.length > 3}
<span class="inline-flex items-center px-2 py-0.5 text-xs text-theme-text-muted">
+{post.tags.length - 3}
</span>
{/if}
</div>
{/if}
</div>
</article>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,157 @@
<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 -top-2 -left-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="flex-1 rounded bg-theme-primary/10 px-3 py-1.5 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
>
Edit Card
</button>
<Dropdown
items={dropdownItems}
buttonText="Actions"
size="sm"
/>
</div>
</div>
<!-- Drag Handle -->
<div
class="absolute top-4 right-4 opacity-0 transition-opacity group-hover:opacity-100"
>
<svg
class="h-6 w-6 text-theme-text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/>
</svg>
</div>
</div>

View file

@ -0,0 +1,216 @@
<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 rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden {className}" class:p-3={compact} class:p-6={!compact} style="{cardCustomStyles()}">
{#if cardData?.type === 'modular'}
<!-- Modular card rendering -->
<div class="space-y-4">
<!-- Header Module -->
{#if headerModule}
<div class="text-center">
<!-- Avatar -->
{#if headerModule.props?.avatar}
<div class="mx-auto mb-3 h-16 w-16 overflow-hidden rounded-full bg-gray-100">
<img
src={headerModule.props.avatar}
alt="Avatar"
class="h-full w-full object-cover"
onerror={(e) => {
e.currentTarget.style.display = 'none';
if (e.currentTarget.nextElementSibling) {
e.currentTarget.nextElementSibling.style.display = 'flex';
}
}}
/>
<div class="hidden h-full w-full items-center justify-center bg-gray-200 text-lg font-bold text-gray-600">
{(headerModule.props?.title || 'U')[0].toUpperCase()}
</div>
</div>
{:else if headerModule.props?.title}
<div class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-lg font-bold text-white">
{headerModule.props.title[0].toUpperCase()}
</div>
{/if}
<!-- Title and Subtitle -->
{#if headerModule.props?.title}
<h3 class="text-xl font-bold text-gray-900">{headerModule.props.title}</h3>
{/if}
{#if headerModule.props?.subtitle}
<p class="mt-1 text-sm text-gray-600">{headerModule.props.subtitle}</p>
{/if}
</div>
{/if}
<!-- Links Module -->
{#if linksModule?.props?.links && linksModule.props.links.length > 0}
<div class="space-y-2">
{#each linksModule.props.links as link}
<div class="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
{#if link.icon}
<span class="text-lg">{link.icon}</span>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-gray-900">{link.title || link.original_url}</div>
{#if link.description}
<div class="truncate text-sm text-gray-600">{link.description}</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if cardData?.type === 'template'}
<!-- Template card rendering -->
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900">Template Card</h3>
<p class="mt-2 text-sm text-gray-600">Template: {cardData.template}</p>
</div>
{:else if cardData?.type === 'custom'}
<!-- Custom HTML card rendering -->
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900">Custom Card</h3>
<p class="mt-2 text-sm text-gray-600">Custom HTML/CSS Card</p>
</div>
{:else}
<!-- Fallback for invalid cards -->
<div class="text-center">
<div class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900">{card.title || card.metadata?.name || 'Unnamed Card'}</h3>
{#if card.subtitle}
<p class="mt-1 text-sm text-gray-600">{card.subtitle}</p>
{/if}
</div>
{/if}
<!-- Metadata -->
{#if showMetadata}
<div class="mt-4 border-t border-gray-200 pt-3">
<div class="flex items-center justify-between text-xs text-gray-500">
<span>{card.metadata?.name || 'Unnamed Card'}</span>
<span>{card.config?.mode || 'unknown'} mode</span>
</div>
</div>
{/if}
</div>
<style>
.safe-card-renderer {
background-color: var(--card-bg, #ffffff);
border-color: var(--card-border, #e2e8f0);
color: var(--card-text, #0f172a);
}
.safe-card-renderer :global(a),
.safe-card-renderer :global(.card-link) {
color: var(--card-links, #0ea5e9);
}
.safe-card-renderer :global(a:hover),
.safe-card-renderer :global(.card-link:hover) {
color: var(--card-links, #0ea5e9);
opacity: 0.8;
}
/* Apply card text color to various text elements */
.safe-card-renderer :global(h1),
.safe-card-renderer :global(h2),
.safe-card-renderer :global(h3),
.safe-card-renderer :global(h4),
.safe-card-renderer :global(h5),
.safe-card-renderer :global(h6),
.safe-card-renderer :global(p),
.safe-card-renderer :global(.text-gray-900) {
color: var(--card-text, #0f172a);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,60 @@
<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="flex h-12 w-12 items-center justify-center rounded-full bg-theme-primary/10">
<span class="text-2xl">{icon}</span>
</div>
{/if}
<div>
{#if title}
<h3 class="text-lg font-semibold text-theme-text">
{title}
{#if badge}
<span class="ml-2 rounded-full bg-theme-accent px-2 py-0.5 text-xs text-white">
{badge}
</span>
{/if}
</h3>
{/if}
{#if subtitle}
<p class="text-sm text-theme-text-muted">{subtitle}</p>
{/if}
</div>
</div>
{#if actions.length > 0}
<div class="flex gap-2">
{#each actions as action}
<button
onclick={action.action}
class="rounded-lg p-2 text-theme-text hover:bg-theme-surface-hover"
title={action.label}
>
{#if action.icon}
<span>{action.icon}</span>
{:else}
<span class="text-sm">{action.label}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,288 @@
<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 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-xl"
transition:slide={{ duration: 300 }}
>
<div class="max-w-7xl mx-auto p-4 md:p-6">
{#if !showDetails}
<!-- Basis Cookie Banner -->
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<!-- Content -->
<div class="flex-1">
<div class="flex items-start space-x-3">
<!-- Cookie Icon -->
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg class="w-5 h-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="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Cookies & Datenschutz
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
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 dark:text-blue-400 hover:underline">
Datenschutzerklärung
</a>
<a href="/impressum" class="text-blue-600 dark:text-blue-400 hover:underline">
Impressum
</a>
<button
onclick={toggleDetails}
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Details anzeigen
</button>
</div>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
<button
onclick={handleAcceptNecessary}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Nur notwendige
</button>
<button
onclick={toggleDetails}
class="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-600 dark:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
>
Anpassen
</button>
<button
onclick={handleAcceptAll}
class="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
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 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
aria-label="Schließen"
>
<svg class="w-5 h-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="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<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="text-sm text-gray-500 mr-2">Immer aktiv</span>
<div class="w-10 h-6 bg-green-600 rounded-full relative">
<div class="w-4 h-4 bg-white rounded-full absolute top-1 right-1"></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="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<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="w-10 h-6 rounded-full transition-colors {customConsent.analytics ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
<div class="w-4 h-4 bg-white rounded-full absolute top-1 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="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<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="w-10 h-6 rounded-full transition-colors {customConsent.marketing ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
<div class="w-4 h-4 bg-white rounded-full absolute top-1 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="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<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="w-10 h-6 rounded-full transition-colors {customConsent.preferences ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
<div class="w-4 h-4 bg-white rounded-full absolute top-1 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 sm:flex-row gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onclick={handleAcceptNecessary}
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Nur notwendige Cookies
</button>
<button
onclick={handleSaveCustom}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Auswahl speichern
</button>
<button
onclick={handleAcceptAll}
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
>
Alle akzeptieren
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
/* Custom animations */
.toggle-switch {
transition: all 0.2s ease-in-out;
}
/* Focus styles for accessibility */
button:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,107 @@
<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="py-16 bg-theme-surface">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-theme-text mb-4">
Insights & Wissen
</h2>
<p class="text-lg text-theme-text-muted max-w-2xl mx-auto">
Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für erfolgreiches Link-Management.
</p>
</div>
{#if formattedPosts.length > 0}
<div class="grid md:grid-cols-3 gap-8 mb-8">
{#each formattedPosts as post}
<article class="bg-theme-background rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden">
{#if post.image}
<img
src={post.image}
alt={post.title}
class="w-full h-48 object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-48 bg-gradient-to-br from-blue-500 to-purple-600"></div>
{/if}
<div class="p-6">
<div class="flex items-center gap-2 mb-3">
<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="text-xl font-semibold text-theme-text mb-2">
<a href="/blog/{post.slug}" class="hover:text-theme-primary transition">
{post.title}
</a>
</h3>
<p class="text-theme-text-muted mb-4 line-clamp-2">
{post.excerpt}
</p>
<a
href="/blog/{post.slug}"
class="text-theme-primary hover:text-theme-primary-hover font-medium inline-flex items-center gap-1"
>
Weiterlesen
<svg class="w-4 h-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="text-center py-12">
<p class="text-theme-text-muted mb-4">
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 px-6 py-3 bg-theme-primary text-white rounded-lg hover:bg-theme-primary-hover transition"
>
Alle Artikel ansehen
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</div>
</section>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,378 @@
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
>
<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="mt-6 rounded-lg bg-theme-primary/10 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="h-16 w-8 rounded bg-theme-primary/20"></div>
<div class="h-24 w-8 rounded bg-theme-primary/40"></div>
<div class="h-32 w-8 rounded bg-theme-primary/60"></div>
<div class="h-28 w-8 rounded bg-theme-primary/80"></div>
<div class="h-36 w-8 rounded bg-theme-primary"></div>
<div class="h-30 w-8 rounded bg-theme-primary/90"></div>
<div class="h-26 w-8 rounded bg-theme-primary/70"></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="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">PNG</span>
<span class="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">SVG</span>
<span class="rounded bg-theme-primary/10 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="h-8 w-8 rounded-full bg-theme-primary/20"></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="mt-4 w-full rounded-lg bg-theme-primary/10 py-2 text-sm font-medium text-theme-primary hover:bg-theme-primary/20">
Alle Templates ansehen →
</button>
</div>
{/if}
</div>
</div>
</div>
</div>
</section>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View file

@ -0,0 +1,164 @@
<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="relative overflow-hidden bg-gradient-to-br from-theme-primary/5 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="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-theme-primary/10 blur-3xl"></div>
<div class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 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="flex flex-col gap-3 rounded-xl border border-theme-border bg-theme-surface/80 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="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-theme-primary/10">
<svg class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Smart Links</h3>
<p class="text-sm text-theme-text-muted">
Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
</p>
<div class="mt-4 text-xs text-theme-primary group-hover:underline">
Mehr erfahren →
</div>
</div>
<!-- Profile cards preview -->
<div class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-600/10">
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Profile Cards</h3>
<p class="text-sm text-theme-text-muted">
Beeindruckende Profilseiten mit Drag & Drop Builder
</p>
<div class="mt-4 text-xs text-purple-600 group-hover:underline">
Templates ansehen →
</div>
</div>
<!-- Team collaboration preview -->
<div class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-600/10">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<h3 class="mb-2 font-semibold text-theme-text">Team Workspace</h3>
<p class="text-sm text-theme-text-muted">
Gemeinsam Links verwalten mit granularen Berechtigungen
</p>
<div class="mt-4 text-xs text-green-600 group-hover:underline">
Für Teams →
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,250 @@
<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'
: 'text-theme-text hover:text-theme-text/80'}"
>
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'
: 'text-theme-text hover:text-theme-text/80'}"
>
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
? 'border-theme-primary shadow-2xl scale-105'
: 'border-theme-border hover:border-theme-primary/50 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'
: 'bg-theme-surface border-2 border-theme-border text-theme-text hover:border-theme-primary hover:bg-theme-primary/5'}"
>
{plan.cta}
</button>
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wide text-theme-text-muted">
Inklusive:
</p>
{#each plan.features as feature}
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-theme-text">{feature}</span>
</div>
{/each}
{#if plan.limitations.length > 0}
<div class="mt-4 border-t border-theme-border pt-4">
{#each plan.limitations as limitation}
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-sm text-theme-text-muted">{limitation}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<!-- FAQ or Additional Info -->
<div class="mt-16 rounded-xl border border-theme-border bg-theme-surface p-8">
<div class="grid gap-8 lg:grid-cols-3">
<div>
<h4 class="mb-2 font-semibold text-theme-text">💳 Keine Kreditkarte erforderlich</h4>
<p class="text-sm text-theme-text-muted">
Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-theme-text">🔄 Jederzeit kündbar</h4>
<p class="text-sm text-theme-text-muted">
Keine Vertragsbindung. Kündige monatlich ohne Probleme.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-theme-text">🚀 Sofort startklar</h4>
<p class="text-sm text-theme-text-muted">
Nach der Anmeldung kannst du sofort alle Features nutzen.
</p>
</div>
</div>
</div>
<!-- Enterprise CTA -->
<div class="mt-12 text-center">
<p class="mb-4 text-theme-text">
Benötigst du eine maßgeschneiderte Lösung für dein Unternehmen?
</p>
<a
href="/contact"
class="inline-flex items-center gap-2 text-theme-primary hover:underline"
>
Kontaktiere uns für Enterprise-Lösungen
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,211 @@
<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="flex h-12 w-12 items-center justify-center rounded-full bg-theme-primary/10 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="rounded bg-theme-primary/10 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="mt-16 rounded-xl border border-theme-border bg-gradient-to-r from-theme-primary/5 to-purple-600/5 p-8">
<h3 class="mb-8 text-center text-2xl font-bold text-theme-text">
Perfekt für diese Use Cases
</h3>
<div class="grid gap-8 lg:grid-cols-2">
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-pink-500/10">
<span class="text-2xl">📱</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Social Media Bio Links</h4>
<p class="text-sm text-theme-text-muted">Instagram & TikTok</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem
Drag & Drop Builder und tracke jeden Klick in Echtzeit.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/10">
<span class="text-2xl">🍽️</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Digitale Speisekarten</h4>
<p class="text-sm text-theme-text-muted">Restaurants & Cafés</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte
ohne neue Codes drucken zu müssen.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/10">
<span class="text-2xl">📊</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Marketing Kampagnen</h4>
<p class="text-sm text-theme-text-muted">Performance Tracking</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau,
welche Kanäle am besten performen.
</p>
</div>
<div class="rounded-lg bg-theme-surface p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/10">
<span class="text-2xl">🎯</span>
</div>
<div>
<h4 class="font-semibold text-theme-text">Event Management</h4>
<p class="text-sm text-theme-text-muted">Tickets & Info-Links</p>
</div>
</div>
<p class="text-sm text-theme-text-muted">
Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für
exklusive Inhalte und VIP-Bereiche.
</p>
</div>
</div>
</div>
<!-- CTA -->
<div class="mt-12 text-center">
<p class="mb-6 text-lg text-theme-text">
Sei einer der Ersten - starte jetzt kostenlos!
</p>
<a
href="/register"
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-theme-primary-hover hover:shadow-xl"
>
Beta-Zugang sichern
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</section>

View file

@ -0,0 +1,211 @@
<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="rounded-xl border border-theme-border bg-gradient-to-br from-theme-surface to-theme-primary/5 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="mt-12 rounded-xl border border-theme-border bg-theme-surface/50 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="inline-flex items-center gap-2 rounded-lg border-2 border-theme-primary bg-theme-primary/10 px-6 py-3 font-medium text-theme-primary transition hover:bg-theme-primary hover:text-white"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
security@ulo.ad
</a>
</div>
</div>
</section>

View file

@ -0,0 +1,442 @@
<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="group relative p-6 transition-all duration-200 hover:bg-gradient-to-br hover:from-theme-surface/50 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="text-lg font-semibold text-theme-text truncate">
{link.title || link.short_code}
</h3>
{#if link.description}
<p class="mt-1 text-sm text-theme-text-muted line-clamp-2">{link.description}</p>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
<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="rounded-lg bg-gradient-to-r from-blue-50/50 to-purple-50/50 dark:from-blue-950/20 dark:to-purple-950/20 p-3 border border-theme-border/50">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="text-[10px] uppercase tracking-wider text-theme-text-muted mb-1 font-medium">Short URL</p>
{#if link.short_code.includes('/')}
<a
href="/{link.short_code}"
target="_blank"
class="text-sm font-mono font-medium text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 truncate block"
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="text-sm font-mono font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 truncate block"
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="p-1.5 rounded-md hover:bg-white/50 dark:hover:bg-black/20 transition-colors"
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="text-xs text-theme-text-muted truncate">
<span class="font-medium">Destination:</span>
<a
href={link.original_url}
target="_blank"
rel="noopener noreferrer"
class="hover:text-blue-600 ml-1"
>
{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="flex flex-wrap items-center gap-4 pt-3 border-t border-theme-border/30">
<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="flex items-center gap-1.5 ml-auto">
<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="mt-4 rounded-lg bg-theme-surface p-4 border border-theme-border/50">
<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 gap-2 flex-wrap justify-center">
<Button
onclick={downloadQR}
variant="primary"
size="lg"
>
Download{qrRotation !== 0 ? ` (${qrRotation}°)` : ''} as {qrFormat.toUpperCase()}
</Button>
<Button
onclick={() => copyToClipboard(formatUrl(link.short_code), 'qr-copy', link.short_code)}
variant="secondary"
size="lg"
>
Copy URL
</Button>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,221 @@
<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="text-sm font-mono text-blue-600 hover:text-blue-800"
onclick={() =>
trackLinkClick({
shortCode: link.short_code,
username: username || 'direct',
hasPassword: !!link.password,
isExpiring: !!link.expires_at
})}
>
{formatUrl(link.short_code)}
</a>
</div>
<p class="truncate text-sm text-theme-text-muted">
{link.original_url}
</p>
{#if link.description}
<p class="text-sm text-theme-text">{link.description}</p>
{/if}
</div>
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-4 text-xs text-theme-text-muted">
<span>Clicks: {link.clicks || 0}</span>
{#if link.expires_at}
<span class="text-orange-600">
Expires: {new Date(link.expires_at).toLocaleDateString()}
</span>
{/if}
{#if link.max_clicks}
<span class="text-purple-600">Max: {link.max_clicks}</span>
{/if}
{#if link.password}
<span class="text-red-600">🔒</span>
{/if}
</div>
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="rounded-lg bg-theme-primary px-3 py-1 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
{copiedStates[link.id] ? '✓ Copied' : '📋 Copy'}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,316 @@
<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 opacity-0 overflow-hidden'}">
<div class="rounded-xl border-2 border-theme-accent/30 bg-gradient-to-br from-theme-surface via-theme-surface to-theme-accent/5 p-6 shadow-2xl sm:p-8">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl">
+
</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="px-3 py-1.5 text-sm font-medium rounded-lg 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="text-theme-text-muted hover:text-theme-text transition-colors p-1 hover:bg-theme-surface-hover rounded-lg"
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="text-theme-accent mr-2">1.</span>
URLs eingeben (eine pro Zeile)
</label>
<textarea
id="bulk_urls"
name="bulk_urls"
rows="6"
required
placeholder="https://beispiel.de&#10;https://google.com&#10;https://github.com"
bind:value={bulkUrls}
class="w-full rounded-lg border-2 border-theme-border bg-theme-surface px-4 py-3 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none font-mono text-sm"
></textarea>
<p class="mt-2 text-sm text-theme-text-muted flex items-center gap-2">
<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="text-theme-accent mr-2">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:ring-2 focus:ring-theme-accent focus:outline-none"
>
<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 p-3 rounded-lg bg-theme-surface-hover">
<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 items-center justify-center rounded-lg bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 px-6 py-4 font-semibold text-white transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
{#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 p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-800">
<h3 class="font-semibold text-green-800 dark:text-green-300 mb-3">
{createdLinks.length} Links erfolgreich erstellt:
</h3>
<div class="space-y-2 max-h-60 overflow-y-auto">
{#each createdLinks as link, i}
<div class="flex items-center gap-2 p-2 rounded bg-white dark:bg-theme-surface">
<code class="flex-1 text-sm font-mono text-green-700 dark:text-green-400">
{link.url}
</code>
<button
type="button"
onclick={() => copyToClipboard(link.url, `bulk-${i}`)}
class="px-2 py-1 text-xs font-medium rounded transition-colors {copiedStates[`bulk-${i}`] ? 'bg-green-600 text-white' : 'bg-green-700 text-white hover:bg-green-800'}"
>
{copiedStates[`bulk-${i}`] ? '✓' : '📋'}
</button>
</div>
{/each}
</div>
</div>
{/if}
</form>
{:else}
<!-- Single Link Progressive Form -->
<LinkCreationForm
{user}
{tags}
{folders}
{workspace}
{editingLink}
mode="advanced"
onSuccess={handleSingleSuccess}
/>
{/if}
</div>
</div>

View file

@ -0,0 +1,905 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TagSelector from '$lib/components/TagSelector.svelte';
import type { Tag } from '$lib/pocketbase';
import { toastMessages, notify } from '$lib/services/toast';
import { trackLinkCreated } from '$lib/analytics';
import { activeWorkspace } from '$lib/stores/activeWorkspace';
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;
editingLink?: any;
action?: string;
mode?: 'simple' | 'advanced';
onSuccess?: (link: any, shortUrl: string) => void;
onCancel?: () => void;
}
let {
user,
folders = [],
tags = [],
workspace,
editingLink,
action = '?/create',
mode = 'advanced',
onSuccess,
onCancel
}: Props = $props();
// Get active workspace data
let activeWorkspaceData = $state(activeWorkspace.getData());
// Subscribe to activeWorkspace changes
$effect(() => {
const unsubData = activeWorkspace.data.subscribe(data => {
activeWorkspaceData = data;
});
return () => {
unsubData();
};
});
// Dynamic action based on editing state and workspace
let dynamicAction = $derived(
editingLink
? '?/update' + (workspace?.id ? `&workspace=${workspace.id}` : '')
: action + (workspace?.id ? `&workspace=${workspace.id}` : '')
);
let isSubmitting = $state(false);
let linkPreview = $state('');
let selectedTags = $state<Tag[]>([]);
let showAdvancedOptions = $state(false);
let showSocialMediaOptions = $state(false);
let urlPreview = $state('');
let isValidUrl = $state(false);
let error = $state<string | null>(null);
let customCode = $state('');
let useUsername = $state(false);
let copiedToClipboard = $state(false);
let createdLink = $state<{url: string, shortCode: string} | null>(null);
let showSuccess = $state(false);
// Progressive form states
let currentStep = $state(1);
let completedSteps = $state<Set<number>>(new Set());
let formData = $state({
url: '',
title: '',
customCode: '',
useUsername: false
});
let showShortlinkPreview = $state(false);
let generatedCode = $state('');
// Generate a random short code (same logic as server)
function generateShortCode(length: number = 6): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function updateLinkPreview() {
if (!window) return;
// Use custom code if provided, otherwise use generated code
const code = formData.customCode || customCode || generatedCode || '[code]';
console.log('📋 updateLinkPreview - codes:', {
customCode,
generatedCode,
formDataCustomCode: formData.customCode,
finalCode: code,
workspace: workspace
});
// Check if user wants prefix
if (formData.useUsername || useUsername) {
// User wants prefix - check workspace context first
if (activeWorkspaceData?.slug) {
// Workspace URL format
linkPreview = `${window.location.origin}/w/${activeWorkspaceData.slug}/${code}`;
} else if (user?.username) {
// Personal workspace with username
linkPreview = `${window.location.origin}/u/${user.username}/${code}`;
} else {
// No prefix available, just simple format
linkPreview = `${window.location.origin}/${code}`;
}
} else {
// User doesn't want prefix - simple format
linkPreview = `${window.location.origin}/${code}`;
}
}
$effect(() => {
updateLinkPreview();
});
// Populate form when editing a link
$effect(() => {
if (editingLink) {
formData = {
url: editingLink.original_url || '',
title: editingLink.title || '',
customCode: editingLink.short_code || '',
useUsername: editingLink.use_username || false
};
// Set other form fields
customCode = editingLink.short_code || '';
useUsername = editingLink.use_username || false;
// Set selected tags if available
if (editingLink.expand?.['link_tags(link_id)']) {
selectedTags = editingLink.expand['link_tags(link_id)']
.map((linkTag: any) => linkTag.expand?.tag_id)
.filter(Boolean);
}
console.log('🔄 Editing link:', editingLink);
console.log('📝 Form data set to:', formData);
} else if (!editingLink && !generatedCode && isValidUrl) {
// Generate code for new links when URL becomes valid
generatedCode = generateShortCode();
console.log('🎲 Generated initial code for new link:', generatedCode);
}
});
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
copiedToClipboard = true;
setTimeout(() => (copiedToClipboard = false), 2000);
}
function validateAndPreviewUrl(event: Event) {
const input = event.target as HTMLInputElement;
const url = input.value?.trim();
console.log('🔍 LinkCreationForm: Validating URL:', url);
// Update formData
formData.url = url;
if (!url) {
urlPreview = '';
isValidUrl = false;
showShortlinkPreview = false;
generatedCode = '';
return;
}
try {
// Add protocol if missing
let testUrl = url;
if (!url.match(/^https?:\/\//)) {
testUrl = 'https://' + url;
}
new URL(testUrl);
urlPreview = url;
isValidUrl = true;
showShortlinkPreview = true;
// Generate a random code for preview when URL becomes valid
if (!generatedCode) {
generatedCode = generateShortCode();
}
// Auto-advance after valid URL
if (isValidUrl && !completedSteps.has(1)) {
completedSteps.add(1);
// Show shortlink immediately, don't auto-advance
currentStep = 2;
}
} catch {
urlPreview = url;
isValidUrl = false;
showShortlinkPreview = false;
generatedCode = '';
formData.url = url; // Make sure it's set even if invalid
}
}
function nextStep() {
// New step order:
// 1. URL
// 2. Shortlink preview + username option
// 3. Title
// 4. Tags/Folders (if available)
let maxSteps = 3; // URL + Shortlink + Title
if (user && (tags?.length > 0 || folders?.length > 0)) maxSteps = 4; // + Tags/Folders
if (currentStep < maxSteps) {
currentStep++;
// Focus next input after animation
setTimeout(() => {
const nextInput = document.querySelector(`[data-step="${currentStep}"]`) as HTMLInputElement;
if (nextInput) nextInput.focus();
}, 150);
}
}
function handleKeydown(event: KeyboardEvent, step: number) {
if (event.key === 'Enter' || event.key === 'Tab') {
if (step < currentStep) return; // Already completed
if (event.key === 'Enter') {
event.preventDefault();
}
// Validate current step before advancing
if (step === 1 && !isValidUrl) return;
if (!completedSteps.has(step)) {
completedSteps.add(step);
}
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
nextStep();
} else if (event.key === 'Enter') {
nextStep();
}
}
}
function handleSubmit() {
console.log('🚀 LinkCreationForm: handleSubmit called');
console.log('Generated code being sent:', generatedCode);
isSubmitting = true;
error = null;
return async ({ result, update, formData }: any) => {
console.log('📦 LinkCreationForm: Form enhance callback triggered');
console.log('Result type:', result.type);
console.log('Result data:', result.data);
if (result.type === 'success') {
console.log('✅ LinkCreationForm: Success! Link created');
console.log('Link data:', result.data?.link);
console.log('Short URL:', result.data?.shortUrl);
toastMessages.linkCreated();
selectedTags = [];
if (result.data?.link) {
const shortUrl = result.data.shortUrl ||
(useUsername && user?.username
? `${window.location.origin}/u/${user.username}/${result.data.link.short_code}`
: `${window.location.origin}/${result.data.link.short_code}`);
createdLink = {
url: shortUrl,
shortCode: result.data.link.short_code
};
// Show success animation
showSuccess = true;
setTimeout(() => showSuccess = false, 2000);
trackLinkCreated({
shortCode: result.data.link.short_code,
hasPassword: !!result.data.link.password,
hasExpiry: !!result.data.link.expires_at,
hasClickLimit: !!result.data.link.max_clicks
});
if (onSuccess) {
onSuccess(result.data.link, shortUrl);
}
// Reset form after successful creation
setTimeout(() => {
formData = { url: '', title: '', customCode: '', useUsername: false };
customCode = '';
useUsername = false;
urlPreview = '';
isValidUrl = false;
currentStep = 1;
completedSteps.clear();
createdLink = null;
generatedCode = '';
}, 3000);
}
} else if (result.type === 'failure' && result.data?.error) {
console.error('❌ LinkCreationForm: Failed to create link');
console.error('Error:', result.data.error);
error = result.data.error;
// Special handling for limit exceeded errors
if (result.data?.limit_exceeded) {
const limitMsg = `Monatslimit erreicht! Du hast ${result.data.current_count}/${result.data.limit} Links verwendet.`;
notify.error('Link-Limit erreicht', limitMsg + ' Upgrade für mehr Links!');
} else {
notify.error(m.error_link_creation_single(), result.data.error);
}
} else {
console.warn('⚠️ LinkCreationForm: Unexpected result type');
console.warn('Full result:', result);
}
await update();
isSubmitting = false;
console.log('🏁 LinkCreationForm: handleSubmit complete');
};
}
</script>
<form
method="POST"
action={dynamicAction}
use:enhance={handleSubmit}
class="max-w-2xl mx-auto"
onsubmit={() => console.log('📤 Form onsubmit event fired!')}
>
{#if editingLink}
<input type="hidden" name="id" value={editingLink.id} />
{/if}
<!-- Send generated code to server -->
<input type="hidden" name="generated_code" bind:value={generatedCode} />
{#if generatedCode}
<input type="hidden" name="debug_generated_code" value={generatedCode} />
{/if}
<div class="space-y-6">
<!-- Step 1: URL Input - Compact Layout -->
<div class="transition-all duration-300 ease-out {currentStep >= 1 ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}">
<div class="flex items-center gap-3">
<label
for="url"
class="text-lg font-medium text-theme-text whitespace-nowrap"
>
URL kürzen:
</label>
<div class="flex-1 relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<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"></path>
</svg>
</div>
<input
type="url"
id="url"
name="url"
data-step="1"
required
placeholder="https://beispiel.de"
value={formData.url}
oninput={validateAndPreviewUrl}
onkeydown={(e) => handleKeydown(e, 1)}
class="w-full pl-10 pr-10 rounded-lg border-2 {isValidUrl ? 'border-green-500 bg-green-50/50 dark:bg-green-900/20 focus:ring-green-500 focus:border-green-500' : error && formData.url ? 'border-red-500 bg-red-50/50 dark:bg-red-900/20 focus:ring-red-500 focus:border-red-500' : 'border-theme-border bg-theme-surface focus:ring-2 focus:ring-theme-accent focus:border-theme-accent'} px-4 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none transition-all shadow-sm hover:shadow-md"
/>
{#if isValidUrl}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
{/if}
</div>
</div>
{#if urlPreview && !isValidUrl}
<div class="mt-1 text-sm text-red-600 dark:text-red-400 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Bitte geben Sie eine gültige URL ein (z.B. https://beispiel.de)
</div>
{/if}
</div>
<!-- Step 2: Shortlink Preview + Options (appears immediately after valid URL) -->
{#if showShortlinkPreview && isValidUrl}
<div class="transition-all duration-300 ease-out opacity-100 translate-y-0 space-y-4">
<!-- Shortlink Preview -->
<div class="rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-2 border-blue-300 dark:border-blue-700 p-4">
<div class="flex items-center gap-3">
<div class="flex-none">
<div class="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<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="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"></path>
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-blue-900 dark:text-blue-300 mb-1">
✨ Ihre kurze URL wird sein:
</p>
<div class="flex items-center gap-2">
<code class="text-base font-mono text-blue-700 dark:text-blue-400 truncate flex-1">
{linkPreview || `${window.location.origin}/[code]`}
</code>
<button
type="button"
onclick={() => copyToClipboard(linkPreview)}
class="flex-none px-3 py-1.5 text-xs font-medium rounded-md transition-all duration-200 {copiedToClipboard ? 'bg-green-500 text-white scale-105' : 'bg-blue-600 text-white hover:bg-blue-700 hover:scale-105'}"
>
{copiedToClipboard ? '✓' : '📋'}
</button>
</div>
</div>
</div>
</div>
<!-- Workspace Indicator (when in workspace context) -->
{#if workspace?.slug}
<div class="flex items-center gap-2 rounded-lg border-2 border-purple-500 bg-purple-50 dark:bg-purple-900/30 px-3 py-2">
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
<span class="font-medium text-theme-text text-sm">
Workspace-Link: <span class="font-mono text-purple-600 dark:text-purple-400">/w/{workspace.slug}/</span>
</span>
</div>
<!-- Custom Code Field for workspace -->
{#if mode === 'advanced'}
<input
type="text"
id="custom_code"
name="custom_code"
value={customCode}
oninput={(e) => {
customCode = e.currentTarget.value;
formData.customCode = e.currentTarget.value;
}}
placeholder="Eigener Code (optional)"
pattern="[a-zA-Z0-9_\-]+"
title="Nur Buchstaben, Zahlen, Bindestriche und Unterstriche erlaubt"
class="flex-1 rounded-lg border-2 border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text placeholder-theme-text-muted hover:border-theme-border-hover focus:ring-2 focus:ring-theme-accent focus:outline-none transition-all"
/>
{/if}
{/if}
<!-- Username Option as Compact Button (only show if not in workspace context) -->
{#if user && !workspace?.slug}
<div class="flex items-center gap-3">
<button
type="button"
onclick={() => {
useUsername = !useUsername;
formData.useUsername = !useUsername;
// Clear custom code when disabling username
if (!useUsername && mode === 'advanced') {
customCode = '';
formData.customCode = '';
}
// Regenerate code when switching username mode for better UX
if (useUsername && !customCode) {
// Keep the same generated code
} else if (!useUsername) {
// Generate new code for non-username mode
generatedCode = generateShortCode();
}
}}
class="flex items-center gap-2 rounded-lg border-2 transition-all duration-200 {useUsername
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-md'
: 'border-theme-border bg-theme-surface hover:border-blue-300 hover:bg-theme-surface-hover hover:shadow-sm'} px-3 py-2 cursor-pointer"
>
<!-- Checkbox indicator -->
<div class="h-5 w-5 rounded border-2 flex items-center justify-center transition-all {useUsername
? 'border-blue-500 bg-blue-500'
: 'border-gray-400 bg-white dark:bg-gray-800'}">
{#if useUsername}
<svg class="h-3 w-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
{/if}
</div>
<span class="font-medium text-theme-text text-sm">
{#if activeWorkspaceData?.slug}
Workspace in URL verwenden <span class="font-mono text-blue-600 dark:text-blue-400">/w/{activeWorkspaceData.slug}/</span>
{:else if user?.username}
Benutzername in URL verwenden <span class="font-mono text-blue-600 dark:text-blue-400">/u/{user.username}/</span>
{:else}
Prefix in URL verwenden
{/if}
</span>
<!-- Hidden checkbox for form submission -->
<input
type="checkbox"
id="use_username"
name="use_username"
checked={useUsername}
class="sr-only"
/>
</button>
<!-- Custom Code Field - Only show when username is selected -->
{#if mode === 'advanced' && useUsername}
<input
type="text"
id="custom_code"
name="custom_code"
value={customCode}
oninput={(e) => {
customCode = e.currentTarget.value;
formData.customCode = e.currentTarget.value;
}}
placeholder="mein-link"
pattern="[a-zA-Z0-9_\-]+"
title="Nur Buchstaben, Zahlen, Bindestriche und Unterstriche erlaubt"
class="flex-1 rounded-lg border-2 border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text placeholder-theme-text-muted hover:border-theme-border-hover focus:ring-2 focus:ring-theme-accent focus:outline-none transition-all"
/>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Step 3: Title (appears after shortlink preview) -->
{#if currentStep >= 3}
<div class="transition-all duration-300 ease-out {currentStep >= 3 ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}">
<label
for="title"
class="mb-2 block text-lg font-medium text-theme-text"
>
<span class="text-theme-accent mr-2">2.</span>
Geben Sie Ihrem Link einen Titel
</label>
<input
type="text"
id="title"
name="title"
data-step="3"
placeholder="z.B. Meine Webseite, GitHub Projekt, Portfolio..."
value={formData.title}
oninput={(e) => formData.title = e.currentTarget.value}
onkeydown={(e) => handleKeydown(e, 3)}
class="w-full rounded-lg border-2 border-theme-border bg-theme-surface px-4 py-3 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none transition-all"
/>
</div>
{/if}
<!-- Step 4: Tags and Folders (appears last) -->
{#if currentStep >= 4 && user}
<div class="transition-all duration-300 ease-out {currentStep >= 4 ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}">
<label class="block text-lg font-medium text-theme-text mb-3">
<span class="text-theme-accent mr-2">3.</span>
Organisation (optional)
</label>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#if mode === 'advanced' && folders.length > 0}
<div>
<label
for="folder"
class="mb-1 block text-sm font-medium text-theme-text"
>
Ordner
</label>
<select
id="folder"
name="folder"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text focus:ring-2 focus:ring-theme-accent focus:outline-none"
>
<option value="">Kein Ordner</option>
{#each folders as folder}
<option value={folder.id}>
{folder.icon} {folder.display_name}
</option>
{/each}
</select>
</div>
{/if}
<div class="{mode === 'simple' || !folders.length ? 'sm:col-span-2' : ''}">
<label
for="link-tag-selector"
class="mb-1 block text-sm font-medium text-theme-text"
>
Tags
</label>
<TagSelector
id="link-tag-selector"
userId={user.id}
bind:selectedTags
placeholder="Tags hinzufügen..."
/>
{#each selectedTags as tag}
<input type="hidden" name="tags" value={tag.id} />
{/each}
</div>
</div>
</div>
{/if}
<!-- Advanced Options Toggle (only show after basic info is filled) -->
{#if currentStep >= 3 && mode === 'advanced'}
<div class="border-t border-theme-border pt-4">
<button
type="button"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
class="flex items-center gap-2 text-sm font-medium text-theme-text hover:text-theme-accent transition-colors"
>
<svg
class="h-4 w-4 transition-transform {showAdvancedOptions ? 'rotate-90' : ''}"
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>
Erweiterte Optionen
</button>
</div>
{/if}
{#if showAdvancedOptions && currentStep >= 3}
<div class="space-y-4 rounded-lg bg-theme-surface-hover p-4">
<div>
<label
for="description"
class="mb-1 block text-sm font-medium text-theme-text"
>
Beschreibung (optional)
</label>
<textarea
id="description"
name="description"
rows="2"
placeholder="Kurze Beschreibung des Links"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
></textarea>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label
for="expires_in"
class="mb-1 block text-sm font-medium text-theme-text"
>
Läuft ab in (Tagen)
</label>
<input
type="number"
id="expires_in"
name="expires_in"
min="1"
placeholder="Nie"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div>
<label
for="max_clicks"
class="mb-1 block text-sm font-medium text-theme-text"
>
Max. Klicks
</label>
<input
type="number"
id="max_clicks"
name="max_clicks"
min="1"
placeholder="Unbegrenzt"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div>
<label
for="password"
class="mb-1 block text-sm font-medium text-theme-text"
>
Passwortschutz
</label>
<input
type="password"
id="password"
name="password"
placeholder="Optional"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
</div>
{#if mode === 'advanced'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
for="activate_at"
class="mb-1 block text-sm font-medium text-theme-text"
>
Aktivieren um (optional)
</label>
<input
type="datetime-local"
id="activate_at"
name="activate_at"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
<p class="mt-1 text-xs text-theme-text-muted">
Link ist inaktiv bis zu diesem Zeitpunkt
</p>
</div>
<div>
<label
for="expires_at"
class="mb-1 block text-sm font-medium text-theme-text"
>
Läuft ab um (optional)
</label>
<input
type="datetime-local"
id="expires_at"
name="expires_at"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
<p class="mt-1 text-xs text-theme-text-muted">
Überschreibt "Läuft ab in Tagen" wenn gesetzt
</p>
</div>
</div>
{/if}
</div>
{/if}
{#if mode === 'advanced'}
<!-- Social Media Preview Toggle -->
<div class="border-t border-theme-border pt-4">
<button
type="button"
onclick={() => (showSocialMediaOptions = !showSocialMediaOptions)}
class="flex items-center gap-2 text-sm text-theme-accent hover:text-theme-accent-hover"
>
<svg class="h-4 w-4 transition-transform {showSocialMediaOptions ? 'rotate-90' : ''}" 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"></path>
</svg>
Social-Media-Vorschau (optional)
</button>
</div>
{#if showSocialMediaOptions}
<div class="space-y-4 rounded-lg bg-theme-surface-hover p-4">
<p class="text-sm text-theme-text-muted">
Passen Sie an, wie Ihr Link erscheint, wenn er auf Social-Media-Plattformen geteilt wird
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label
for="og_title"
class="mb-1 block text-sm font-medium text-theme-text"
>
Vorschau-Titel
</label>
<input
type="text"
id="og_title"
name="og_title"
placeholder="Interessanter Artikel"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div>
<label
for="og_image"
class="mb-1 block text-sm font-medium text-theme-text"
>
Vorschau-Bild-URL
</label>
<input
type="url"
id="og_image"
name="og_image"
placeholder="https://example.com/bild.jpg"
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
</div>
<div>
<label
for="og_description"
class="mb-1 block text-sm font-medium text-theme-text"
>
Vorschau-Beschreibung
</label>
<textarea
id="og_description"
name="og_description"
rows="3"
placeholder="Diese Beschreibung erscheint in der Social-Media-Vorschau, wenn Ihr Link geteilt wird..."
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
></textarea>
</div>
</div>
{/if}
{/if}
<!-- Submit Button (shows after URL is valid) -->
{#if isValidUrl}
<div class="flex gap-2 transition-all duration-300 ease-out opacity-100 translate-y-0">
<button
type="submit"
disabled={isSubmitting || !isValidUrl || showSuccess}
onclick={() => console.log('💯 Submit button clicked! Submitting form...')}
class="flex flex-1 items-center justify-center rounded-lg px-6 py-4 font-semibold text-white transition-all duration-300 shadow-lg transform {showSuccess ? 'bg-green-500 scale-105' : isSubmitting ? 'bg-gradient-to-r from-blue-500 to-indigo-500' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 hover:shadow-xl hover:-translate-y-0.5'} disabled:cursor-not-allowed disabled:opacity-50"
>
{#if showSuccess}
<svg class="mr-2 h-5 w-5 animate-bounce" 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"></path>
</svg>
Erfolgreich erstellt!
{:else 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>
{editingLink ? 'Wird aktualisiert...' : 'Wird erstellt...'}
{:else}
{editingLink ? '✏️ Link aktualisieren' : '🚀 Link erstellen'}
{/if}
</button>
{#if onCancel}
<button
type="button"
onclick={onCancel}
class="rounded-lg border border-theme-border bg-theme-surface px-4 py-4 font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Abbrechen
</button>
{/if}
</div>
{/if}
</div>
</form>
{#if error}
<div class="mt-4 rounded-lg border-2 border-red-400 bg-red-50 dark:border-red-700 dark:bg-red-900/20 p-3 text-red-700 dark:text-red-400">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 flex-none" 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"></path>
</svg>
<span>{error}</span>
</div>
</div>
{/if}
{#if createdLink && !error}
<div class="mt-4 rounded-lg border border-green-400 bg-green-50 dark:border-green-800 dark:bg-green-900/20 p-4">
<div class="flex items-start gap-3">
<svg class="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5" 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"></path>
</svg>
<div class="flex-1">
<p class="font-medium text-green-800 dark:text-green-300">
Link erfolgreich erstellt!
</p>
<div class="mt-2 flex items-center gap-2">
<code class="text-sm font-mono text-green-700 dark:text-green-400">
{createdLink.url}
</code>
<button
type="button"
onclick={() => copyToClipboard(createdLink.url)}
class="px-2 py-1 text-xs font-medium rounded transition-colors {copiedToClipboard ? 'bg-green-600 text-white' : 'bg-green-700 text-white hover:bg-green-800'}"
>
{copiedToClipboard ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,187 @@
<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="rounded-xl border border-theme-border bg-theme-surface shadow-sm hover:shadow-lg transition-shadow duration-200 overflow-hidden relative {isSelectMode && selectedLinks.has(link.id) ? 'ring-2 ring-theme-primary' : ''}">
{#if isSelectMode}
<div class="absolute top-3 left-3 z-10">
<input
type="checkbox"
checked={selectedLinks.has(link.id)}
onchange={() => onToggleSelect(link.id)}
class="h-5 w-5 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
/>
</div>
{/if}
<LinkCard {link} {username} onCopy={handleCopy} {copiedStates} />
</div>
{/each}
</div>
</div>
{:else}
<div class="rounded-xl border border-theme-border bg-theme-surface shadow-xl overflow-hidden">
<!-- Desktop Table Header -->
<div class="hidden lg:grid {isSelectMode ? 'grid-cols-[40px_200px_200px_1fr_100px_120px_180px]' : 'grid-cols-[200px_200px_1fr_100px_120px_180px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text">
{#if isSelectMode}<div></div>{/if}
<div>Title</div>
<div>Short URL</div>
<div>Destination</div>
<div>Clicks</div>
<div>Created</div>
<div class="text-right">Actions</div>
</div>
<!-- Tablet Table Header -->
<div class="hidden md:grid lg:hidden {isSelectMode ? 'grid-cols-[40px_minmax(200px,1fr)_200px_100px_140px]' : 'grid-cols-[minmax(200px,1fr)_200px_100px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text">
{#if isSelectMode}<div></div>{/if}
<div>Title</div>
<div>Short URL</div>
<div>Clicks</div>
<div class="text-right">Actions</div>
</div>
<!-- Table Body -->
<div>
{#each links.items as link}
<LinkListItem
{link}
{username}
onCopy={handleCopy}
{copiedStates}
{isSelectMode}
isSelected={selectedLinks.has(link.id)}
onToggleSelect={() => onToggleSelect(link.id)}
/>
{/each}
</div>
</div>
{/if}
{#if links.totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
{#if links.page > 1}
<button
onclick={() => onPageChange(links.page - 1)}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Previous
</button>
{/if}
{#each Array(Math.min(5, links.totalPages)) as _, i}
{@const pageNum = Math.max(1, links.page - 2) + i}
{#if pageNum <= links.totalPages}
<button
onclick={() => onPageChange(pageNum)}
class="rounded-lg px-3 py-2 transition-colors {pageNum === links.page
? 'bg-theme-primary text-white'
: 'border border-theme-border bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
{pageNum}
</button>
{/if}
{/each}
{#if links.page < links.totalPages}
<button
onclick={() => onPageChange(links.page + 1)}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Next
</button>
{/if}
</div>
{/if}
{:else}
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
<p class="text-theme-text">
Keine Links gefunden. Versuchen Sie Ihre Filter anzupassen oder erstellen Sie Ihren ersten
Link!
</p>
<slot name="empty-action">
<button
onclick={() => window.dispatchEvent(new CustomEvent('show-create-form'))}
class="mt-4 inline-block rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary-hover"
>
Ersten Link erstellen
</button>
</slot>
</div>
{/if}

View file

@ -0,0 +1,522 @@
<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 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
/>
</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="text-sm text-theme-primary hover:underline truncate block"
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="text-sm text-theme-text-muted flex items-center gap-2">
<span class="truncate" title={link.original_url}>
{link.original_url}
</span>
{#if !link.is_active}
<span class="text-xs text-red-600 font-medium flex-shrink-0">Inactive</span>
{/if}
{#if link.password}
<Lock class="h-3 w-3 text-yellow-600 flex-shrink-0" 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 ? 'text-orange-600 font-medium' : ''}">
{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="text-theme-accent text-xs mt-1">
Last: {new Date(link.last_clicked_at).toLocaleDateString('de-DE')}
</div>
{/if}
</div>
<!-- Actions Column -->
<div class="flex items-center gap-2 justify-end">
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="rounded-lg bg-theme-primary/10 px-3 py-1.5 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
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 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
/>
</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="flex flex-wrap gap-1 mt-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="text-sm text-theme-primary hover:underline truncate block"
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 ? 'text-orange-600 font-medium' : ''}">
{link.clicks || 0}
</span>
</div>
</div>
<!-- Actions Column -->
<div class="flex items-center gap-2 justify-end">
<button
onclick={() => copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
class="rounded-lg bg-theme-primary/10 px-3 py-1.5 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
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="md:hidden border-b border-theme-border {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 items-center gap-2 cursor-pointer">
<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="font-medium text-theme-text mb-1">
{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="h-3 w-3 inline" />
{new Date(link.created).toLocaleDateString('de-DE')}
</span>
</div>
<div class="flex items-center gap-2">
{#if !link.is_active}
<span class="text-xs text-red-600 font-medium">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="flex-1 rounded-lg bg-theme-primary/10 px-3 py-2 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
>
{#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-sm font-medium text-theme-text text-center transition hover:bg-theme-surface-hover"
>
Analytics
</a>
<Dropdown
items={[
{
label: 'QR Code',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h8m-4 0v8m-4-8h.01M8 8h8M4 20h2m0-4v4m12-4v4" /></svg>',
color: '#16a34a',
action: () => {
window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
}
},
{
label: 'Edit',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
color: '#9333ea',
action: () => {
window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
}
},
{
label: link.is_active ? 'Deactivate' : 'Activate',
type: 'form',
formAction: '?/toggle',
formData: { id: link.id, is_active: String(link.is_active) },
icon: link.is_active
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>',
color: link.is_active ? '#ea580c' : '#16a34a'
},
{
divider: true
},
{
label: 'Delete',
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>',
color: '#dc2626',
type: 'form',
formAction: '?/delete',
formData: { id: link.id },
enhanceOptions: () => {
return async ({ update, result, cancel }) => {
if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
cancel();
return;
}
await update();
if (result.type === 'success') {
trackEvent(EVENTS.LINK_DELETED, {
short_code: link.short_code
});
toastMessages.linkDeleted();
}
};
}
}
]}
buttonText="•••"
size="sm"
/>
</div>
</div>
</div>

View file

@ -0,0 +1,310 @@
<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.40),
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 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Total Clicks -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<div class="flex items-center justify-between mb-2">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<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="text-sm text-theme-text-muted mt-1">Total Clicks</p>
</div>
</div>
<!-- Active Links -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<div class="flex items-center justify-between mb-2">
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<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="text-sm text-theme-text-muted mt-1">Active Links</p>
</div>
</div>
<!-- Average CTR -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<div class="flex items-center justify-between mb-2">
<div class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<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="text-sm text-theme-text-muted mt-1">Avg. Engagement</p>
</div>
</div>
<!-- Link Efficiency -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<div class="flex items-center justify-between mb-2">
<div class="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<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="text-sm text-theme-text-muted mt-1">Clicks per Link</p>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Hourly Activity Chart -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<h3 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<Clock class="h-5 w-5 text-theme-text-muted" />
Click Activity (24h)
</h3>
<div class="h-48 flex items-end gap-1">
{#each stats().hourlyDistribution as hour}
<div class="flex-1 bg-blue-500 dark:bg-blue-600 rounded-t opacity-80 hover:opacity-100 transition-opacity relative group"
style="height: {hour.clicks}%"
title="{hour.hour}:00 - {hour.clicks} clicks">
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{hour.hour}:00
</div>
</div>
{/each}
</div>
<div class="flex justify-between mt-2 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="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<h3 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<Users class="h-5 w-5 text-theme-text-muted" />
Device Types
</h3>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-theme-text">Desktop</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.desktop}</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full" style="width: 55%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-theme-text">Mobile</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.mobile}</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 40%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-theme-text">Tablet</span>
<span class="text-theme-text-muted">{stats().deviceBreakdown.tablet}</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-purple-500 h-2 rounded-full" style="width: 5%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Top Links Table -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Top Performing Links -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<h3 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<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 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-theme-text-muted w-6">#{i + 1}</span>
<div class="min-w-0">
<p class="text-sm font-medium text-theme-text truncate">
{link.title || link.short_url}
</p>
<p class="text-xs text-theme-text-muted truncate">
{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="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
>
<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="bg-white dark:bg-gray-800 rounded-lg border border-theme-border p-6">
<h3 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<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 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="min-w-0">
<p class="text-sm font-medium text-theme-text truncate">
{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="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
>
<ExternalLink class="h-3 w-3 text-theme-text-muted" />
</a>
</div>
</div>
{:else}
<p class="text-sm text-theme-text-muted text-center py-4">No links created yet</p>
{/each}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,183 @@
<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="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4">
<!-- Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<!-- App Icon -->
<div class="w-12 h-12 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-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="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">
Install uLoad
</h3>
<p class="text-xs text-gray-600 dark:text-gray-300 mt-1">
Add to home screen for quick access
</p>
</div>
</div>
<!-- Dismiss Button -->
<button
onclick={dismissBanner}
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Dismiss install banner"
>
<svg class="w-4 h-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="w-3 h-3 text-green-500 mr-2 flex-shrink-0" 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="w-3 h-3 text-green-500 mr-2 flex-shrink-0" 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="w-3 h-3 text-green-500 mr-2 flex-shrink-0" 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="flex-1 bg-blue-600 text-white text-sm font-medium py-2 px-3 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors relative overflow-hidden"
>
{#if isInstalling}
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 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 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
>
Not now
</button>
</div>
</div>
</div>
{/if}
<style>
/* Smooth animations for banner */
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
div[class*="fixed bottom-4"] > div {
animation: slideUp 0.3s ease-out;
}
</style>

View file

@ -0,0 +1,370 @@
<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="max-w-2xl mx-auto p-6">
<!-- Header -->
<div class="text-center mb-8">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-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="text-2xl font-bold text-gray-900 mb-2">
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="flex items-center justify-center mb-8">
<div class="flex items-center space-x-4">
{#each [1, 2, 3] as stepNumber}
<div class="flex items-center">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
{step >= stepNumber ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-500'}">
{stepNumber}
</div>
{#if stepNumber < 3}
<div class="w-12 h-0.5 {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="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-lg font-semibold mb-4">1. Authenticator-App einrichten</h3>
<div class="text-center mb-6">
<!-- QR Code Placeholder -->
<div class="w-48 h-48 mx-auto mb-4 bg-gray-100 rounded-lg flex items-center justify-center">
{#if qrCodeURL}
<!-- In Produktion würde hier ein echter QR Code generiert werden -->
<div class="text-center">
<div class="w-32 h-32 bg-white border-2 border-gray-300 rounded-lg mb-2 flex items-center justify-center">
<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="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full"></div>
{/if}
</div>
<p class="text-sm text-gray-600 mb-4">
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="text-sm text-blue-600 cursor-pointer hover:text-blue-700">
Manueller Setup-Code
</summary>
<div class="mt-3 p-3 bg-gray-50 rounded-lg">
<p class="text-xs text-gray-600 mb-2">Falls Sie den QR-Code nicht scannen können:</p>
<code class="text-sm font-mono bg-white p-2 rounded border block break-all">
{secret}
</code>
<button
onclick={() => navigator.clipboard.writeText(secret)}
class="text-xs text-blue-600 hover:text-blue-700 mt-2"
>
Code kopieren
</button>
</div>
</details>
</div>
<div class="flex space-x-3">
<button
onclick={handleCancel}
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Abbrechen
</button>
<button
onclick={() => step = 2}
disabled={!qrCodeURL}
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Weiter
</button>
</div>
</div>
{:else if step === 2}
<!-- Schritt 2: Code verifizieren -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-lg font-semibold mb-4">2. Code verifizieren</h3>
<div class="text-center mb-6">
<p class="text-gray-600 mb-6">
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 text-center text-2xl font-mono py-3 px-4 border-2 border-gray-300 rounded-lg 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="text-xs text-gray-400 mb-4">
<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 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Zurück
</button>
<button
onclick={verifyCode}
disabled={isVerifying || verificationCode.replace(/\s/g, '').length !== 6}
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if isVerifying}
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" 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="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-lg font-semibold mb-4">3. Backup-Codes sichern</h3>
<div class="mb-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-yellow-400 mt-0.5 mr-3 flex-shrink-0" 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="text-yellow-800 font-medium">Wichtig: Backup-Codes sichern</h4>
<p class="text-yellow-700 text-sm mt-1">
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="grid grid-cols-2 gap-3 mb-6">
{#each backupCodes as code}
<div class="bg-gray-50 p-3 rounded-lg text-center">
<code class="font-mono text-sm">{code}</code>
</div>
{/each}
</div>
<!-- Action Buttons -->
<div class="flex space-x-3 mb-6">
<button
onclick={copyBackupCodes}
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center"
>
<svg class="w-4 h-4 mr-2" 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-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors flex items-center justify-center"
>
<svg class="w-4 h-4 mr-2" 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 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
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>

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