mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
c6c4c5a552
commit
c712a2504a
1031 changed files with 189301 additions and 290 deletions
1
uload/apps/web/.npmrc
Normal file
1
uload/apps/web/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
9
uload/apps/web/.prettierignore
Normal file
9
uload/apps/web/.prettierignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
16
uload/apps/web/.prettierrc
Normal file
16
uload/apps/web/.prettierrc
Normal 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"
|
||||
}
|
||||
13
uload/apps/web/drizzle.config.ts
Normal file
13
uload/apps/web/drizzle.config.ts
Normal 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
|
||||
227
uload/apps/web/drizzle/0000_material_puma.sql
Normal file
227
uload/apps/web/drizzle/0000_material_puma.sql
Normal 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");
|
||||
1846
uload/apps/web/drizzle/meta/0000_snapshot.json
Normal file
1846
uload/apps/web/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
uload/apps/web/drizzle/meta/_journal.json
Normal file
13
uload/apps/web/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1763571183375,
|
||||
"tag": "0000_material_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
6
uload/apps/web/e2e/demo.test.ts
Normal file
6
uload/apps/web/e2e/demo.test.ts
Normal 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();
|
||||
});
|
||||
40
uload/apps/web/eslint.config.js
Normal file
40
uload/apps/web/eslint.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
75
uload/apps/web/package.json
Normal file
75
uload/apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
uload/apps/web/playwright.config.ts
Normal file
9
uload/apps/web/playwright.config.ts
Normal 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'
|
||||
});
|
||||
1
uload/apps/web/project.inlang/cache/plugins/3fhvg7lmyjji3
vendored
Normal file
1
uload/apps/web/project.inlang/cache/plugins/3fhvg7lmyjji3
vendored
Normal file
File diff suppressed because one or more lines are too long
16
uload/apps/web/project.inlang/cache/plugins/3j6u37qs3lqz3
vendored
Normal file
16
uload/apps/web/project.inlang/cache/plugins/3j6u37qs3lqz3
vendored
Normal file
File diff suppressed because one or more lines are too long
28
uload/apps/web/project.inlang/messages/de.json
Normal file
28
uload/apps/web/project.inlang/messages/de.json
Normal 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"
|
||||
}
|
||||
28
uload/apps/web/project.inlang/messages/en.json
Normal file
28
uload/apps/web/project.inlang/messages/en.json
Normal 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"
|
||||
}
|
||||
28
uload/apps/web/project.inlang/messages/es.json
Normal file
28
uload/apps/web/project.inlang/messages/es.json
Normal 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"
|
||||
}
|
||||
28
uload/apps/web/project.inlang/messages/fr.json
Normal file
28
uload/apps/web/project.inlang/messages/fr.json
Normal 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"
|
||||
}
|
||||
28
uload/apps/web/project.inlang/messages/it.json
Normal file
28
uload/apps/web/project.inlang/messages/it.json
Normal 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"
|
||||
}
|
||||
1
uload/apps/web/project.inlang/project_id
Normal file
1
uload/apps/web/project.inlang/project_id
Normal file
|
|
@ -0,0 +1 @@
|
|||
vBR0K1t5zNgjHxICus
|
||||
12
uload/apps/web/project.inlang/settings.json
Normal file
12
uload/apps/web/project.inlang/settings.json
Normal 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
162
uload/apps/web/src/app.css
Normal 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
33
uload/apps/web/src/app.d.ts
vendored
Normal 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
103
uload/apps/web/src/app.html
Normal 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>
|
||||
11
uload/apps/web/src/content/authors/till-schneider.json
Normal file
11
uload/apps/web/src/content/authors/till-schneider.json
Normal 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"
|
||||
}
|
||||
}
|
||||
144
uload/apps/web/src/content/blog/link-tracking-guide.md
Normal file
144
uload/apps/web/src/content/blog/link-tracking-guide.md
Normal 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!
|
||||
172
uload/apps/web/src/content/blog/psychologie-kurzer-urls.md
Normal file
172
uload/apps/web/src/content/blog/psychologie-kurzer-urls.md
Normal 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.
|
||||
57
uload/apps/web/src/content/config.ts
Normal file
57
uload/apps/web/src/content/config.ts
Normal 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;
|
||||
}
|
||||
7
uload/apps/web/src/demo.spec.ts
Normal file
7
uload/apps/web/src/demo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
uload/apps/web/src/hooks.server.ts
Normal file
153
uload/apps/web/src/hooks.server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
250
uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte
Normal file
250
uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte
Normal 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>
|
||||
208
uload/apps/web/src/lib/ab-testing/config/variants.ts
Normal file
208
uload/apps/web/src/lib/ab-testing/config/variants.ts
Normal 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();
|
||||
}
|
||||
209
uload/apps/web/src/lib/ab-testing/service/HashManager.ts
Normal file
209
uload/apps/web/src/lib/ab-testing/service/HashManager.ts
Normal 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();
|
||||
16
uload/apps/web/src/lib/actions/clickOutside.ts
Normal file
16
uload/apps/web/src/lib/actions/clickOutside.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
203
uload/apps/web/src/lib/actions/touch.test.ts
Normal file
203
uload/apps/web/src/lib/actions/touch.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
343
uload/apps/web/src/lib/actions/touch.ts
Normal file
343
uload/apps/web/src/lib/actions/touch.ts
Normal 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;
|
||||
}
|
||||
145
uload/apps/web/src/lib/analytics.ts
Normal file
145
uload/apps/web/src/lib/analytics.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
1
uload/apps/web/src/lib/assets/favicon.svg
Normal file
1
uload/apps/web/src/lib/assets/favicon.svg
Normal 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 |
146
uload/apps/web/src/lib/auth-helper.ts
Normal file
146
uload/apps/web/src/lib/auth-helper.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
219
uload/apps/web/src/lib/cache.test.ts
Normal file
219
uload/apps/web/src/lib/cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
uload/apps/web/src/lib/cache.ts
Normal file
96
uload/apps/web/src/lib/cache.ts
Normal 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;
|
||||
167
uload/apps/web/src/lib/components/AccountSwitcher.svelte
Normal file
167
uload/apps/web/src/lib/components/AccountSwitcher.svelte
Normal 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>
|
||||
49
uload/apps/web/src/lib/components/Button.svelte
Normal file
49
uload/apps/web/src/lib/components/Button.svelte
Normal 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>
|
||||
177
uload/apps/web/src/lib/components/DataTable.svelte
Normal file
177
uload/apps/web/src/lib/components/DataTable.svelte
Normal 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>
|
||||
193
uload/apps/web/src/lib/components/Dropdown.svelte
Normal file
193
uload/apps/web/src/lib/components/Dropdown.svelte
Normal 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>
|
||||
637
uload/apps/web/src/lib/components/FloatingSidebar.svelte
Normal file
637
uload/apps/web/src/lib/components/FloatingSidebar.svelte
Normal 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>
|
||||
201
uload/apps/web/src/lib/components/Footer.svelte
Normal file
201
uload/apps/web/src/lib/components/Footer.svelte
Normal 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>
|
||||
90
uload/apps/web/src/lib/components/LanguageSwitcher.svelte
Normal file
90
uload/apps/web/src/lib/components/LanguageSwitcher.svelte
Normal 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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
83
uload/apps/web/src/lib/components/LinkUsageBar.svelte
Normal file
83
uload/apps/web/src/lib/components/LinkUsageBar.svelte
Normal 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>
|
||||
306
uload/apps/web/src/lib/components/MobileSidebar.svelte
Normal file
306
uload/apps/web/src/lib/components/MobileSidebar.svelte
Normal 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>
|
||||
840
uload/apps/web/src/lib/components/Navigation.svelte
Normal file
840
uload/apps/web/src/lib/components/Navigation.svelte
Normal 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>
|
||||
258
uload/apps/web/src/lib/components/NotificationBell.svelte
Normal file
258
uload/apps/web/src/lib/components/NotificationBell.svelte
Normal 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>
|
||||
144
uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte
Normal file
144
uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte
Normal 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}
|
||||
159
uload/apps/web/src/lib/components/StatsBar.svelte
Normal file
159
uload/apps/web/src/lib/components/StatsBar.svelte
Normal 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>
|
||||
75
uload/apps/web/src/lib/components/TagBadge.svelte
Normal file
75
uload/apps/web/src/lib/components/TagBadge.svelte
Normal 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}
|
||||
169
uload/apps/web/src/lib/components/TagCard.svelte
Normal file
169
uload/apps/web/src/lib/components/TagCard.svelte
Normal 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>
|
||||
90
uload/apps/web/src/lib/components/TagList.svelte
Normal file
90
uload/apps/web/src/lib/components/TagList.svelte
Normal 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}
|
||||
391
uload/apps/web/src/lib/components/TagListItem.svelte
Normal file
391
uload/apps/web/src/lib/components/TagListItem.svelte
Normal 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>
|
||||
207
uload/apps/web/src/lib/components/TagSelector.svelte
Normal file
207
uload/apps/web/src/lib/components/TagSelector.svelte
Normal 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>
|
||||
267
uload/apps/web/src/lib/components/TagStats.svelte
Normal file
267
uload/apps/web/src/lib/components/TagStats.svelte
Normal 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>
|
||||
160
uload/apps/web/src/lib/components/ThemeDropdown.svelte
Normal file
160
uload/apps/web/src/lib/components/ThemeDropdown.svelte
Normal 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>
|
||||
75
uload/apps/web/src/lib/components/UpgradeButton.svelte
Normal file
75
uload/apps/web/src/lib/components/UpgradeButton.svelte
Normal 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}
|
||||
62
uload/apps/web/src/lib/components/ViewToggle.svelte
Normal file
62
uload/apps/web/src/lib/components/ViewToggle.svelte
Normal 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>
|
||||
195
uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte
Normal file
195
uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte
Normal 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>
|
||||
139
uload/apps/web/src/lib/components/blog/BlogCard.svelte
Normal file
139
uload/apps/web/src/lib/components/blog/BlogCard.svelte
Normal 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>
|
||||
73
uload/apps/web/src/lib/components/cards/BaseCard.svelte
Normal file
73
uload/apps/web/src/lib/components/cards/BaseCard.svelte
Normal 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>
|
||||
541
uload/apps/web/src/lib/components/cards/CardEditor.svelte
Normal file
541
uload/apps/web/src/lib/components/cards/CardEditor.svelte
Normal 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>
|
||||
285
uload/apps/web/src/lib/components/cards/CardRenderer.svelte
Normal file
285
uload/apps/web/src/lib/components/cards/CardRenderer.svelte
Normal 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>
|
||||
156
uload/apps/web/src/lib/components/cards/CustomCard.svelte
Normal file
156
uload/apps/web/src/lib/components/cards/CustomCard.svelte
Normal 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>
|
||||
237
uload/apps/web/src/lib/components/cards/ModularCard.svelte
Normal file
237
uload/apps/web/src/lib/components/cards/ModularCard.svelte
Normal 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>
|
||||
157
uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte
Normal file
157
uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte
Normal 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>
|
||||
216
uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte
Normal file
216
uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte
Normal 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>
|
||||
190
uload/apps/web/src/lib/components/cards/TemplateCard.svelte
Normal file
190
uload/apps/web/src/lib/components/cards/TemplateCard.svelte
Normal 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>
|
||||
102
uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte
Normal file
102
uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
275
uload/apps/web/src/lib/components/cards/types.ts
Normal file
275
uload/apps/web/src/lib/components/cards/types.ts
Normal 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 };
|
||||
288
uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte
Normal file
288
uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte
Normal 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>
|
||||
107
uload/apps/web/src/lib/components/landing/BlogSection.svelte
Normal file
107
uload/apps/web/src/lib/components/landing/BlogSection.svelte
Normal 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>
|
||||
378
uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte
Normal file
378
uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte
Normal 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>
|
||||
164
uload/apps/web/src/lib/components/landing/HeroSection.svelte
Normal file
164
uload/apps/web/src/lib/components/landing/HeroSection.svelte
Normal 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>
|
||||
250
uload/apps/web/src/lib/components/landing/PricingSection.svelte
Normal file
250
uload/apps/web/src/lib/components/landing/PricingSection.svelte
Normal 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>
|
||||
293
uload/apps/web/src/lib/components/landing/TargetAudience.svelte
Normal file
293
uload/apps/web/src/lib/components/landing/TargetAudience.svelte
Normal 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>
|
||||
211
uload/apps/web/src/lib/components/landing/Testimonials.svelte
Normal file
211
uload/apps/web/src/lib/components/landing/Testimonials.svelte
Normal 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>
|
||||
211
uload/apps/web/src/lib/components/landing/TrustSignals.svelte
Normal file
211
uload/apps/web/src/lib/components/landing/TrustSignals.svelte
Normal 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>
|
||||
442
uload/apps/web/src/lib/components/links/LinkCard.svelte
Normal file
442
uload/apps/web/src/lib/components/links/LinkCard.svelte
Normal 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>
|
||||
221
uload/apps/web/src/lib/components/links/LinkCardCompact.svelte
Normal file
221
uload/apps/web/src/lib/components/links/LinkCardCompact.svelte
Normal 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>
|
||||
316
uload/apps/web/src/lib/components/links/LinkCreationCard.svelte
Normal file
316
uload/apps/web/src/lib/components/links/LinkCreationCard.svelte
Normal 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 https://google.com https://github.com"
|
||||
bind:value={bulkUrls}
|
||||
class="w-full rounded-lg border-2 border-theme-border bg-theme-surface px-4 py-3 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>
|
||||
|
||||
905
uload/apps/web/src/lib/components/links/LinkCreationForm.svelte
Normal file
905
uload/apps/web/src/lib/components/links/LinkCreationForm.svelte
Normal 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}
|
||||
187
uload/apps/web/src/lib/components/links/LinkList.svelte
Normal file
187
uload/apps/web/src/lib/components/links/LinkList.svelte
Normal 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}
|
||||
522
uload/apps/web/src/lib/components/links/LinkListItem.svelte
Normal file
522
uload/apps/web/src/lib/components/links/LinkListItem.svelte
Normal 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>
|
||||
310
uload/apps/web/src/lib/components/links/LinkStats.svelte
Normal file
310
uload/apps/web/src/lib/components/links/LinkStats.svelte
Normal 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>
|
||||
183
uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte
Normal file
183
uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte
Normal 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>
|
||||
370
uload/apps/web/src/lib/components/security/TOTPSetup.svelte
Normal file
370
uload/apps/web/src/lib/components/security/TOTPSetup.svelte
Normal 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
Loading…
Add table
Add a link
Reference in a new issue