From 3686926a8e2d3f54f5eb39286b5d0d31dfc12c84 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 14:14:17 +0200 Subject: [PATCH] refactor(uload): clean up migration, add Stripe/Email stubs, fix 497 type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete non-MVP pages (cards, templates, teams, workspaces, public profiles) - Delete old PocketBase components, stores, services, utils - Simplify settings, pricing, analytics pages - Rewrite reset-password, verify-email pages - Add Stripe checkout/webhook and email stub routes to Hono server - Add uload to shared-branding (app icon, mana-apps registry) - Simplify svelte.config.js, vite.config.ts, theme store - 501 type errors → 4 (vite.config Tailwind v4 compat only) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/uload/apps/server/src/index.ts | 4 + apps/uload/apps/server/src/routes/email.ts | 9 + apps/uload/apps/server/src/routes/stripe.ts | 14 + apps/uload/apps/web/drizzle.config.ts | 14 - .../apps/web/drizzle/0000_material_puma.sql | 227 --- .../apps/web/drizzle/meta/0000_snapshot.json | 1762 ----------------- .../uload/apps/web/drizzle/meta/_journal.json | 13 - apps/uload/apps/web/e2e/demo.test.ts | 6 - apps/uload/apps/web/package.json | 1 + apps/uload/apps/web/playwright.config.ts | 9 - .../src/content/authors/till-schneider.json | 11 - .../src/content/blog/link-tracking-guide.md | 157 -- .../content/blog/psychologie-kurzer-urls.md | 184 -- apps/uload/apps/web/src/content/config.ts | 64 - apps/uload/apps/web/src/demo.spec.ts | 7 - .../ab-testing/components/HeroABTest.svelte | 251 --- .../web/src/lib/ab-testing/config/variants.ts | 208 -- .../src/lib/ab-testing/service/HashManager.ts | 209 -- .../apps/web/src/lib/actions/clickOutside.ts | 16 - .../apps/web/src/lib/actions/touch.test.ts | 202 -- apps/uload/apps/web/src/lib/actions/touch.ts | 343 ---- apps/uload/apps/web/src/lib/analytics.ts | 145 -- apps/uload/apps/web/src/lib/api/feedback.ts | 15 - apps/uload/apps/web/src/lib/auth-helper.ts | 146 -- apps/uload/apps/web/src/lib/cache.test.ts | 219 -- apps/uload/apps/web/src/lib/cache.ts | 93 - .../src/lib/components/AccountSwitcher.svelte | 180 -- .../apps/web/src/lib/components/Button.svelte | 48 - .../web/src/lib/components/DataTable.svelte | 186 -- .../web/src/lib/components/Dropdown.svelte | 197 -- .../src/lib/components/FloatingSidebar.svelte | 644 ------ .../apps/web/src/lib/components/Footer.svelte | 201 -- .../lib/components/LanguageSwitcher.svelte | 92 - .../src/lib/components/LinkUsageBar.svelte | 92 - .../src/lib/components/MobileSidebar.svelte | 306 --- .../web/src/lib/components/Navigation.svelte | 840 -------- .../lib/components/NotificationBell.svelte | 270 --- .../components/SimpleAccountSwitcher.svelte | 145 -- .../web/src/lib/components/StatsBar.svelte | 159 -- .../web/src/lib/components/TagBadge.svelte | 75 - .../web/src/lib/components/TagCard.svelte | 175 -- .../web/src/lib/components/TagList.svelte | 100 - .../web/src/lib/components/TagListItem.svelte | 413 ---- .../web/src/lib/components/TagSelector.svelte | 207 -- .../web/src/lib/components/TagStats.svelte | 290 --- .../src/lib/components/ThemeDropdown.svelte | 160 -- .../src/lib/components/UpgradeButton.svelte | 75 - .../web/src/lib/components/ViewToggle.svelte | 62 - .../lib/components/WorkspaceSwitcher.svelte | 202 -- .../src/lib/components/blog/BlogCard.svelte | 146 -- .../src/lib/components/cards/BaseCard.svelte | 73 - .../lib/components/cards/CardEditor.svelte | 541 ----- .../lib/components/cards/CardRenderer.svelte | 285 --- .../lib/components/cards/CustomCard.svelte | 156 -- .../lib/components/cards/ModularCard.svelte | 236 --- .../components/cards/ProfileCardItem.svelte | 149 -- .../components/cards/SafeCardRenderer.svelte | 234 --- .../lib/components/cards/TemplateCard.svelte | 190 -- .../components/cards/editor/CodeEditor.svelte | 102 - .../cards/editor/ModuleEditor.svelte | 363 ---- .../cards/editor/TemplateEditor.svelte | 202 -- .../cards/modules/ActionsModule.svelte | 55 - .../cards/modules/ContentModule.svelte | 72 - .../cards/modules/FooterModule.svelte | 53 - .../cards/modules/HeaderModule.svelte | 60 - .../cards/modules/LinksModule.svelte | 125 -- .../cards/modules/MediaModule.svelte | 44 - .../cards/modules/StatsModule.svelte | 49 - .../web/src/lib/components/cards/types.ts | 275 --- .../lib/components/gdpr/CookieBanner.svelte | 328 --- .../lib/components/landing/BlogSection.svelte | 118 -- .../components/landing/FeatureShowcase.svelte | 553 ------ .../lib/components/landing/HeroSection.svelte | 228 --- .../components/landing/PricingSection.svelte | 270 --- .../components/landing/TargetAudience.svelte | 487 ----- .../components/landing/Testimonials.svelte | 221 --- .../components/landing/TrustSignals.svelte | 270 --- .../src/lib/components/links/LinkCard.svelte | 533 ----- .../components/links/LinkCardCompact.svelte | 234 --- .../components/links/LinkCreationCard.svelte | 340 ---- .../components/links/LinkCreationForm.svelte | 1002 ---------- .../src/lib/components/links/LinkList.svelte | 200 -- .../lib/components/links/LinkListItem.svelte | 541 ----- .../src/lib/components/links/LinkStats.svelte | 314 --- .../components/mobile/InstallPWABanner.svelte | 233 --- .../lib/components/security/TOTPSetup.svelte | 411 ---- .../src/lib/components/tags/TagStats.svelte | 409 ---- .../templates/CreateTemplateModal.svelte | 322 --- .../components/templates/TemplateCard.svelte | 227 --- .../templates/TemplatePreviewModal.svelte | 267 --- apps/uload/apps/web/src/lib/content/index.ts | 186 -- apps/uload/apps/web/src/lib/db/index.ts | 24 - apps/uload/apps/web/src/lib/db/schema.ts | 413 ---- apps/uload/apps/web/src/lib/email.ts | 222 --- .../uload/apps/web/src/lib/gdpr/compliance.ts | 422 ---- apps/uload/apps/web/src/lib/i18n/index.ts | 60 - .../apps/web/src/lib/i18n/locales/de.json | 28 - .../apps/web/src/lib/i18n/locales/en.json | 144 -- .../apps/web/src/lib/i18n/locales/es.json | 28 - .../apps/web/src/lib/i18n/locales/fr.json | 28 - .../apps/web/src/lib/i18n/locales/it.json | 28 - apps/uload/apps/web/src/lib/index.ts | 1 - .../web/src/lib/layouts/BlogLayout.svelte | 263 --- .../web/src/lib/layouts/DefaultLayout.svelte | 14 - apps/uload/apps/web/src/lib/locale.ts | 42 - apps/uload/apps/web/src/lib/pwa.ts | 172 -- apps/uload/apps/web/src/lib/qrcode.ts | 175 -- .../apps/web/src/lib/schemas/cardSchemas.ts | 256 --- .../lib/scripts/update-links-collection.js | 87 - apps/uload/apps/web/src/lib/security/totp.ts | 284 --- .../web/src/lib/services/cardConverter.ts | 494 ----- .../web/src/lib/services/cardSanitizer.ts | 400 ---- .../apps/web/src/lib/services/cardService.ts | 153 -- .../web/src/lib/services/cardValidator.ts | 454 ----- .../apps/web/src/lib/services/email-sender.ts | 113 -- apps/uload/apps/web/src/lib/services/email.ts | 276 --- .../apps/web/src/lib/services/iframePool.ts | 211 -- .../apps/web/src/lib/services/link-limits.ts | 152 -- .../web/src/lib/services/moduleEventBus.ts | 266 --- .../web/src/lib/services/pocketbase-email.ts | 121 -- apps/uload/apps/web/src/lib/services/toast.ts | 173 -- .../src/lib/services/unifiedCardService.ts | 617 ------ apps/uload/apps/web/src/lib/storage.ts | 141 -- .../uload/apps/web/src/lib/stores/accounts.ts | 146 -- .../web/src/lib/stores/activeWorkspace.ts | 145 -- apps/uload/apps/web/src/lib/stores/cards.ts | 433 ---- .../apps/web/src/lib/stores/notifications.ts | 152 -- .../apps/web/src/lib/stores/unifiedCards.ts | 557 ------ .../apps/web/src/lib/stores/viewModes.ts | 63 - .../apps/web/src/lib/stores/workspaces.ts | 236 --- .../apps/web/src/lib/stripe-translations.ts | 272 --- apps/uload/apps/web/src/lib/theme.svelte.ts | 31 +- apps/uload/apps/web/src/lib/themes/README.md | 123 -- apps/uload/apps/web/src/lib/themes/presets.ts | 207 -- .../apps/web/src/lib/themes/theme-store.ts | 181 -- apps/uload/apps/web/src/lib/types/accounts.ts | 146 -- apps/uload/apps/web/src/lib/username.spec.ts | 171 -- apps/uload/apps/web/src/lib/username.ts | 107 - .../apps/web/src/lib/utils/reserved-slugs.ts | 684 ------- apps/uload/apps/web/src/paraglide/messages.ts | 175 -- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../web/src/routes/(app)/apps/+page.svelte | 17 - .../src/routes/(app)/feedback/+page.svelte | 7 - .../apps/web/src/routes/(app)/my/+page.svelte | 7 +- .../(app)/my/analytics/[id]/+page.svelte | 440 ++-- .../src/routes/(app)/my/cards/+page.svelte | 403 ---- .../routes/(app)/my/cards/+page.svelte.backup | 482 ----- .../(app)/my/cards/builder/+page.svelte | 676 ------- .../routes/(app)/my/links/debug/+page.svelte | 96 - .../routes/(app)/my/tags/page.server.spec.ts | 425 ---- .../web/src/routes/(app)/pricing/+page.svelte | 355 +--- .../src/routes/(app)/settings/+page.svelte | 820 +------- .../routes/(app)/settings/team/+page.svelte | 285 --- .../(app)/settings/workspaces/+page.svelte | 224 --- .../settings/workspaces/[id]/+page.svelte | 420 ---- .../settings/workspaces/new/+page.svelte | 276 --- .../routes/(app)/setup-username/+page.svelte | 217 -- .../routes/(app)/template-store/+page.svelte | 314 --- .../apps/web/src/routes/(auth)/+layout.svelte | 9 +- .../(auth)/forgot-password/+page.svelte | 5 +- .../src/routes/(auth)/register/+page.svelte | 7 - .../routes/(auth)/register/register.test.ts | 87 - .../routes/(auth)/reset-password/+page.svelte | 248 +-- .../routes/(auth)/verify-email/+page.svelte | 41 +- apps/uload/apps/web/src/routes/+layout.svelte | 3 +- apps/uload/apps/web/src/routes/+page.svelte | 614 +----- .../web/src/routes/[...slug]/+page.svelte | 67 - .../src/routes/checkout/success/+page.svelte | 144 -- .../apps/web/src/routes/offline/+page.svelte | 130 -- .../web/src/routes/p/[username]/+page.svelte | 277 --- .../apps/web/src/routes/page.svelte.spec.ts | 13 - .../apps/web/src/routes/preview/+page.svelte | 50 - .../routes/team/accept-invite/+page.svelte | 107 - .../web/src/routes/u/[username]/+page.svelte | 70 - .../w/[workspace]/[...code]/+page.svelte | 120 -- .../apps/web/src/tests/factories/index.ts | 165 -- .../apps/web/src/tests/mocks/pocketbase.ts | 107 - apps/uload/apps/web/src/tests/setup.ts | 240 --- apps/uload/apps/web/svelte.config.js | 37 +- apps/uload/apps/web/vite.config.ts | 85 +- apps/uload/apps/web/vitest-setup-client.ts | 2 - packages/shared-branding/src/app-icons.ts | 3 + packages/shared-branding/src/mana-apps.ts | 17 + pnpm-lock.yaml | 118 ++ 184 files changed, 530 insertions(+), 38347 deletions(-) create mode 100644 apps/uload/apps/server/src/routes/email.ts create mode 100644 apps/uload/apps/server/src/routes/stripe.ts delete mode 100644 apps/uload/apps/web/drizzle.config.ts delete mode 100644 apps/uload/apps/web/drizzle/0000_material_puma.sql delete mode 100644 apps/uload/apps/web/drizzle/meta/0000_snapshot.json delete mode 100644 apps/uload/apps/web/drizzle/meta/_journal.json delete mode 100644 apps/uload/apps/web/e2e/demo.test.ts delete mode 100644 apps/uload/apps/web/playwright.config.ts delete mode 100644 apps/uload/apps/web/src/content/authors/till-schneider.json delete mode 100644 apps/uload/apps/web/src/content/blog/link-tracking-guide.md delete mode 100644 apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md delete mode 100644 apps/uload/apps/web/src/content/config.ts delete mode 100644 apps/uload/apps/web/src/demo.spec.ts delete mode 100644 apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte delete mode 100644 apps/uload/apps/web/src/lib/ab-testing/config/variants.ts delete mode 100644 apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts delete mode 100644 apps/uload/apps/web/src/lib/actions/clickOutside.ts delete mode 100644 apps/uload/apps/web/src/lib/actions/touch.test.ts delete mode 100644 apps/uload/apps/web/src/lib/actions/touch.ts delete mode 100644 apps/uload/apps/web/src/lib/analytics.ts delete mode 100644 apps/uload/apps/web/src/lib/api/feedback.ts delete mode 100644 apps/uload/apps/web/src/lib/auth-helper.ts delete mode 100644 apps/uload/apps/web/src/lib/cache.test.ts delete mode 100644 apps/uload/apps/web/src/lib/cache.ts delete mode 100644 apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/Button.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/DataTable.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/Dropdown.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/Footer.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/MobileSidebar.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/Navigation.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/NotificationBell.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/StatsBar.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagBadge.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagList.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagListItem.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagSelector.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/TagStats.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/UpgradeButton.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/ViewToggle.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/cards/types.ts delete mode 100644 apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkList.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/links/LinkStats.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/tags/TagStats.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte delete mode 100644 apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte delete mode 100644 apps/uload/apps/web/src/lib/content/index.ts delete mode 100644 apps/uload/apps/web/src/lib/db/index.ts delete mode 100644 apps/uload/apps/web/src/lib/db/schema.ts delete mode 100644 apps/uload/apps/web/src/lib/email.ts delete mode 100644 apps/uload/apps/web/src/lib/gdpr/compliance.ts delete mode 100644 apps/uload/apps/web/src/lib/i18n/index.ts delete mode 100644 apps/uload/apps/web/src/lib/i18n/locales/de.json delete mode 100644 apps/uload/apps/web/src/lib/i18n/locales/en.json delete mode 100644 apps/uload/apps/web/src/lib/i18n/locales/es.json delete mode 100644 apps/uload/apps/web/src/lib/i18n/locales/fr.json delete mode 100644 apps/uload/apps/web/src/lib/i18n/locales/it.json delete mode 100644 apps/uload/apps/web/src/lib/index.ts delete mode 100644 apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte delete mode 100644 apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte delete mode 100644 apps/uload/apps/web/src/lib/locale.ts delete mode 100644 apps/uload/apps/web/src/lib/pwa.ts delete mode 100644 apps/uload/apps/web/src/lib/qrcode.ts delete mode 100644 apps/uload/apps/web/src/lib/schemas/cardSchemas.ts delete mode 100644 apps/uload/apps/web/src/lib/scripts/update-links-collection.js delete mode 100644 apps/uload/apps/web/src/lib/security/totp.ts delete mode 100644 apps/uload/apps/web/src/lib/services/cardConverter.ts delete mode 100644 apps/uload/apps/web/src/lib/services/cardSanitizer.ts delete mode 100644 apps/uload/apps/web/src/lib/services/cardService.ts delete mode 100644 apps/uload/apps/web/src/lib/services/cardValidator.ts delete mode 100644 apps/uload/apps/web/src/lib/services/email-sender.ts delete mode 100644 apps/uload/apps/web/src/lib/services/email.ts delete mode 100644 apps/uload/apps/web/src/lib/services/iframePool.ts delete mode 100644 apps/uload/apps/web/src/lib/services/link-limits.ts delete mode 100644 apps/uload/apps/web/src/lib/services/moduleEventBus.ts delete mode 100644 apps/uload/apps/web/src/lib/services/pocketbase-email.ts delete mode 100644 apps/uload/apps/web/src/lib/services/toast.ts delete mode 100644 apps/uload/apps/web/src/lib/services/unifiedCardService.ts delete mode 100644 apps/uload/apps/web/src/lib/storage.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/accounts.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/activeWorkspace.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/cards.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/notifications.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/unifiedCards.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/viewModes.ts delete mode 100644 apps/uload/apps/web/src/lib/stores/workspaces.ts delete mode 100644 apps/uload/apps/web/src/lib/stripe-translations.ts delete mode 100644 apps/uload/apps/web/src/lib/themes/README.md delete mode 100644 apps/uload/apps/web/src/lib/themes/presets.ts delete mode 100644 apps/uload/apps/web/src/lib/themes/theme-store.ts delete mode 100644 apps/uload/apps/web/src/lib/types/accounts.ts delete mode 100644 apps/uload/apps/web/src/lib/username.spec.ts delete mode 100644 apps/uload/apps/web/src/lib/username.ts delete mode 100644 apps/uload/apps/web/src/lib/utils/reserved-slugs.ts delete mode 100644 apps/uload/apps/web/src/paraglide/messages.ts delete mode 100644 apps/uload/apps/web/src/routes/(app)/apps/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup delete mode 100644 apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts delete mode 100644 apps/uload/apps/web/src/routes/(app)/settings/team/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/settings/workspaces/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/settings/workspaces/[id]/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/settings/workspaces/new/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/setup-username/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(app)/template-store/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/(auth)/register/register.test.ts delete mode 100644 apps/uload/apps/web/src/routes/[...slug]/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/checkout/success/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/offline/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/p/[username]/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/page.svelte.spec.ts delete mode 100644 apps/uload/apps/web/src/routes/preview/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/team/accept-invite/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/u/[username]/+page.svelte delete mode 100644 apps/uload/apps/web/src/routes/w/[workspace]/[...code]/+page.svelte delete mode 100644 apps/uload/apps/web/src/tests/factories/index.ts delete mode 100644 apps/uload/apps/web/src/tests/mocks/pocketbase.ts delete mode 100644 apps/uload/apps/web/src/tests/setup.ts delete mode 100644 apps/uload/apps/web/vitest-setup-client.ts diff --git a/apps/uload/apps/server/src/index.ts b/apps/uload/apps/server/src/index.ts index 3b17f5b3d..13e01b3b5 100644 --- a/apps/uload/apps/server/src/index.ts +++ b/apps/uload/apps/server/src/index.ts @@ -9,6 +9,8 @@ import { AnalyticsService } from './services/analytics'; import { healthRoutes } from './routes/health'; import { createRedirectRoutes } from './routes/redirect'; import { createAnalyticsRoutes } from './routes/analytics'; +import { createStripeRoutes } from './routes/stripe'; +import { createEmailRoutes } from './routes/email'; const config = loadConfig(); const db = getDb(config.databaseUrl); @@ -30,6 +32,8 @@ app.route('/r', createRedirectRoutes(redirectService)); // Analytics API (auth required) app.use('/api/v1/*', jwtAuth(config.manaAuthUrl)); app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService)); +app.route('/api/v1/stripe', createStripeRoutes()); +app.route('/api/v1/email', createEmailRoutes()); console.log(`uload-server starting on port ${config.port}...`); diff --git a/apps/uload/apps/server/src/routes/email.ts b/apps/uload/apps/server/src/routes/email.ts new file mode 100644 index 000000000..56d302873 --- /dev/null +++ b/apps/uload/apps/server/src/routes/email.ts @@ -0,0 +1,9 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../middleware/jwt-auth'; + +export function createEmailRoutes() { + return new Hono<{ Variables: { user: AuthUser } }>().post('/send-invitation', async (c) => { + // TODO: Implement with Resend + return c.json({ error: 'Email not configured yet' }, 501); + }); +} diff --git a/apps/uload/apps/server/src/routes/stripe.ts b/apps/uload/apps/server/src/routes/stripe.ts new file mode 100644 index 000000000..161daf3c3 --- /dev/null +++ b/apps/uload/apps/server/src/routes/stripe.ts @@ -0,0 +1,14 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../middleware/jwt-auth'; + +export function createStripeRoutes() { + return new Hono<{ Variables: { user: AuthUser } }>() + .post('/checkout', async (c) => { + // TODO: Implement Stripe checkout session creation + return c.json({ error: 'Stripe not configured yet' }, 501); + }) + .post('/webhook', async (c) => { + // TODO: Implement Stripe webhook handling + return c.json({ received: true }); + }); +} diff --git a/apps/uload/apps/web/drizzle.config.ts b/apps/uload/apps/web/drizzle.config.ts deleted file mode 100644 index af619f226..000000000 --- a/apps/uload/apps/web/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from 'drizzle-kit'; - -export default { - schema: './src/lib/db/schema.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: - process.env.DATABASE_URL || - 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev', - }, - verbose: true, - strict: true, -} satisfies Config; diff --git a/apps/uload/apps/web/drizzle/0000_material_puma.sql b/apps/uload/apps/web/drizzle/0000_material_puma.sql deleted file mode 100644 index 52957eaa5..000000000 --- a/apps/uload/apps/web/drizzle/0000_material_puma.sql +++ /dev/null @@ -1,227 +0,0 @@ -CREATE TABLE "accounts" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "owner" uuid NOT NULL, - "is_active" boolean DEFAULT true, - "plan_type" text DEFAULT 'free', - "settings" jsonb, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "clicks" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "link_id" uuid NOT NULL, - "ip_hash" text, - "user_agent" text, - "referer" text, - "browser" text, - "device_type" text, - "os" text, - "country" text, - "city" text, - "clicked_at" timestamp DEFAULT now() NOT NULL, - "utm_source" text, - "utm_medium" text, - "utm_campaign" text, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "feature_requests" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "title" text NOT NULL, - "description" text NOT NULL, - "user_id" uuid NOT NULL, - "status" text DEFAULT 'pending', - "vote_count" integer DEFAULT 0, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "feature_votes" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "feature_request_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "folders" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "user_id" uuid NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "link_tags" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "link_id" uuid NOT NULL, - "tag_id" uuid NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "links" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "short_code" text NOT NULL, - "custom_code" text, - "original_url" text NOT NULL, - "title" text, - "description" text, - "user_id" uuid, - "is_active" boolean DEFAULT true, - "password" text, - "max_clicks" integer, - "expires_at" timestamp, - "click_count" integer DEFAULT 0, - "qr_code_url" text, - "tags" jsonb, - "utm_source" text, - "utm_medium" text, - "utm_campaign" text, - "account_owner" uuid, - "workspace_id" uuid, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "links_short_code_unique" UNIQUE("short_code") -); ---> statement-breakpoint -CREATE TABLE "notifications" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "type" text NOT NULL, - "title" text NOT NULL, - "message" text NOT NULL, - "data" jsonb, - "read" boolean DEFAULT false, - "action_url" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "pending_invitations" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "email" text NOT NULL, - "token" text NOT NULL, - "owner" uuid NOT NULL, - "expires_at" timestamp NOT NULL, - "accepted_at" timestamp, - "accepted_by" uuid, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "pending_invitations_token_unique" UNIQUE("token") -); ---> statement-breakpoint -CREATE TABLE "shared_access" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "owner" uuid NOT NULL, - "user_id" uuid, - "permissions" jsonb, - "invitation_status" text DEFAULT 'pending', - "accepted_at" timestamp, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "tags" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "slug" text NOT NULL, - "color" text, - "icon" text, - "is_public" boolean DEFAULT false, - "usage_count" integer DEFAULT 0, - "user_id" uuid, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "users" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "external_auth_id" text, - "email" text NOT NULL, - "username" text NOT NULL, - "name" text, - "avatar_url" text, - "bio" text, - "location" text, - "website" text, - "github" text, - "twitter" text, - "linkedin" text, - "instagram" text, - "public_profile" boolean DEFAULT false, - "show_click_stats" boolean DEFAULT true, - "email_notifications" boolean DEFAULT true, - "default_expiry" integer, - "profile_background" text, - "verified" boolean DEFAULT false, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "users_external_auth_id_unique" UNIQUE("external_auth_id"), - CONSTRAINT "users_email_unique" UNIQUE("email"), - CONSTRAINT "users_username_unique" UNIQUE("username") -); ---> statement-breakpoint -CREATE TABLE "workspaces" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "slug" text NOT NULL, - "type" text NOT NULL, - "owner" uuid NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "workspaces_slug_unique" UNIQUE("slug") -); ---> statement-breakpoint -ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "clicks" ADD CONSTRAINT "clicks_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "feature_requests" ADD CONSTRAINT "feature_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_feature_request_id_feature_requests_id_fk" FOREIGN KEY ("feature_request_id") REFERENCES "public"."feature_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "links" ADD CONSTRAINT "links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "links" ADD CONSTRAINT "links_account_owner_accounts_id_fk" FOREIGN KEY ("account_owner") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "links" ADD CONSTRAINT "links_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_accepted_by_users_id_fk" FOREIGN KEY ("accepted_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "accounts_owner_idx" ON "accounts" USING btree ("owner");--> statement-breakpoint -CREATE INDEX "clicks_link_id_idx" ON "clicks" USING btree ("link_id");--> statement-breakpoint -CREATE INDEX "clicks_clicked_at_idx" ON "clicks" USING btree ("clicked_at");--> statement-breakpoint -CREATE INDEX "clicks_country_idx" ON "clicks" USING btree ("country");--> statement-breakpoint -CREATE INDEX "feature_requests_user_id_idx" ON "feature_requests" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "feature_requests_status_idx" ON "feature_requests" USING btree ("status");--> statement-breakpoint -CREATE INDEX "feature_requests_vote_count_idx" ON "feature_requests" USING btree ("vote_count");--> statement-breakpoint -CREATE INDEX "feature_votes_feature_request_id_idx" ON "feature_votes" USING btree ("feature_request_id");--> statement-breakpoint -CREATE INDEX "feature_votes_user_id_idx" ON "feature_votes" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "feature_votes_unique_idx" ON "feature_votes" USING btree ("feature_request_id","user_id");--> statement-breakpoint -CREATE INDEX "folders_user_id_idx" ON "folders" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "link_tags_link_id_idx" ON "link_tags" USING btree ("link_id");--> statement-breakpoint -CREATE INDEX "link_tags_tag_id_idx" ON "link_tags" USING btree ("tag_id");--> statement-breakpoint -CREATE INDEX "link_tags_unique_idx" ON "link_tags" USING btree ("link_id","tag_id");--> statement-breakpoint -CREATE INDEX "links_user_id_idx" ON "links" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "links_short_code_idx" ON "links" USING btree ("short_code");--> statement-breakpoint -CREATE INDEX "links_workspace_id_idx" ON "links" USING btree ("workspace_id");--> statement-breakpoint -CREATE INDEX "links_account_owner_idx" ON "links" USING btree ("account_owner");--> statement-breakpoint -CREATE INDEX "links_is_active_idx" ON "links" USING btree ("is_active");--> statement-breakpoint -CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "notifications_read_idx" ON "notifications" USING btree ("read");--> statement-breakpoint -CREATE INDEX "pending_invitations_email_idx" ON "pending_invitations" USING btree ("email");--> statement-breakpoint -CREATE INDEX "pending_invitations_token_idx" ON "pending_invitations" USING btree ("token");--> statement-breakpoint -CREATE INDEX "pending_invitations_owner_idx" ON "pending_invitations" USING btree ("owner");--> statement-breakpoint -CREATE INDEX "shared_access_owner_idx" ON "shared_access" USING btree ("owner");--> statement-breakpoint -CREATE INDEX "shared_access_user_id_idx" ON "shared_access" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "shared_access_status_idx" ON "shared_access" USING btree ("invitation_status");--> statement-breakpoint -CREATE INDEX "tags_user_id_idx" ON "tags" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint -CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint -CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint -CREATE INDEX "users_external_auth_id_idx" ON "users" USING btree ("external_auth_id");--> statement-breakpoint -CREATE INDEX "workspaces_slug_idx" ON "workspaces" USING btree ("slug");--> statement-breakpoint -CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner"); \ No newline at end of file diff --git a/apps/uload/apps/web/drizzle/meta/0000_snapshot.json b/apps/uload/apps/web/drizzle/meta/0000_snapshot.json deleted file mode 100644 index b49bb3835..000000000 --- a/apps/uload/apps/web/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,1762 +0,0 @@ -{ - "id": "2584c29f-f6ed-4eb7-a1d0-1940d6be47b9", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner": { - "name": "owner", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "plan_type": { - "name": "plan_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'free'" - }, - "settings": { - "name": "settings", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "accounts_owner_idx": { - "name": "accounts_owner_idx", - "columns": [ - { - "expression": "owner", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "accounts_owner_users_id_fk": { - "name": "accounts_owner_users_id_fk", - "tableFrom": "accounts", - "tableTo": "users", - "columnsFrom": ["owner"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.clicks": { - "name": "clicks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "link_id": { - "name": "link_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "ip_hash": { - "name": "ip_hash", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "referer": { - "name": "referer", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "browser": { - "name": "browser", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "device_type": { - "name": "device_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "os": { - "name": "os", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "country": { - "name": "country", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "city": { - "name": "city", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "clicked_at": { - "name": "clicked_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "utm_source": { - "name": "utm_source", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "utm_medium": { - "name": "utm_medium", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "utm_campaign": { - "name": "utm_campaign", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "clicks_link_id_idx": { - "name": "clicks_link_id_idx", - "columns": [ - { - "expression": "link_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "clicks_clicked_at_idx": { - "name": "clicks_clicked_at_idx", - "columns": [ - { - "expression": "clicked_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "clicks_country_idx": { - "name": "clicks_country_idx", - "columns": [ - { - "expression": "country", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "clicks_link_id_links_id_fk": { - "name": "clicks_link_id_links_id_fk", - "tableFrom": "clicks", - "tableTo": "links", - "columnsFrom": ["link_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.feature_requests": { - "name": "feature_requests", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'pending'" - }, - "vote_count": { - "name": "vote_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "feature_requests_user_id_idx": { - "name": "feature_requests_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "feature_requests_status_idx": { - "name": "feature_requests_status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "feature_requests_vote_count_idx": { - "name": "feature_requests_vote_count_idx", - "columns": [ - { - "expression": "vote_count", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "feature_requests_user_id_users_id_fk": { - "name": "feature_requests_user_id_users_id_fk", - "tableFrom": "feature_requests", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.feature_votes": { - "name": "feature_votes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "feature_request_id": { - "name": "feature_request_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "feature_votes_feature_request_id_idx": { - "name": "feature_votes_feature_request_id_idx", - "columns": [ - { - "expression": "feature_request_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "feature_votes_user_id_idx": { - "name": "feature_votes_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "feature_votes_unique_idx": { - "name": "feature_votes_unique_idx", - "columns": [ - { - "expression": "feature_request_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "feature_votes_feature_request_id_feature_requests_id_fk": { - "name": "feature_votes_feature_request_id_feature_requests_id_fk", - "tableFrom": "feature_votes", - "tableTo": "feature_requests", - "columnsFrom": ["feature_request_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "feature_votes_user_id_users_id_fk": { - "name": "feature_votes_user_id_users_id_fk", - "tableFrom": "feature_votes", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.folders": { - "name": "folders", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "folders_user_id_idx": { - "name": "folders_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "folders_user_id_users_id_fk": { - "name": "folders_user_id_users_id_fk", - "tableFrom": "folders", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.link_tags": { - "name": "link_tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "link_id": { - "name": "link_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "link_tags_link_id_idx": { - "name": "link_tags_link_id_idx", - "columns": [ - { - "expression": "link_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "link_tags_tag_id_idx": { - "name": "link_tags_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "link_tags_unique_idx": { - "name": "link_tags_unique_idx", - "columns": [ - { - "expression": "link_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "link_tags_link_id_links_id_fk": { - "name": "link_tags_link_id_links_id_fk", - "tableFrom": "link_tags", - "tableTo": "links", - "columnsFrom": ["link_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "link_tags_tag_id_tags_id_fk": { - "name": "link_tags_tag_id_tags_id_fk", - "tableFrom": "link_tags", - "tableTo": "tags", - "columnsFrom": ["tag_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.links": { - "name": "links", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "short_code": { - "name": "short_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "custom_code": { - "name": "custom_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_url": { - "name": "original_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "is_active": { - "name": "is_active", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "max_clicks": { - "name": "max_clicks", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "click_count": { - "name": "click_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "qr_code_url": { - "name": "qr_code_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tags": { - "name": "tags", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "utm_source": { - "name": "utm_source", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "utm_medium": { - "name": "utm_medium", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "utm_campaign": { - "name": "utm_campaign", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "account_owner": { - "name": "account_owner", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "links_user_id_idx": { - "name": "links_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "links_short_code_idx": { - "name": "links_short_code_idx", - "columns": [ - { - "expression": "short_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "links_workspace_id_idx": { - "name": "links_workspace_id_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "links_account_owner_idx": { - "name": "links_account_owner_idx", - "columns": [ - { - "expression": "account_owner", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "links_is_active_idx": { - "name": "links_is_active_idx", - "columns": [ - { - "expression": "is_active", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "links_user_id_users_id_fk": { - "name": "links_user_id_users_id_fk", - "tableFrom": "links", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "links_account_owner_accounts_id_fk": { - "name": "links_account_owner_accounts_id_fk", - "tableFrom": "links", - "tableTo": "accounts", - "columnsFrom": ["account_owner"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "links_workspace_id_workspaces_id_fk": { - "name": "links_workspace_id_workspaces_id_fk", - "tableFrom": "links", - "tableTo": "workspaces", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "links_short_code_unique": { - "name": "links_short_code_unique", - "nullsNotDistinct": false, - "columns": ["short_code"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.notifications": { - "name": "notifications", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "data": { - "name": "data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "read": { - "name": "read", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "action_url": { - "name": "action_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "notifications_user_id_idx": { - "name": "notifications_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "notifications_read_idx": { - "name": "notifications_read_idx", - "columns": [ - { - "expression": "read", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "notifications_user_id_users_id_fk": { - "name": "notifications_user_id_users_id_fk", - "tableFrom": "notifications", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pending_invitations": { - "name": "pending_invitations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner": { - "name": "owner", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "accepted_at": { - "name": "accepted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "accepted_by": { - "name": "accepted_by", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "pending_invitations_email_idx": { - "name": "pending_invitations_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "pending_invitations_token_idx": { - "name": "pending_invitations_token_idx", - "columns": [ - { - "expression": "token", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "pending_invitations_owner_idx": { - "name": "pending_invitations_owner_idx", - "columns": [ - { - "expression": "owner", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "pending_invitations_owner_users_id_fk": { - "name": "pending_invitations_owner_users_id_fk", - "tableFrom": "pending_invitations", - "tableTo": "users", - "columnsFrom": ["owner"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "pending_invitations_accepted_by_users_id_fk": { - "name": "pending_invitations_accepted_by_users_id_fk", - "tableFrom": "pending_invitations", - "tableTo": "users", - "columnsFrom": ["accepted_by"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "pending_invitations_token_unique": { - "name": "pending_invitations_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.shared_access": { - "name": "shared_access", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "owner": { - "name": "owner", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "permissions": { - "name": "permissions", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invitation_status": { - "name": "invitation_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "default": "'pending'" - }, - "accepted_at": { - "name": "accepted_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "shared_access_owner_idx": { - "name": "shared_access_owner_idx", - "columns": [ - { - "expression": "owner", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "shared_access_user_id_idx": { - "name": "shared_access_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "shared_access_status_idx": { - "name": "shared_access_status_idx", - "columns": [ - { - "expression": "invitation_status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "shared_access_owner_users_id_fk": { - "name": "shared_access_owner_users_id_fk", - "tableFrom": "shared_access", - "tableTo": "users", - "columnsFrom": ["owner"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "shared_access_user_id_users_id_fk": { - "name": "shared_access_user_id_users_id_fk", - "tableFrom": "shared_access", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tags": { - "name": "tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "icon": { - "name": "icon", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_public": { - "name": "is_public", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "usage_count": { - "name": "usage_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "tags_user_id_idx": { - "name": "tags_user_id_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tags_slug_idx": { - "name": "tags_slug_idx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "tags_user_id_users_id_fk": { - "name": "tags_user_id_users_id_fk", - "tableFrom": "tags", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "external_auth_id": { - "name": "external_auth_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "bio": { - "name": "bio", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "location": { - "name": "location", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "website": { - "name": "website", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "github": { - "name": "github", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "twitter": { - "name": "twitter", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "linkedin": { - "name": "linkedin", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instagram": { - "name": "instagram", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "public_profile": { - "name": "public_profile", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "show_click_stats": { - "name": "show_click_stats", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "email_notifications": { - "name": "email_notifications", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": true - }, - "default_expiry": { - "name": "default_expiry", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "profile_background": { - "name": "profile_background", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "verified": { - "name": "verified", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "users_email_idx": { - "name": "users_email_idx", - "columns": [ - { - "expression": "email", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "users_username_idx": { - "name": "users_username_idx", - "columns": [ - { - "expression": "username", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "users_external_auth_id_idx": { - "name": "users_external_auth_id_idx", - "columns": [ - { - "expression": "external_auth_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_external_auth_id_unique": { - "name": "users_external_auth_id_unique", - "nullsNotDistinct": false, - "columns": ["external_auth_id"] - }, - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - }, - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": ["username"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workspaces": { - "name": "workspaces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owner": { - "name": "owner", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "workspaces_slug_idx": { - "name": "workspaces_slug_idx", - "columns": [ - { - "expression": "slug", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "workspaces_owner_idx": { - "name": "workspaces_owner_idx", - "columns": [ - { - "expression": "owner", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "workspaces_owner_users_id_fk": { - "name": "workspaces_owner_users_id_fk", - "tableFrom": "workspaces", - "tableTo": "users", - "columnsFrom": ["owner"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "workspaces_slug_unique": { - "name": "workspaces_slug_unique", - "nullsNotDistinct": false, - "columns": ["slug"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/uload/apps/web/drizzle/meta/_journal.json b/apps/uload/apps/web/drizzle/meta/_journal.json deleted file mode 100644 index d3df524b9..000000000 --- a/apps/uload/apps/web/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1763571183375, - "tag": "0000_material_puma", - "breakpoints": true - } - ] -} diff --git a/apps/uload/apps/web/e2e/demo.test.ts b/apps/uload/apps/web/e2e/demo.test.ts deleted file mode 100644 index 9985ce113..000000000 --- a/apps/uload/apps/web/e2e/demo.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('home page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('h1')).toBeVisible(); -}); diff --git a/apps/uload/apps/web/package.json b/apps/uload/apps/web/package.json index 996decbf2..12badfb2e 100644 --- a/apps/uload/apps/web/package.json +++ b/apps/uload/apps/web/package.json @@ -28,6 +28,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.4", "@tailwindcss/forms": "^0.5.8", "@tailwindcss/typography": "^0.5.16", + "@manacore/shared-vite-config": "workspace:*", "@tailwindcss/vite": "^4.1.11", "@types/eslint__js": "^8.42.3", "@types/node": "^24.3.0", diff --git a/apps/uload/apps/web/playwright.config.ts b/apps/uload/apps/web/playwright.config.ts deleted file mode 100644 index 156389684..000000000 --- a/apps/uload/apps/web/playwright.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - webServer: { - command: 'npm run build && npm run preview', - port: 4173, - }, - testDir: 'e2e', -}); diff --git a/apps/uload/apps/web/src/content/authors/till-schneider.json b/apps/uload/apps/web/src/content/authors/till-schneider.json deleted file mode 100644 index 1f2968dcd..000000000 --- a/apps/uload/apps/web/src/content/authors/till-schneider.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "till-schneider", - "name": "Till Schneider", - "bio": "Gründer von uload und begeistert von der Psychologie hinter digitalem Marketing.", - "avatar": "/images/authors/till.jpg", - "social": { - "twitter": "https://twitter.com/tillschneider", - "linkedin": "https://linkedin.com/in/tillschneider", - "website": "https://ulo.ad" - } -} diff --git a/apps/uload/apps/web/src/content/blog/link-tracking-guide.md b/apps/uload/apps/web/src/content/blog/link-tracking-guide.md deleted file mode 100644 index 86e6f1628..000000000 --- a/apps/uload/apps/web/src/content/blog/link-tracking-guide.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: Der ultimative Link-Tracking Guide für 2024 -excerpt: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben. -date: 2024-01-20 -author: till-schneider -category: tutorial -tags: [tracking, analytics, dsgvo, marketing] -featured: false -image: /blog/link-tracking.jpg ---- - -Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern. - -## Was ist Link-Tracking? - -Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen: - -- Woher kommen Ihre Besucher? -- Welche Kampagnen funktionieren? -- Wie hoch ist Ihre Conversion-Rate? -- Welche Inhalte performen am besten? - -## Die wichtigsten Metriken - -### 1. Click-Through-Rate (CTR) - -Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%. - -### 2. Conversion Rate - -Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen (Kauf, Anmeldung, Download). - -### 3. Bounce Rate - -Wie viele Nutzer verlassen Ihre Seite sofort wieder? Eine hohe Bounce Rate deutet auf Probleme hin. - -### 4. Geographic Distribution - -Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen. - -## UTM-Parameter richtig einsetzen - -UTM-Parameter sind der Standard für Campaign-Tracking: - -``` -https://ulo.ad/angebot -?utm_source=newsletter -&utm_medium=email -&utm_campaign=winter-sale -&utm_content=header-cta -``` - -### Die 5 UTM-Parameter - -1. **utm_source**: Woher kommt der Traffic? (newsletter, google, facebook) -2. **utm_medium**: Welches Medium? (email, cpc, social) -3. **utm_campaign**: Welche Kampagne? (winter-sale, black-friday) -4. **utm_content**: Welcher spezifische Link? (header-cta, footer-link) -5. **utm_term**: Welches Keyword? (bei Paid Search) - -## DSGVO-konformes Tracking - -### Was ist erlaubt? - -✅ **Anonymisierte Daten** - -- Gerätetyp -- Browser -- Ungefährer Standort (Land/Stadt) -- Referrer - -✅ **Aggregierte Metriken** - -- Gesamtklicks -- Durchschnittliche Verweildauer -- Conversion-Raten - -### Was braucht Zustimmung? - -❌ **Personenbezogene Daten** - -- Vollständige IP-Adressen -- Device Fingerprinting -- Cross-Site Tracking -- Retargeting-Pixel - -## Best Practices für Link-Tracking - -### 1. Konsistente Namenskonvention - -Entwickeln Sie ein einheitliches Schema: - -``` -utm_source: [channel] -utm_medium: [type] -utm_campaign: [yyyy-mm]-[campaign-name] -``` - -### 2. Dokumentation führen - -Erstellen Sie eine Tracking-Tabelle: -| Kampagne | Source | Medium | Link | Erstellt | -|----------|--------|--------|------|----------| -| Winter Sale | newsletter | email | /winter | 2024-01-15 | - -### 3. Regelmäßige Bereinigung - -Löschen Sie alte, inaktive Links und konsolidieren Sie ähnliche Kampagnen. - -## A/B-Testing mit Links - -Testen Sie verschiedene Varianten: - -- Verschiedene Call-to-Actions -- Unterschiedliche Landing Pages -- Alternative Platzierungen -- Timing-Experimente - -## Tools und Integration - -### Google Analytics 4 - -- Automatisches UTM-Tracking -- Conversion-Tracking -- Audience-Segmentierung - -### Marketing-Automation - -- HubSpot -- Mailchimp -- ActiveCampaign - -### Social Media Tools - -- Buffer -- Hootsuite -- Sprout Social - -## Fehler, die Sie vermeiden sollten - -1. **Inkonsistente Parameter**: newsletter vs Newsletter vs Email-Newsletter -2. **Zu viele Parameter**: Halten Sie es simpel -3. **Keine Dokumentation**: Nach 6 Monaten weiß niemand mehr, was "camp-x1" war -4. **Ignorieren der Daten**: Tracking ohne Analyse ist nutzlos - -## Zukunft des Link-Trackings - -- **Privacy-First**: Mehr Fokus auf aggregierte, anonyme Daten -- **Server-Side Tracking**: Umgehung von Ad-Blockern -- **KI-gestützte Analyse**: Automatische Mustererkennung -- **Cross-Device Attribution**: Besseres Verständnis der Customer Journey - -## Fazit - -Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern und dabei vollständig DSGVO-konform bleiben. - -Starten Sie noch heute mit professionellem Link-Tracking – Ihre Conversion-Rate wird es Ihnen danken! diff --git a/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md b/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md deleted file mode 100644 index ec75cc6e5..000000000 --- a/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt -excerpt: 42% weniger Klicks bei langen URLs – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter. -date: 2024-01-15 -author: till-schneider -category: psychology -tags: [urls, psychology, conversion, marketing] -featured: true -image: /blog/psychology-urls.jpg -seo: - title: URL-Psychologie Guide 2024 - Warum kurze Links funktionieren | uload Blog - description: Erfahren Sie, warum kurze URLs 42% mehr Klicks erhalten. Wissenschaftlich fundierte Erkenntnisse zur Cognitive Load Theory und praktische Tipps für bessere Conversion-Rates. ---- - -**42% weniger Klicks bei langen URLs** – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können. - -## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen - -Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link – nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance. - -### Die Spam-Alarm-Reaktion unseres Gehirns - -Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen. Diese evolutionäre Schutzreaktion lässt uns instinktiv zurückschrecken. - -Vergleichen Sie diese beiden URLs: - -**Lange URL (schlecht):** - -``` -https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024&ref=user789&tracking=enabled -``` - -**Kurze URL (gut):** - -``` -https://ulo.ad/summer-sale -``` - -Der Unterschied ist offensichtlich, oder? - -### Mobile Nutzer: Die vergessene Mehrheit - -In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen. Was nicht auf den ersten Blick erkennbar ist, wird ignoriert – eine simple, aber folgenreiche Wahrheit. - -## Die Wissenschaft dahinter: Cognitive Load Theory - -### Warum unser Gehirn faul ist (und das gut so ist) - -Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen – es ist evolutionär faul, aber auf eine intelligente Weise. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands. - -Wenn wir einen kurzen, klaren Link sehen, kann unser Gehirn ihn schnell verarbeiten und kategorisieren. Diese mühelose Verarbeitung erzeugt ein positives Gefühl – wir verbinden "einfach" automatisch mit "sicher" und "vertrauenswürdig". - -### Der Halo-Effekt kurzer URLs - -Psychologen nennen es den Halo-Effekt: Ein positives Merkmal (die Kürze des Links) überträgt sich auf die gesamte Wahrnehmung. Ein kurzer, sauberer Link lässt uns unbewusst annehmen, dass auch die Zielseite professionell, sicher und relevant sein wird. - -## Die vier Säulen des Link-Vertrauens - -Unsere Analyse von über 10.000 Link-Klicks hat vier Hauptfaktoren identifiziert: - -### 1. Erkennbare Domain (60% Wichtigkeit) - -Menschen wollen wissen, wo sie landen werden. Eine klare, erkennbare Domain ist der wichtigste Vertrauensfaktor: - -- Verwenden Sie Ihre Marken-Domain wenn möglich -- Bei Kurz-URLs: Wählen Sie einen Service mit gutem Ruf -- Vermeiden Sie obskure URL-Shortener - -### 2. Keine kryptischen Zeichen (25% Wichtigkeit) - -Zufällige Zahlen-Buchstaben-Kombinationen wie "x7h9k2p" schrecken Nutzer ab. Stattdessen: - -- Nutzen Sie sprechende Begriffe -- Verwenden Sie relevante Keywords -- Halten Sie es lesbar und merkbar - -### 3. Optimale Länge (10% Wichtigkeit) - -Die magische Grenze liegt bei etwa 50 Zeichen: - -- **15-30 Zeichen**: Optimal für Social Media -- **30-50 Zeichen**: Ideal für E-Mail-Marketing -- **Über 50 Zeichen**: Deutlicher Rückgang der Klickrate - -### 4. HTTPS-Verschlüsselung (5% Wichtigkeit) - -Das kleine Schloss-Symbol mag nur 5% ausmachen, aber es ist ein Hygienefaktor – fehlt es, kann das Vertrauen komplett zerstört werden. - -## Praktische Optimierungsstrategien - -### 1. Sprechende URLs verwenden - -❌ **Schlecht:** `ulo.ad/p47829` -✅ **Gut:** `ulo.ad/sommer-sale` - -Der Unterschied? Der zweite Link kommuniziert sofort, was den Nutzer erwartet. Diese Transparenz erhöht die Klickrate um durchschnittlich 39%. - -### 2. Die 50-Zeichen-Regel - -Halten Sie Ihre URLs unter 50 Zeichen. Das ist: - -- Kurz genug für Twitter/X -- Lesbar auf Mobilgeräten -- Merkbar für Nutzer -- Optimal für die Anzeige in E-Mails - -### 3. A/B-Testing ist Ihr Freund - -Testen Sie verschiedene URL-Varianten: - -- Kurz vs. deskriptiv -- Mit Markenname vs. ohne -- Verschiedene Keywords -- Unterschiedliche Strukturen - -### 4. Performance-Tracking implementieren - -Ohne Daten keine Optimierung. Moderne Link-Management-Tools bieten: - -- Detaillierte Klick-Statistiken -- Geografische Verteilung -- Geräteerkennung -- Referrer-Tracking -- Conversion-Tracking - -## Case Studies: Erfolgsgeschichten - -### E-Commerce: 67% mehr Conversions - -Ein großer Online-Händler verkürzte seine Produkt-URLs von durchschnittlich 120 auf 45 Zeichen: - -- **67% höhere Conversion Rate** -- **42% mehr Social Shares** -- **31% niedrigere Bounce Rate** - -### Newsletter-Marketing: Verdoppelte Klickrate - -Ein B2B-Unternehmen wechselte von langen Tracking-URLs zu personalisierten Kurz-URLs: - -- **Vorher:** `company.com/newsletter/2024/march/article-5?utm_source=email&utm_medium=newsletter` -- **Nachher:** `co.link/cloud-guide` -- **Resultat:** 2,1x höhere Klickrate - -## Die Zukunft kurzer URLs - -### KI-optimierte Personalisierung - -Moderne Systeme nutzen KI, um für jeden Nutzer die optimale URL-Variante zu generieren – basierend auf: - -- Demografischen Daten -- Bisherigem Klickverhalten -- Kontext der Interaktion -- Tageszeit und Gerät - -### Voice-First Optimization - -Mit dem Aufstieg von Sprachassistenten werden "sprechbare" URLs wichtiger: - -- Einfache Wörter statt Buchstaben-Zahlen-Kombinationen -- Vermeidung ähnlich klingender Begriffe -- Klare, eindeutige Aussprache - -## Fazit: Die Macht der Kürze - -Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen. - -### Die wichtigsten Takeaways - -1. **42% weniger Klicks** bei URLs über 100 Zeichen -2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit -3. **50 Zeichen** ist die magische Grenze -4. **Sprechende URLs** performen 39% besser -5. **Mobile First**: Über 60% surfen mobil -6. **Vertrauen** ist wichtiger als Tracking - -### Ihre nächsten Schritte - -1. **Audit**: Analysieren Sie Ihre aktuellen URLs -2. **Optimieren**: Kürzen und verbessern Sie systematisch -3. **Testen**: A/B-Tests für verschiedene Varianten -4. **Messen**: Tracking der Performance-Verbesserungen -5. **Iterieren**: Kontinuierliche Optimierung basierend auf Daten - -Tools wie [uload](https://ulo.ad) wurden speziell entwickelt, um die Erkenntnisse der URL-Psychologie in die Praxis umzusetzen. Mit Features wie personalisierten Kurz-URLs, detaillierten Analytics und A/B-Testing können Sie sofort damit beginnen, Ihre Link-Performance zu optimieren. diff --git a/apps/uload/apps/web/src/content/config.ts b/apps/uload/apps/web/src/content/config.ts deleted file mode 100644 index 2d534a399..000000000 --- a/apps/uload/apps/web/src/content/config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from 'zod'; - -// Author Schema -export const authorSchema = z.object({ - id: z.string(), - name: z.string(), - bio: z.string().optional(), - avatar: z.string().optional(), - social: z - .object({ - twitter: z.string().optional(), - github: z.string().optional(), - linkedin: z.string().optional(), - website: z.string().optional(), - }) - .optional(), -}); - -// Blog Post Schema -export const blogSchema = z.object({ - title: z.string(), - excerpt: z.string(), - date: z - .string() - .or(z.date()) - .transform((val) => new Date(val)), - author: z.string(), // Author ID - tags: z.array(z.string()).default([]), - category: z.enum(['tutorial', 'psychology', 'feature', 'announcement', 'case-study']), - image: z.string().optional(), - draft: z.boolean().default(false), - featured: z.boolean().default(false), - series: z.string().optional(), - layout: z.string().default('blog'), - seo: z - .object({ - title: z.string().optional(), - description: z.string().optional(), - canonical: z.string().optional(), - }) - .optional(), -}); - -// Type exports -export type BlogPost = z.infer; -export type Author = z.infer; - -// 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; -} diff --git a/apps/uload/apps/web/src/demo.spec.ts b/apps/uload/apps/web/src/demo.spec.ts deleted file mode 100644 index e07cbbd72..000000000 --- a/apps/uload/apps/web/src/demo.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte b/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte deleted file mode 100644 index 8f2c6ba63..000000000 --- a/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte +++ /dev/null @@ -1,251 +0,0 @@ - - -{#if showDebug} -
-
-
A/B Test Debug
-
Variant: {variant}
-
Name: {content.name}
-
Locale: {get(locale)}
-
- -
-
-
-{/if} - -
- -
-
- -
- {#if !isLoading} -
- -

- {#if variant === 'b2' && content.headline.includes(',')} - - {content.headline.split(',')[0]}, - {content.headline.split(',').slice(1).join(',')} - {:else} - {content.headline} - {/if} -

- - -

- {content.subheadline} -

- - - {#if content.socialProof} -
- {#if content.socialProof.type === 'numbers'} -
- {#each content.socialProof.content.split('•') as stat} - - - {stat.trim()} - - {/each} -
- {:else if content.socialProof.type === 'logos'} -
- {#each content.socialProof.content.split('•') as logo} - - {logo.trim()} - - {/each} -
- {:else if content.socialProof.type === 'testimonial'} -
- {content.socialProof.content} -
- {/if} -
- {/if} - - - {#if content.features && content.features.length > 0} -
- {#each content.features.slice(0, 3) as feature} -
- - - - {feature} -
- {/each} - {#if content.features.length > 3} - {#each content.features.slice(3) as feature} -
- - - - {feature} -
- {/each} - {/if} -
- {/if} - - -
- - {content.ctaText} - - - {#if !data.user} -

- {freeText} -

- {/if} -
- - -
- {#each trustBadges as badge} - - {badge.icon} - {badge.text} - - {/each} -
-
- {:else} - -
-
Loading...
-
- {/if} -
-
diff --git a/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts b/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts deleted file mode 100644 index 5c8e1a933..000000000 --- a/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * A/B Testing Variant Configurations - * Defines content and styling for each variant using multilingual messages - */ - -import * as m from '$paraglide/messages'; - -export interface VariantContent { - id: string; - name: string; - headline: string; - subheadline: string; - ctaText: string; - ctaStyle?: string; - features?: string[]; - socialProof?: { - type: 'numbers' | 'logos' | 'testimonial'; - content: string; - }; - layout?: 'standard' | 'split' | 'centered'; -} - -// Get variant content with multilingual support -export function getVariantContent(variantId: string): VariantContent { - switch (variantId) { - case 'control': - return { - id: 'control', - name: 'Control (Baseline)', - headline: m.hero_control_headline(), - subheadline: m.hero_control_subheadline(), - ctaText: m.hero_control_cta(), - ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover', - layout: 'standard', - }; - - // Variant A - Value Focused - case 'a1': - return { - id: 'a1', - name: 'Value Generic', - headline: m.hero_a1_headline(), - subheadline: m.hero_a1_subheadline(), - ctaText: m.hero_a1_cta(), - ctaStyle: 'bg-blue-600 hover:bg-blue-700', - features: [m.hero_a1_feature_1(), m.hero_a1_feature_2(), m.hero_a1_feature_3()], - layout: 'standard', - }; - - case 'a2': - return { - id: 'a2', - name: 'Value Specific', - headline: 'Save 3 Hours Per Week on Link Management', - subheadline: 'Join teams who reduced their link management tasks by 75%', - ctaText: 'Calculate Your Savings', - ctaStyle: - 'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700', - features: ['3 hours saved weekly', '75% faster workflows', 'ROI in 2 weeks'], - layout: 'standard', - }; - - case 'a3': - return { - id: 'a3', - name: 'Value Transform', - headline: 'Your Links, 10x More Powerful', - subheadline: 'Transform every URL into a conversion machine with analytics and automation', - ctaText: 'Unlock Link Power →', - ctaStyle: 'bg-black hover:bg-gray-800', - features: ['10x more clicks', 'Conversion tracking', 'Smart redirects'], - layout: 'centered', - }; - - // Variant B - Social Proof - case 'b1': - return { - id: 'b1', - name: 'Social Numbers', - headline: m.hero_b1_headline(), - subheadline: m.hero_b1_subheadline(), - ctaText: m.hero_b1_cta(), - ctaStyle: 'bg-purple-600 hover:bg-purple-700', - socialProof: { - type: 'numbers', - content: m.hero_b1_social(), - }, - layout: 'standard', - }; - - case 'b2': - return { - id: 'b2', - name: 'Social Logos', - headline: 'Trusted by Google, Meta, and Microsoft Teams', - subheadline: 'Enterprise-grade URL management for companies of all sizes', - ctaText: 'See Why They Chose Us', - ctaStyle: - 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700', - socialProof: { - type: 'logos', - content: 'Google • Meta • Microsoft • Spotify • Netflix', - }, - layout: 'standard', - }; - - case 'b3': - return { - id: 'b3', - name: 'Social Testimonial', - headline: 'Rated #1 URL Shortener by Marketing Teams', - subheadline: '"uLoad saved us 5 hours per week and increased our CTR by 40%"', - ctaText: 'Read Success Stories', - ctaStyle: 'bg-green-600 hover:bg-green-700', - socialProof: { - type: 'testimonial', - content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews', - }, - layout: 'centered', - }; - - // Variant C - Feature Focused - case 'c1': - return { - id: 'c1', - name: 'Features All-in-One', - headline: m.hero_c1_headline(), - subheadline: m.hero_c1_subheadline(), - ctaText: m.hero_c1_cta(), - ctaStyle: 'bg-indigo-600 hover:bg-indigo-700', - features: [ - m.hero_c1_feature_1(), - m.hero_c1_feature_2(), - m.hero_c1_feature_3(), - m.hero_c1_feature_4(), - m.hero_c1_feature_5(), - m.hero_c1_feature_6(), - ], - layout: 'standard', - }; - - case 'c2': - return { - id: 'c2', - name: 'Features QR Focus', - headline: 'QR Codes That Actually Convert', - subheadline: 'Create dynamic QR codes with real-time analytics and custom branding', - ctaText: 'Create Your First QR Code', - ctaStyle: 'bg-orange-600 hover:bg-orange-700', - features: ['Dynamic QR codes', 'Custom designs', 'Scan analytics', 'Bulk generation'], - layout: 'split', - }; - - case 'c3': - return { - id: 'c3', - name: 'Features Integration', - headline: 'Works With Your Favorite Tools', - subheadline: 'Seamless integration with Zapier, Slack, WordPress & 100+ platforms', - ctaText: 'Connect Your Tools', - ctaStyle: 'bg-teal-600 hover:bg-teal-700', - features: [ - 'Zapier automation', - 'Slack notifications', - 'WordPress plugin', - 'API & Webhooks', - ], - layout: 'standard', - }; - - // Default to control - default: - return { - id: 'control', - name: 'Control (Baseline)', - headline: m.hero_control_headline(), - subheadline: m.hero_control_subheadline(), - ctaText: m.hero_control_cta(), - ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover', - layout: 'standard', - }; - } -} - -// Get all active variant IDs -export function getActiveVariantIds(): string[] { - return ['control', 'a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3']; -} - -// Check if variant exists -export function isValidVariant(variantId: string): boolean { - return getActiveVariantIds().includes(variantId); -} - -// Get trust badges with translations -export function getTrustBadges(): Array<{ icon: string; text: string }> { - return [ - { icon: '🔒', text: m.hero_trust_badge_1() }, - { icon: '🇪🇺', text: m.hero_trust_badge_2() }, - { icon: '⚡', text: m.hero_trust_badge_3() }, - { icon: '🚀', text: m.hero_trust_badge_4() }, - ]; -} - -// Get free text -export function getFreeText(): string { - return m.hero_free_text(); -} diff --git a/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts b/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts deleted file mode 100644 index 5bfd16e33..000000000 --- a/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Hash-based A/B Testing Manager - * Manages variant assignment and persistence via URL hash - */ -export class HashManager { - // Valid variants with versions - private readonly validVariants = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3']; - - // Current traffic distribution (percentages must sum to 100) - private readonly distribution: Record = { - 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 { - 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(); diff --git a/apps/uload/apps/web/src/lib/actions/clickOutside.ts b/apps/uload/apps/web/src/lib/actions/clickOutside.ts deleted file mode 100644 index 8335869ca..000000000 --- a/apps/uload/apps/web/src/lib/actions/clickOutside.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Click outside action for Svelte components -export function clickOutside(node: HTMLElement, callback: () => void) { - const handleClick = (event: MouseEvent) => { - if (node && !node.contains(event.target as Node) && !event.defaultPrevented) { - callback(); - } - }; - - document.addEventListener('click', handleClick, true); - - return { - destroy() { - document.removeEventListener('click', handleClick, true); - }, - }; -} diff --git a/apps/uload/apps/web/src/lib/actions/touch.test.ts b/apps/uload/apps/web/src/lib/actions/touch.test.ts deleted file mode 100644 index 28f0caf72..000000000 --- a/apps/uload/apps/web/src/lib/actions/touch.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, test, expect, beforeEach, vi } from 'vitest'; -import { isTouchDevice, isOptimalTouchTarget } from './touch'; - -// Mock DOM APIs für Tests -const mockEventListener = vi.fn(); -const mockRemoveEventListener = vi.fn(); - -const createMockElement = (width = 44, height = 44) => ({ - addEventListener: mockEventListener, - removeEventListener: mockRemoveEventListener, - getBoundingClientRect: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }), - style: {}, - appendChild: vi.fn(), - remove: vi.fn(), -}); - -// Mock global objects -Object.defineProperty(window, 'navigator', { - value: { - maxTouchPoints: 0, - userAgent: 'Mozilla/5.0 (Test Browser)', - }, - writable: true, -}); - -describe('Touch Utilities', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset touch support - delete (window as any).ontouchstart; - (window.navigator as any).maxTouchPoints = 0; - }); - - describe('isTouchDevice', () => { - test('should detect touch support via ontouchstart', () => { - (window as any).ontouchstart = true; - expect(isTouchDevice()).toBe(true); - }); - - test('should detect touch support via maxTouchPoints', () => { - (window.navigator as any).maxTouchPoints = 1; - expect(isTouchDevice()).toBe(true); - }); - - test('should return false for non-touch devices', () => { - expect(isTouchDevice()).toBe(false); - }); - }); - - describe('isOptimalTouchTarget', () => { - test('should return true for 44x44 elements', () => { - const element = createMockElement(44, 44); - expect(isOptimalTouchTarget(element as any)).toBe(true); - }); - - test('should return true for larger elements', () => { - const element = createMockElement(50, 60); - expect(isOptimalTouchTarget(element as any)).toBe(true); - }); - - test('should return false for small width', () => { - const element = createMockElement(30, 44); - expect(isOptimalTouchTarget(element as any)).toBe(false); - }); - - test('should return false for small height', () => { - const element = createMockElement(44, 30); - expect(isOptimalTouchTarget(element as any)).toBe(false); - }); - - test('should return false for small elements', () => { - const element = createMockElement(20, 20); - expect(isOptimalTouchTarget(element as any)).toBe(false); - }); - }); -}); - -describe('Touch Actions (Integration)', () => { - let mockElement: any; - - beforeEach(() => { - mockElement = createMockElement(); - vi.clearAllMocks(); - }); - - describe('Event Registration', () => { - test('should register touch and pointer events', () => { - // Diese Tests würden die tatsächlichen Touch-Actions testen - // Für jetzt testen wir nur die Utility-Funktionen - expect(mockEventListener).not.toHaveBeenCalled(); - }); - }); - - describe('Gesture Recognition', () => { - test('should calculate touch distances correctly', () => { - const touch1 = { clientX: 0, clientY: 0 }; - const touch2 = { clientX: 100, clientY: 100 }; - - // Math.sqrt(100^2 + 100^2) = Math.sqrt(20000) ≈ 141.42 - const expectedDistance = Math.sqrt(20000); - const actualDistance = Math.sqrt( - Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2) - ); - - expect(actualDistance).toBeCloseTo(expectedDistance, 2); - }); - - test('should detect horizontal swipes', () => { - const startTouch = { clientX: 0, clientY: 100 }; - const endTouch = { clientX: 100, clientY: 100 }; - - const deltaX = endTouch.clientX - startTouch.clientX; - const deltaY = endTouch.clientY - startTouch.clientY; - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - // Horizontal swipe: |deltaX| > |deltaY| - expect(absDeltaX).toBeGreaterThan(absDeltaY); - expect(deltaX).toBeGreaterThan(0); // Right swipe - }); - - test('should detect vertical swipes', () => { - const startTouch = { clientX: 100, clientY: 0 }; - const endTouch = { clientX: 100, clientY: 100 }; - - const deltaX = endTouch.clientX - startTouch.clientX; - const deltaY = endTouch.clientY - startTouch.clientY; - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - // Vertical swipe: |deltaY| > |deltaX| - expect(absDeltaY).toBeGreaterThan(absDeltaX); - expect(deltaY).toBeGreaterThan(0); // Down swipe - }); - }); - - describe('Touch Target Validation', () => { - test('should validate minimum touch target sizes', () => { - const sizes = [ - { width: 44, height: 44, expected: true }, - { width: 48, height: 48, expected: true }, - { width: 40, height: 40, expected: false }, - { width: 44, height: 40, expected: false }, - { width: 40, height: 44, expected: false }, - ]; - - sizes.forEach(({ width, height, expected }) => { - const element = createMockElement(width, height); - expect(isOptimalTouchTarget(element as any)).toBe(expected); - }); - }); - }); - - describe('Performance Considerations', () => { - test('should handle rapid touch events', () => { - // Simuliere viele schnelle Touch-Events - const events = Array.from({ length: 100 }, (_, i) => ({ - clientX: i, - clientY: i, - timestamp: Date.now() + i, - })); - - // In einer echten Implementation würden wir Throttling/Debouncing testen - expect(events).toHaveLength(100); - - // Teste dass Events innerhalb vernünftiger Zeit verarbeitet werden können - const startTime = Date.now(); - events.forEach((event) => { - // Simuliere Event-Verarbeitung - const deltaX = event.clientX; - const deltaY = event.clientY; - Math.sqrt(deltaX * deltaX + deltaY * deltaY); - }); - const endTime = Date.now(); - - expect(endTime - startTime).toBeLessThan(100); // Sollte sehr schnell sein - }); - }); - - describe('Accessibility Considerations', () => { - test('should maintain focus accessibility', () => { - // Touch-Actions sollten Keyboard-Navigation nicht beeinträchtigen - const element = createMockElement(); - - // Simuliere dass Element fokussierbar bleibt - element.tabIndex = 0; - element.setAttribute = vi.fn(); - - expect(element.tabIndex).toBe(0); - }); - - test('should work with screen readers', () => { - // Touch-Targets sollten Screen-Reader-kompatibel bleiben - const element = createMockElement(); - element.getAttribute = vi.fn().mockReturnValue('button'); - element.textContent = 'Touch Button'; - - expect(element.getAttribute('role')).toBe('button'); - expect(element.textContent).toBe('Touch Button'); - }); - }); -}); diff --git a/apps/uload/apps/web/src/lib/actions/touch.ts b/apps/uload/apps/web/src/lib/actions/touch.ts deleted file mode 100644 index d4edcf17f..000000000 --- a/apps/uload/apps/web/src/lib/actions/touch.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Touch-optimierte Aktionen für mobile Geräte -import type { Action } from 'svelte/action'; - -// Touch-optimierte Ripple-Effekte -export const ripple: Action = ( - 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 = (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 = (node, options = {}) => { - const { duration = 500, onLongPress } = options; - - let timer: ReturnType; - 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 = (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; -} diff --git a/apps/uload/apps/web/src/lib/analytics.ts b/apps/uload/apps/web/src/lib/analytics.ts deleted file mode 100644 index 59c59f5da..000000000 --- a/apps/uload/apps/web/src/lib/analytics.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Umami Analytics Event Tracking - * Provides type-safe event tracking with Umami Analytics - */ - -declare global { - interface Window { - umami?: { - track: (eventName: string, data?: Record) => 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): 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 - ) - : 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', - }); -} diff --git a/apps/uload/apps/web/src/lib/api/feedback.ts b/apps/uload/apps/web/src/lib/api/feedback.ts deleted file mode 100644 index eff57a51d..000000000 --- a/apps/uload/apps/web/src/lib/api/feedback.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Feedback Service Instance for uLoad Web App - */ - -import { createFeedbackService } from '@manacore/shared-feedback-service'; -import { pb } from '$lib/pocketbase'; -import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; - -const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; - -export const feedbackService = createFeedbackService({ - apiUrl: MANA_AUTH_URL, - appId: 'uload', - getAuthToken: async () => pb.authStore.token || '', -}); diff --git a/apps/uload/apps/web/src/lib/auth-helper.ts b/apps/uload/apps/web/src/lib/auth-helper.ts deleted file mode 100644 index 03753c0ae..000000000 --- a/apps/uload/apps/web/src/lib/auth-helper.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { pb } from './pocketbase'; -import { generateUsernameFromEmail } from './username'; - -export interface RegisterData { - email: string; - password: string; - passwordConfirm: string; -} - -export interface RegisterResult { - success: boolean; - user?: any; - error?: string; -} - -export async function registerUser(data: RegisterData): Promise { - 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', - }; - } -} diff --git a/apps/uload/apps/web/src/lib/cache.test.ts b/apps/uload/apps/web/src/lib/cache.test.ts deleted file mode 100644 index eee6c5fb0..000000000 --- a/apps/uload/apps/web/src/lib/cache.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, test, expect, beforeEach, vi } from 'vitest'; -import { cache, cacheKey, CacheKeys } from './cache'; - -describe('Cache System', () => { - beforeEach(() => { - cache.clear(); - }); - - describe('Basic Cache Operations', () => { - test('should set and get values', () => { - const key = 'test-key'; - const value = { data: 'test' }; - - cache.set(key, value); - const result = cache.get(key); - - expect(result).toEqual(value); - }); - - test('should return null for non-existent keys', () => { - const result = cache.get('non-existent'); - expect(result).toBeNull(); - }); - - test('should handle TTL expiration', async () => { - const key = 'ttl-test'; - const value = 'test-value'; - const shortTTL = 10; // 10ms - - cache.set(key, value, shortTTL); - - // Should be available immediately - expect(cache.get(key)).toBe(value); - - // Wait for TTL to expire - await new Promise((resolve) => setTimeout(resolve, 20)); - - // Should be null after expiration - expect(cache.get(key)).toBeNull(); - }); - - test('should delete specific keys', () => { - cache.set('key1', 'value1'); - cache.set('key2', 'value2'); - - cache.delete('key1'); - - expect(cache.get('key1')).toBeNull(); - expect(cache.get('key2')).toBe('value2'); - }); - - test('should clear all keys', () => { - cache.set('key1', 'value1'); - cache.set('key2', 'value2'); - - cache.clear(); - - expect(cache.get('key1')).toBeNull(); - expect(cache.get('key2')).toBeNull(); - }); - }); - - describe('Cache Key Generation', () => { - test('should generate cache keys correctly', () => { - const key = cacheKey('user', 123, 'profile'); - expect(key).toBe('user:123:profile'); - }); - - test('should handle different data types in keys', () => { - const key = cacheKey('prefix', 42, 'suffix', true); - expect(key).toBe('prefix:42:suffix:true'); - }); - - test('should generate predefined cache keys', () => { - expect(CacheKeys.userLinks('user123')).toBe('user:user123:links'); - expect(CacheKeys.linkStats('link456')).toBe('link:link456:stats'); - expect(CacheKeys.userProfile('john')).toBe('profile:john'); - expect(CacheKeys.linkRedirect('abc123')).toBe('redirect:abc123'); - }); - }); - - describe('Cache Cleanup', () => { - test('should cleanup expired entries', async () => { - const shortTTL = 10; // 10ms - - cache.set('key1', 'value1', shortTTL); - cache.set('key2', 'value2', 60000); // 1 minute - - // Wait for first key to expire - await new Promise((resolve) => setTimeout(resolve, 20)); - - cache.cleanup(); - - expect(cache.get('key1')).toBeNull(); - expect(cache.get('key2')).toBe('value2'); - }); - }); - - describe('Type Safety', () => { - test('should handle typed values correctly', () => { - interface TestData { - id: string; - name: string; - count: number; - } - - const key = 'typed-test'; - const value: TestData = { id: '123', name: 'test', count: 42 }; - - cache.set(key, value); - const result = cache.get(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); - }); - }); -}); diff --git a/apps/uload/apps/web/src/lib/cache.ts b/apps/uload/apps/web/src/lib/cache.ts deleted file mode 100644 index 2b6a4f986..000000000 --- a/apps/uload/apps/web/src/lib/cache.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Simple in-memory cache with TTL for server-side caching -// In Produktion kann das durch Redis/Valkey ersetzt werden - -interface CacheEntry { - data: T; - expiresAt: number; -} - -class SimpleCache { - private cache = new Map>(); - private readonly defaultTTL = 5 * 60 * 1000; // 5 Minuten default - - set(key: string, data: T, ttlMs: number = this.defaultTTL): void { - const expiresAt = Date.now() + ttlMs; - this.cache.set(key, { data, expiresAt }); - } - - get(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(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 { - const key = keyGenerator(...args); - const cached = cache.get(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; diff --git a/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte b/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte deleted file mode 100644 index bda3e7e7c..000000000 --- a/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte +++ /dev/null @@ -1,180 +0,0 @@ - - -
- - - {#if showDropdown} -
- - {#if accounts.currentUser} -
-
- {m.account_my_account()} -
- -
- {/if} - - - {#if accounts.sharedAccounts && accounts.sharedAccounts.length > 0} -
-
- {m.account_team_accounts()} -
- {#each accounts.sharedAccounts as shared} - {#if shared.expand?.owner} - - {/if} - {/each} -
- {:else} - -
-

- {m.account_no_team_accounts()} -

-

- {m.account_team_invite_info()} -

-
- {/if} - - -
- -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/Button.svelte b/apps/uload/apps/web/src/lib/components/Button.svelte deleted file mode 100644 index a3485a353..000000000 --- a/apps/uload/apps/web/src/lib/components/Button.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/apps/uload/apps/web/src/lib/components/DataTable.svelte b/apps/uload/apps/web/src/lib/components/DataTable.svelte deleted file mode 100644 index 8681ef62a..000000000 --- a/apps/uload/apps/web/src/lib/components/DataTable.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -{#if items && items.length > 0} -
- {#if title} -
-

- {title} -

-
- {/if} - - {#if isMobile && renderMobileCard} - -
- {#each items as item} - {@html renderMobileCard(item)} - {/each} -
- {:else} - - - - - -
- {#each items as item} - - - - -
- {#if renderMobileCard} - {@html renderMobileCard(item)} - {:else} - -
- {#each columns.filter((col) => !col.hideOnMobile) as column} -
- {column.label}: - - {#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} - -
- {/each} -
- {/if} -
- {/each} -
- {/if} -
-{:else} -
-

- {emptyMessage} -

-
-{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/Dropdown.svelte b/apps/uload/apps/web/src/lib/components/Dropdown.svelte deleted file mode 100644 index 73c29e2a0..000000000 --- a/apps/uload/apps/web/src/lib/components/Dropdown.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - -
- - - {#if isOpen} -
- {#each items as item, index} - {#if item.divider} -
- {:else if item.type === 'form'} -
{ - return async ({ update }) => { - closeDropdown(); - await update(); - }; - })} - > - {#if item.formData} - {#each Object.entries(item.formData) as [name, value]} - - {/each} - {/if} - -
- {:else if item.href} - closeDropdown()} class={getItemClasses(item.color)}> - {#if item.icon} - {@html item.icon} - {/if} - {item.label} - - {:else} - - {/if} - {/each} -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte b/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte deleted file mode 100644 index dd93ca8dd..000000000 --- a/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte +++ /dev/null @@ -1,644 +0,0 @@ - - -{#if user && mounted} - -{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/Footer.svelte b/apps/uload/apps/web/src/lib/components/Footer.svelte deleted file mode 100644 index 357e8f4d0..000000000 --- a/apps/uload/apps/web/src/lib/components/Footer.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - diff --git a/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte b/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte deleted file mode 100644 index abe6f2d2d..000000000 --- a/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- - - {#if showDropdown} -
- {#each languages as lang} - - {/each} -
- {/if} -
- - { - // Close dropdown when clicking outside - if (showDropdown && !(e.target as HTMLElement)?.closest('.relative')) { - showDropdown = false; - } - }} -/> diff --git a/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte b/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte deleted file mode 100644 index 634f286bd..000000000 --- a/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
-
-
- - - {#if usageInfo.unlimited} - Unbegrenzte Links - {:else} - Link-Nutzung diesen Monat - {/if} - -
- {#if !usageInfo.unlimited} - - {usageInfo.current} / {usageInfo.limit} - - {/if} -
- - {#if !usageInfo.unlimited} - -
-
-
- - -
- {#if usageInfo.status === 'danger'} - - Monatslimit erreicht! Upgrade für mehr Links. - - {:else if usageInfo.status === 'warning'} - - {usageInfo.limit - usageInfo.current} Links verbleibend - - {:else} - - {usageInfo.limit - usageInfo.current} Links verbleibend - - {/if} -
- {:else} -
- 🎉 Du hast unbegrenzten Zugang zu allen Features! -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte b/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte deleted file mode 100644 index adbcae965..000000000 --- a/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte +++ /dev/null @@ -1,306 +0,0 @@ - - -{#if user && open} - -
- - - -{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/Navigation.svelte b/apps/uload/apps/web/src/lib/components/Navigation.svelte deleted file mode 100644 index c7a41a84a..000000000 --- a/apps/uload/apps/web/src/lib/components/Navigation.svelte +++ /dev/null @@ -1,840 +0,0 @@ - - - - - - - - - -{#if mobileMenuOpen} - -{/if} - - -{#if mobileMenuOpen} -
-
-
- {#if user} - - - - - - - -
-

Preferences

-
- - - - Theme - -
-
- - - - Language - -
-
- {:else} - - - - -
-

Preferences

-
- - - - Theme - -
-
- - - - Language - -
-
- {/if} -
- - - -
-
-{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/NotificationBell.svelte b/apps/uload/apps/web/src/lib/components/NotificationBell.svelte deleted file mode 100644 index 3c4a2ff19..000000000 --- a/apps/uload/apps/web/src/lib/components/NotificationBell.svelte +++ /dev/null @@ -1,270 +0,0 @@ - - -
- - - - - {#if showDropdown} -
- -
-
-

Benachrichtigungen

-
- {#if $unreadCount > 0} - - {/if} - -
-
-
- - -
- {#if $notifications.loading} -
-
-

Lade Benachrichtigungen...

-
- {:else if $notifications.notifications.length === 0} -
- -

Keine Benachrichtigungen

-
- {:else} -
- {#each $notifications.notifications as notification, i} -
-
- -
- - {getNotificationIcon(notification.type)} - -
- - -
-
- - - -
- {#if !notification.read} - - {/if} - -
-
- - {#if notification.type === 'team_invite' && notification.action_url} - - {/if} -
-
-
- {/each} -
- {/if} -
-
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte b/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte deleted file mode 100644 index 6fb058dd3..000000000 --- a/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte +++ /dev/null @@ -1,145 +0,0 @@ - - -{#if user && sharedAccounts.length > 0} -
- - - {#if isOpen} - - - - -
-
- - - - {#if sharedAccounts.length > 0} -
- - -
-

Team Accounts

-
- - {#each sharedAccounts as shared} - {#if shared.invitation_status === 'accepted'} - - {/if} - {/each} - {/if} -
-
- {/if} -
-{/if} diff --git a/apps/uload/apps/web/src/lib/components/StatsBar.svelte b/apps/uload/apps/web/src/lib/components/StatsBar.svelte deleted file mode 100644 index 03e5ffaa8..000000000 --- a/apps/uload/apps/web/src/lib/components/StatsBar.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -
- -
-
-
- {#each statItems as stat} -
- - - - - - -
- - {formatNumber(displayStats[stat.key] || 0)} - - - {stat.label} - -
-
- {/each} -
-
-
-
- - diff --git a/apps/uload/apps/web/src/lib/components/TagBadge.svelte b/apps/uload/apps/web/src/lib/components/TagBadge.svelte deleted file mode 100644 index 17f3c44d3..000000000 --- a/apps/uload/apps/web/src/lib/components/TagBadge.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -{#if tag && tag.name} - - {#if tag.icon && tag.icon.trim()} - {tag.icon} - {/if} - {tag.name} - {#if removable} - - {/if} - -{/if} diff --git a/apps/uload/apps/web/src/lib/components/TagCard.svelte b/apps/uload/apps/web/src/lib/components/TagCard.svelte deleted file mode 100644 index 63857b0f2..000000000 --- a/apps/uload/apps/web/src/lib/components/TagCard.svelte +++ /dev/null @@ -1,175 +0,0 @@ - - -
- {#if editingTag} -
{ - return async ({ update }) => { - await update(); - cancelEdit(); - }; - }} - > - -
- - - -
- - -
-
-
- {:else} -
-
-
- -
-
-
- - {tag.linkCount || 0} links - -
- -
- - {tag.totalClicks || 0} clicks - -
- -
- - {tag.usage_count || 0} uses - -
- {#if tag.is_public} - - Public - {:else} - - Private - {/if} -
-
- ', - color: '#9333ea', - action: startEdit, - }, - { - label: 'View Links', - icon: '', - color: '#2563eb', - href: `/my/links?tag=${tag.name}`, - }, - { - label: tag.is_public ? 'Make Private' : 'Make Public', - icon: tag.is_public - ? '' - : '', - color: '#ea580c', - type: 'form', - formAction: '?/togglePublic', - formData: { id: tag.id, is_public: String(!tag.is_public) }, - }, - { - divider: true, - }, - { - label: 'Delete', - icon: '', - 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" - /> -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/TagList.svelte b/apps/uload/apps/web/src/lib/components/TagList.svelte deleted file mode 100644 index 74e69ac47..000000000 --- a/apps/uload/apps/web/src/lib/components/TagList.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -{#if tags && tags.length > 0} - {#if viewMode === 'stats'} - - {:else if viewMode === 'cards'} -
- {#each tags as tag} -
- {#if isSelectMode} -
- onToggleSelect(tag.id)} - class="h-5 w-5 cursor-pointer rounded border-theme-border bg-white text-theme-primary focus:ring-theme-primary" - /> -
- {/if} - -
- {/each} -
- {:else} -
-
-

- Your Tags ({tags.length} total) -

-
- - - - - -
- {#each tags as tag} - onToggleSelect(tag.id)} - /> - {/each} -
-
- {/if} -{:else} -
-

No tags yet. Create your first tag to organize your links!

-
-{/if} diff --git a/apps/uload/apps/web/src/lib/components/TagListItem.svelte b/apps/uload/apps/web/src/lib/components/TagListItem.svelte deleted file mode 100644 index 81ed12ed9..000000000 --- a/apps/uload/apps/web/src/lib/components/TagListItem.svelte +++ /dev/null @@ -1,413 +0,0 @@ - - - - - - - - - -
- {#if editingTag} -
{ - return async ({ update }) => { - await update(); - cancelEdit(); - }; - }} - > - - - - -
- - -
-
- {:else} -
- {#if isSelectMode} -
- -
- {/if} -
- - {#if tag.is_public} - Public - {:else} - Private - {/if} -
- -
-
- {tag.linkCount || 0} links - - - {tag.totalClicks || 0} clicks - - {tag.usage_count || 0} uses -
-
- - {#if !isSelectMode} -
- -
{ - return async ({ update }) => { - if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) { - await update(); - } - }; - }} - > - - -
-
- {/if} -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/TagSelector.svelte b/apps/uload/apps/web/src/lib/components/TagSelector.svelte deleted file mode 100644 index 72ff2baaf..000000000 --- a/apps/uload/apps/web/src/lib/components/TagSelector.svelte +++ /dev/null @@ -1,207 +0,0 @@ - - -
- {#if selectedTags.length > 0} -
- {#each selectedTags as tag} - removeTag(tag)} /> - {/each} -
- {/if} - -
- - - {#if isDropdownOpen && (filteredTags.length > 0 || canCreateNewTag || isCreatingTag)} -
- {#if isCreatingTag} -
-
- { - if (e.key === 'Enter') { - e.preventDefault(); - createNewTag(); - } else if (e.key === 'Escape') { - isCreatingTag = false; - newTagName = ''; - } - }} - /> - - -
-
- {/if} - - {#each filteredTags as tag} - - {/each} - - {#if canCreateNewTag && !isCreatingTag} - - {/if} -
- {/if} -
-
diff --git a/apps/uload/apps/web/src/lib/components/TagStats.svelte b/apps/uload/apps/web/src/lib/components/TagStats.svelte deleted file mode 100644 index 84c793a2a..000000000 --- a/apps/uload/apps/web/src/lib/components/TagStats.svelte +++ /dev/null @@ -1,290 +0,0 @@ - - -
- -
-
-
-
-

Gesamt Tags

-

{totalTags}

-
-
- -
-
-
- -
-
-
-

Gesamt Klicks

-

{formatNumber(totalClicks)}

-
-
- -
-
-
- -
-
-
-

Ø Links/Tag

-

{averageLinksPerTag}

-
-
- -
-
-
- -
-
-
-

Top Tag

- {#if mostClickedTag} -

{mostClickedTag.name}

-

- {formatNumber(mostClickedTag.totalClicks || 0)} Klicks -

- {:else} -

-

- {/if} -
-
- -
-
-
-
- - -
- -
-
-

Top 10 Tags nach Klicks

- -
-
- {#each topTagsByClicks as tag, index} -
-
- {index + 1} -
-
-
- - {tag.name} - - - {formatNumber(tag.totalClicks || 0)} - -
-
-
-
-
-
- {/each} - {#if topTagsByClicks.length === 0} -

Keine Daten verfügbar

- {/if} -
-
- - -
-
-

Top 10 Tags nach Links

- -
-
- {#each topTagsByLinks as tag, index} -
-
- {index + 1} -
-
-
- - {tag.name} - - - {tag.linkCount || 0} Links - -
-
-
-
-
-
- {/each} - {#if topTagsByLinks.length === 0} -

Keine Daten verfügbar

- {/if} -
-
-
- - -
-
-

Detaillierte Tag-Statistiken

-
-
- - - - - - - - - - - - - - {#each tags as tag} - - - - - - - - - - {/each} - -
- Tag - - Links - - Klicks - - CTR - - Verwendungen - - Status - - Erstellt -
-
-
- {tag.name} -
-
- {tag.linkCount || 0} - - {formatNumber(tag.totalClicks || 0)} - - - {calculateCTR(tag)} - - - {tag.usage_count || 0} - - {#if tag.is_public} - - Öffentlich - - {:else} - - Privat - - {/if} - - {new Date(tag.created).toLocaleDateString('de-DE')} -
- {#if tags.length === 0} -
-

Keine Tags vorhanden

-
- {/if} -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte b/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte deleted file mode 100644 index 0104c1721..000000000 --- a/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -
- - - {#if showDropdown} -
- -
-
- Dark Mode - -
-
- - -
-

Choose Theme

-
- {#each Object.values(themes) as theme} - - {/each} -
-
-
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte b/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte deleted file mode 100644 index 8baa1eec8..000000000 --- a/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - -{#if error} -
{error}
-{/if} diff --git a/apps/uload/apps/web/src/lib/components/ViewToggle.svelte b/apps/uload/apps/web/src/lib/components/ViewToggle.svelte deleted file mode 100644 index 96155c4f0..000000000 --- a/apps/uload/apps/web/src/lib/components/ViewToggle.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
- - - {#if showStats} - - {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte b/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte deleted file mode 100644 index fbc46b717..000000000 --- a/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - -
- - - {#if showDropdown} -
- - {#if workspacesState.personalWorkspace} -
-
- Personal Workspace -
- -
- {/if} - - - {#if workspacesState.teamWorkspaces && workspacesState.teamWorkspaces.length > 0} -
-
- Team Workspaces -
- {#each workspacesState.teamWorkspaces as workspace} - - {/each} -
- {:else} - -
-

No team workspaces yet

-

- Create or join a team workspace to collaborate -

-
- {/if} - - -
- -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte b/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte deleted file mode 100644 index f98608148..000000000 --- a/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte +++ /dev/null @@ -1,146 +0,0 @@ - - -
(isHovered = true)} - onmouseleave={() => (isHovered = false)} -> - {#if post.image && viewMode === 'cards'} -
- {post.title} - {#if featured} -
- - Featured - -
- {/if} -
- {/if} - - {#if post.image && viewMode === 'list'} -
- {post.title} - {#if featured} -
- - Featured - -
- {/if} -
- {/if} - -
- {#if featured && !post.image} - - Featured - - {/if} - -

- - {post.title} - -

- -

- {post.excerpt} -

- -
- - - - - - {readingTimeText} - -
- - {#if post.tags.length > 0} -
- {#each post.tags.slice(0, 3) as tag} - - #{tag} - - {/each} - {#if post.tags.length > 3} - - +{post.tags.length - 3} - - {/if} -
- {/if} -
-
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte b/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte deleted file mode 100644 index 9a84752a7..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -
- {@render children()} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte deleted file mode 100644 index 3dec0f4dc..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte +++ /dev/null @@ -1,541 +0,0 @@ - - -
-
- -
-

{card.id === 'new' ? 'Create Card' : 'Edit Card'}

- -
- - -
- - - -
- - -
- {#if activeTab === 'config'} - -
- - -
- - - {#if isBeginnerCard(editingCard.config)} -
-

Modules

- - - {#if editingCard.config.modules.length > 0} -
- {#each editingCard.config.modules as module (module.id)} - { - 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} -
- {:else} -

No modules yet. Add one below.

- {/if} - - -
- - - - - - -
-
- {:else if isAdvancedCard(editingCard.config)} - - {:else if isExpertCard(editingCard.config)} - - {/if} - {:else if activeTab === 'metadata'} - - {:else if activeTab === 'preview'} -
-
- -

Preview coming soon...

-
-
- {/if} -
- - - {#if validationErrors.length > 0} -
-

Validation Errors:

-
    - {#each validationErrors as error} -
  • {error}
  • - {/each} -
-
- {/if} - - - -
-
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte b/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte deleted file mode 100644 index f35b5132f..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte +++ /dev/null @@ -1,285 +0,0 @@ - - -
- {#if editable} -
- - -
- {/if} - - {#if isBeginnerCard(card.config)} - - {:else if isAdvancedCard(card.config)} - - {:else if isExpertCard(card.config)} - - {:else} -
- - - -

Unknown card mode: {card.config.mode}

-
- {/if} - - {#if (showMetadata || !editable) && getMetadata()?.name} - - {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte b/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte deleted file mode 100644 index 24489d202..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - -
- {#if error} -
- - - -

{error}

-
- {:else if isLoading} -
-
-

Loading...

-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte b/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte deleted file mode 100644 index 56fc14149..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte +++ /dev/null @@ -1,236 +0,0 @@ - - -
- {#each sortedModules() as module (module.id)} - {#if isModuleVisible(module) && moduleComponents[module.type]} -
- handleModuleEvent(module.id, event, data)} - /> -
- {/if} - {/each} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte b/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte deleted file mode 100644 index 23bdffd8e..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte +++ /dev/null @@ -1,149 +0,0 @@ - - -
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' - : ''}" -> - -
- {index + 1} -
- - -
- -
-

- {card.metadata?.name || `Card ${index + 1}`} -

-

- Aspect: {card.constraints?.aspectRatio || 'auto'} -

-
- - -
- -
- - -
- - {#if card.visibility !== 'public' && card.page === 'profile'} - ⚠️ Set to public to display - {/if} -
- - -
- - -
-
- - -
- - - -
-
diff --git a/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte b/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte deleted file mode 100644 index f443854ee..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte +++ /dev/null @@ -1,234 +0,0 @@ - - -
- {#if cardData?.type === 'modular'} - -
- - {#if headerModule} -
- - {#if headerModule.props?.avatar} -
- Avatar { - e.currentTarget.style.display = 'none'; - if (e.currentTarget.nextElementSibling) { - e.currentTarget.nextElementSibling.style.display = 'flex'; - } - }} - /> - -
- {:else if headerModule.props?.title} -
- {headerModule.props.title[0].toUpperCase()} -
- {/if} - - - {#if headerModule.props?.title} -

{headerModule.props.title}

- {/if} - {#if headerModule.props?.subtitle} -

{headerModule.props.subtitle}

- {/if} -
- {/if} - - - {#if linksModule?.props?.links && linksModule.props.links.length > 0} -
- {#each linksModule.props.links as link} -
- {#if link.icon} - {link.icon} - {/if} -
-
- {link.title || link.original_url} -
- {#if link.description} -
{link.description}
- {/if} -
-
- {/each} -
- {/if} -
- {:else if cardData?.type === 'template'} - -
-

Template Card

-

Template: {cardData.template}

-
- {:else if cardData?.type === 'custom'} - -
-

Custom Card

-

Custom HTML/CSS Card

-
- {:else} - -
-
- - - -
-

- {card.title || card.metadata?.name || 'Unnamed Card'} -

- {#if card.subtitle} -

{card.subtitle}

- {/if} -
- {/if} - - - {#if showMetadata} -
-
- {card.metadata?.name || 'Unnamed Card'} - {card.config?.mode || 'unknown'} mode -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte b/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte deleted file mode 100644 index ddfb059a5..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - -
- {#if template} - - {:else} -
- - - -

No template provided

-
- {/if} -
- -{#if variables.length > 0 && import.meta.env.DEV} - -
- Template Variables ({variables.length}) -
- {#each variables as variable} -
- {variable.name} - {variable.type} - - {values?.[variable.name] || 'undefined'} - -
- {/each} -
-
-{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte deleted file mode 100644 index 065902101..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -
-
-

HTML

- -
- -
-

CSS

- -
- -
-

- 💡 Tip: Your HTML and CSS will be sanitized for security. Scripts and dangerous - patterns will be removed. -

-

📏 Limits: HTML max 100KB, CSS max 50KB

-
-
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte deleted file mode 100644 index 6468b0ffb..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte +++ /dev/null @@ -1,363 +0,0 @@ - - -
-
- - -
- onUpdate({ ...module, order: parseInt(e.currentTarget.value) })} - class="order-input" - min="0" - title="Order" - /> - -
-
- - {#if expanded} -
- {#if module.type === 'header'} -
- - updateProp('title', e.currentTarget.value)} - placeholder="Enter title" - /> -
-
- - updateProp('subtitle', e.currentTarget.value)} - placeholder="Enter subtitle" - /> -
- {:else if module.type === 'content'} -
- - -
- {:else if module.type === 'links'} -
- - -
-
- - -
- {:else if module.type === 'media'} -
- - -
- {#if module.props.type === 'image'} -
- - updateProp('src', e.currentTarget.value)} - placeholder="https://example.com/image.jpg" - /> -
-
- - updateProp('alt', e.currentTarget.value)} - placeholder="Image description" - /> -
- {:else if module.props.type === 'qr'} -
- - updateProp('qrData', e.currentTarget.value)} - placeholder="https://example.com" - /> -
- {/if} - {:else if module.type === 'stats'} -
- - -
- {:else if module.type === 'footer'} -
- - updateProp('text', e.currentTarget.value)} - placeholder="Footer text" - /> -
-
- - updateProp('copyright', e.currentTarget.value)} - placeholder="© 2024" - /> -
- {:else} -
- - -
- {/if} - -
- - -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte deleted file mode 100644 index 986a51369..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte +++ /dev/null @@ -1,202 +0,0 @@ - - -
-
-

HTML Template

-

Use {'{{variable}}'} syntax for dynamic content

- -
- -
-

CSS Styles

- -
- - {#if variables.length > 0} -
-

Template Variables

-
- {#each variables as variable} -
- - - {#if variable.type === 'text'} - (values[variable.name] = e.currentTarget.value)} - placeholder={variable.placeholder || `Enter ${variable.name}`} - /> - {:else if variable.type === 'number'} - (values[variable.name] = parseFloat(e.currentTarget.value))} - placeholder={variable.placeholder || '0'} - /> - {:else if variable.type === 'color'} - (values[variable.name] = e.currentTarget.value)} - /> - {:else if variable.type === 'boolean'} - - {:else if variable.type === 'link'} - (values[variable.name] = e.currentTarget.value)} - placeholder={variable.placeholder || 'https://example.com'} - /> - {:else if variable.type === 'image'} - (values[variable.name] = e.currentTarget.value)} - placeholder={variable.placeholder || 'https://example.com/image.jpg'} - /> - {:else} - (values[variable.name] = e.currentTarget.value)} - placeholder={variable.placeholder || `Enter ${variable.name}`} - /> - {/if} -
- {/each} -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte deleted file mode 100644 index ce5271376..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
- {#each actions as action} - - {/each} -
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte deleted file mode 100644 index 67ac6c666..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- {#if html} - {@html html} - {:else if text} -

{text}

- {:else if items.length > 0} -
    - {#each items as item} -
  • -
    - {#if item.icon} - {item.icon} - {/if} - {item.label} -
    - {item.value} -
  • - {/each} -
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte deleted file mode 100644 index d5d3d5cb8..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - - diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte deleted file mode 100644 index 35c69ab94..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
-
- {#if avatar} - {avatarAlt - {:else if icon} -
- {icon} -
- {/if} - -
- {#if title} -

- {title} - {#if badge} - - {badge} - - {/if} -

- {/if} - - {#if subtitle} -

{subtitle}

- {/if} -
-
- - {#if actions.length > 0} -
- {#each actions as action} - - {/each} -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte deleted file mode 100644 index f8537faa3..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - - - diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte deleted file mode 100644 index 0064d2861..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
- {#if type === 'image' && src} - - {:else if type === 'video' && src} - - {:else if type === 'qr' && qrData} -
- QR Code -
- {:else if type === 'icon' && icon} -
- {icon} -
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte deleted file mode 100644 index 13e74699b..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -
- {#each stats as stat} -
- {#if stat.icon} - - {stat.icon} - - {/if} - -
-
- {stat.value} - {#if stat.change} - - {stat.change > 0 ? '↑' : '↓'} - {Math.abs(stat.change)}% - - {/if} -
-
{stat.label}
-
-
- {/each} -
- - diff --git a/apps/uload/apps/web/src/lib/components/cards/types.ts b/apps/uload/apps/web/src/lib/components/cards/types.ts deleted file mode 100644 index 3f34cd6a2..000000000 --- a/apps/uload/apps/web/src/lib/components/cards/types.ts +++ /dev/null @@ -1,275 +0,0 @@ -// ============================================ -// SIMPLIFIED CARD SYSTEM V2 - Using Discriminated Unions -// ============================================ - -// Base Types -export type RenderMode = 'beginner' | 'advanced' | 'expert'; - -// Card Metadata -export interface CardMetadata { - name?: string; - description?: string; - author?: string; - version?: string; - created?: string; - updated?: string; - tags?: string[]; - isActive?: boolean; - isPublic?: boolean; -} - -// Card Constraints -export interface CardConstraints { - aspectRatio?: string; - maxWidth?: string; - minHeight?: string; - maxHeight?: string; - maxModules?: number; - maxHTMLSize?: number; - maxCSSSize?: number; - preventScripts?: boolean; -} - -// Theme Configuration -export interface Theme { - id?: string; - name?: string; - colors?: Record; - typography?: { - fontFamily?: string; - fontSize?: Record; - fontWeight?: Record; - lineHeight?: Record; - }; - spacing?: Record; - borderRadius?: Record; - shadows?: Record; -} - -// Module Definition -export interface Module { - id: string; - type: 'header' | 'content' | 'footer' | 'media' | 'stats' | 'actions' | 'links' | 'custom'; - props: Record; - 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; - } - | { - 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 { - return config.mode === 'beginner'; -} - -export function isAdvancedCard( - config: CardConfig -): config is Extract { - return config.mode === 'advanced'; -} - -export function isExpertCard( - config: CardConfig -): config is Extract { - return config.mode === 'expert'; -} - -// Conversion Types -export interface CardConverter { - toModular(config: CardConfig): Promise>; - toTemplate(config: CardConfig): Promise>; - toCustom(config: CardConfig): Promise>; -} - -// 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; - update(id: string, updates: Partial): Promise; - delete(id: string): Promise; - convert(id: string, targetMode: RenderMode): Promise; - duplicate(id: string): Promise; - 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; -export type TemplateConfig = Extract; -export type CustomHTMLConfig = Extract; -export type { Module as ModuleConfig }; diff --git a/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte b/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte deleted file mode 100644 index f38c98325..000000000 --- a/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte +++ /dev/null @@ -1,328 +0,0 @@ - - -{#if showBanner} -
-
- {#if !showDetails} - -
- -
-
- -
- - - -
- - -
-

- Cookies & Datenschutz -

-

- 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. -

- - -
- - Datenschutzerklärung - - - Impressum - - -
-
-
-
- - -
- - - -
-
- {:else} - -
- -
-

- Cookie-Einstellungen -

- -
- - -
- -
-
-
-

Notwendige Cookies

-

- Technisch erforderlich für die Grundfunktionen der Website -

-
-
- Immer aktiv -
-
-
-
-
-

- Speichern von Login-Status, Spracheinstellungen und technischen Präferenzen -

-
- - -
-
-
-

Analytics Cookies

-

- Helfen uns die Website zu verbessern -

-
- -
-

- Anonyme Nutzungsstatistiken, Seitenaufrufe und Klick-Verhalten -

-
- - -
-
-
-

Marketing Cookies

-

- Für personalisierte Inhalte und Werbung -

-
- -
-

- Newsletter-Präferenzen und zielgerichtete Kommunikation -

-
- - -
-
-
-

Präferenz Cookies

-

- Speichern Ihre persönlichen Einstellungen -

-
- -
-

- Theme-Einstellungen, Layout-Präferenzen und Benutzeroberfläche -

-
-
- - -
- - - -
-
- {/if} -
-
-{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte b/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte deleted file mode 100644 index 9f89390ab..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -
-
-
-

Insights & Wissen

-

- Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für - erfolgreiches Link-Management. -

-
- - {#if formattedPosts.length > 0} -
- {#each formattedPosts as post} - - {/each} -
- {:else} -
-

- Bald verfügbar: Spannende Artikel über URL-Optimierung und digitales Marketing. -

-
- {/if} - - -
-
- - diff --git a/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte b/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte deleted file mode 100644 index f1ee97781..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte +++ /dev/null @@ -1,553 +0,0 @@ - - -
-
-
-

- Alle Features die du brauchst -

-

- Von Link-Verkürzung bis Team-Kollaboration - alles in einer Plattform vereint -

-
- -
- -
- - - - - - - - - - - -
- - -
-
- {#if selectedFeature === 'links'} -
-

Smart Link Features

-
-
-
- Custom Short Codes -
-
-
- Ablaufdatum festlegen -
-
-
- Click-Limits definieren -
-
-
- Passwortschutz aktivieren -
-
-
- Tags zur Organisation -
-
-
- Bulk-Operationen -
-
-
-

- Beispiel: ulo.ad/produkt-launch → 500 Clicks, läuft in 7 Tagen ab -

-
-
- {/if} - - {#if selectedFeature === 'cards'} -
-

3-Stufen Builder

-
-
-

👶 Anfänger

-

- Einfache Vorlagen, schnell anpassbar -

-
-
-

💪 Fortgeschritten

-

- Drag & Drop Module, mehr Kontrolle -

-
-
-

🚀 Experte

-

- Volle Freiheit, eigener Code möglich -

-
-
-
- {/if} - - {#if selectedFeature === 'analytics'} -
-

Analytics Dashboard

-
- -
-
-
-
-
-
-
-
-
-
-
-

Total Clicks

-

24.5k

-
-
-

CTR

-

3.2%

-
-
-
-

Top Referrer

-
- Instagram - 45% -
-
- Twitter - 28% -
-
- Direct - 27% -
-
-
-
- {/if} - - {#if selectedFeature === 'qr'} -
-

QR-Code Optionen

-
-
-
-
-
-
-

Schwarz

-
-
-
-
-
-

Weiß

-
-
-
-
-
-

Gold

-
-
-
-
-

Formate:

-
- PNG - SVG - JPG -
-
-
- {/if} - - {#if selectedFeature === 'team'} -
-

Team Workspace

-
-
-
-
-
-

Max Mustermann

-

Admin

-
-
- Full Access -
-
-
-
-
-

Anna Schmidt

-

Editor

-
-
- Edit Links -
-
-
-
-
-

Tom Weber

-

Viewer

-
-
- View Only -
-
-
- {/if} - - {#if selectedFeature === 'templates'} -
-

Template Gallery

-
-
-

Creator Pro

-
-
-
-

Business

-
-
-
-

Restaurant

-
-
-
-

Portfolio

-
-
-
- -
- {/if} -
-
-
-
-
- - diff --git a/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte b/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte deleted file mode 100644 index 9e9efe077..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte +++ /dev/null @@ -1,228 +0,0 @@ - - -
- -
-
-
-
- -
-
- -
- - - - - DSGVO-konform - - - - - - Blitzschnell - - - - - - 100% Sicher - -
- - -

- More than links. - - Your digital identity. - -

- -

- Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links, - beeindruckende Profilkarten und manage alles im Team. -

- - - - - -
-
{ - isSubmitting = true; - return async ({ update }) => { - await update(); - isSubmitting = false; - }; - }} - class="bg-theme-surface/80 flex flex-col gap-3 rounded-xl border border-theme-border p-4 backdrop-blur sm:flex-row sm:p-2" - > - - -
-

- Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive -

-
-
- - -
- -
-
- - - -
-

Smart Links

-

- Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz -

-
Mehr erfahren →
-
- - -
-
- - - -
-

Profile Cards

-

- Beeindruckende Profilseiten mit Drag & Drop Builder -

-
Templates ansehen →
-
- - -
-
- - - -
-

Team Workspace

-

- Gemeinsam Links verwalten mit granularen Berechtigungen -

-
Für Teams →
-
-
-
-
diff --git a/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte b/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte deleted file mode 100644 index d92e25ed6..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte +++ /dev/null @@ -1,270 +0,0 @@ - - -
-
-
-

- Transparente Preise, keine versteckten Kosten -

-

- Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar. -

- - -
- - -
-
- - -
- {#each plans as plan} -
(hoveredPlan = plan.id)} - onmouseleave={() => (hoveredPlan = null)} - > - {#if plan.badge} -
- - {plan.badge} - -
- {/if} - -
-

{plan.name}

-

{plan.description}

- -
-
- - {formatPrice( - billingCycle === 'monthly' ? plan.price.monthly : plan.price.yearly / 12 - )} - - /Monat -
- {#if billingCycle === 'yearly' && plan.price.yearly > 0} -

- Spare {getYearlySavings(plan.price.monthly, plan.price.yearly)}% jährlich -

- {/if} -
- - - -
-

- Inklusive: -

- {#each plan.features as feature} -
- - - - {feature} -
- {/each} - - {#if plan.limitations.length > 0} -
- {#each plan.limitations as limitation} -
- - - - {limitation} -
- {/each} -
- {/if} -
-
-
- {/each} -
- - -
-
-
-

💳 Keine Kreditkarte erforderlich

-

- Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst. -

-
-
-

🔄 Jederzeit kündbar

-

- Keine Vertragsbindung. Kündige monatlich ohne Probleme. -

-
-
-

🚀 Sofort startklar

-

- Nach der Anmeldung kannst du sofort alle Features nutzen. -

-
-
-
- - -
-

- Benötigst du eine maßgeschneiderte Lösung für dein Unternehmen? -

- - Kontaktiere uns für Enterprise-Lösungen - - - - -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte b/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte deleted file mode 100644 index a10130bd0..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte +++ /dev/null @@ -1,487 +0,0 @@ - - -
-
-
-

- Für jeden die richtige Lösung -

-

- Egal ob Creator, Team oder Unternehmen - wir haben die passenden Features für dich -

-
- - -
- - - - -
- - -
- {#if activeTab === 'creators'} -
-
-

Ein Link für alle deine Kanäle

-

- Perfekt für Instagram, TikTok und YouTube. Erstelle beeindruckende Link-in-Bio Seiten, - tracke deine Klicks und verstehe deine Audience besser. -

-
    -
  • - - - - Anpassbare Profilseiten mit deinem Branding -
  • -
  • - - - - QR-Codes für Offline-zu-Online Verbindung -
  • -
  • - - - - Detaillierte Analytics zu Klicks und Herkunft -
  • -
  • - - - - Social Media Icons und Integrationen -
  • -
- -
-
-
-
- Creator Profile Preview {}} - /> - -
-
-
📱
-

Creator Profile

-

Coming Soon

-
-
-
-
-
- {/if} - - {#if activeTab === 'teams'} -
-
-

Gemeinsam mehr erreichen

-

- Perfekte Kollaboration für Marketing-Teams und Agenturen. Verwaltet Links gemeinsam, - teilt Analytics und arbeitet effizienter zusammen. -

-
    -
  • - - - - Team-Workspaces mit granularen Berechtigungen -
  • -
  • - - - - Multi-Client Management für Agenturen -
  • -
  • - - - - Gemeinsame Analytics und Reporting -
  • -
  • - - - - Bulk-Operationen und CSV-Import -
  • -
- -
-
-
-
-
💼
-

Team Dashboard

-

10 Mitglieder • Unbegrenzte Links

-
-
-
-
- {/if} - - {#if activeTab === 'business'} -
-
-

Professionelles Link-Management

-

- Die kostengünstige Alternative zu Enterprise-Lösungen. Perfekt für KMUs und Startups, - die ihre digitale Präsenz professionell verwalten wollen. -

-
    -
  • - - - - Custom Domains für deine Marke (coming soon) -
  • -
  • - - - - API-Zugang für Automatisierung -
  • -
  • - - - - Erweiterte Analytics und Exporte -
  • -
  • - - - - DSGVO-konform und hosted in Germany -
  • -
- -
-
-
-
-
🏢
-

Enterprise Ready

-

API • Custom Domain • SSO

-
-
-
-
- {/if} - - {#if activeTab === 'events'} -
-
-

QR-Codes die funktionieren

-

- Ideal für Restaurants, Events und Veranstaltungen. Erstelle QR-Codes für Speisekarten, - Event-Infos oder zeitlich begrenzte Aktionen. -

-
    -
  • - - - - QR-Codes in verschiedenen Farben und Formaten -
  • -
  • - - - - Zeitlich begrenzte Links für Aktionen -
  • -
  • - - - - Passwortgeschützte Inhalte für VIPs -
  • -
  • - - - - Echtzeit-Updates ohne QR-Code Neudruck -
  • -
- -
-
-
-
-
🎯
-

Event QR-Codes

-

Dynamisch • Trackbar • Aktualisierbar

-
-
-
-
- {/if} -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte b/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte deleted file mode 100644 index 8fc58339f..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte +++ /dev/null @@ -1,221 +0,0 @@ - - -
-
-
-

Was Beta-Tester sagen

-

- Erste Stimmen aus unserem exklusiven Beta-Programm -

-
- - -
- {#each stats as stat} -
-
{stat.icon}
-
{stat.value}
-
{stat.label}
-
- {/each} -
- - -
- {#each testimonials as testimonial} -
-
-
-
- {testimonial.avatar} -
-
-

{testimonial.name}

-

{testimonial.role}

-
-
- - {testimonial.platform} - -
- -
- {#each Array(testimonial.rating) as _} - - - - {/each} -
- -

- "{testimonial.content}" -

-
- {/each} -
- - -
-

- Perfekt für diese Use Cases -

-
-
-
-
- 📱 -
-
-

Social Media Bio Links

-

Instagram & TikTok

-
-
-

- Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem Drag & - Drop Builder und tracke jeden Klick in Echtzeit. -

-
- -
-
-
- 🍽️ -
-
-

Digitale Speisekarten

-

Restaurants & Cafés

-
-
-

- QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte ohne neue - Codes drucken zu müssen. -

-
- -
-
-
- 📊 -
-
-

Marketing Kampagnen

-

Performance Tracking

-
-
-

- Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau, welche - Kanäle am besten performen. -

-
- -
-
-
- 🎯 -
-
-

Event Management

-

Tickets & Info-Links

-
-
-

- Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für exklusive - Inhalte und VIP-Bereiche. -

-
-
-
- - -
-

Sei einer der Ersten - starte jetzt kostenlos!

- - Beta-Zugang sichern - - - - -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte b/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte deleted file mode 100644 index 3a5557354..000000000 --- a/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte +++ /dev/null @@ -1,270 +0,0 @@ - - -
-
- -
- {#each trustBadges as badge} -
-
{badge.icon}
-

{badge.title}

-

{badge.description}

-
- {/each} -
- - -
-
-

- Sicherheit und Datenschutz an erster Stelle -

-

- Wir nehmen den Schutz deiner Daten ernst. Deshalb setzen wir auf höchste - Sicherheitsstandards. -

-
- -
- {#each securityFeatures as feature} -
-

- - - - {feature.title} -

-
    - {#each feature.items as item} -
  • - - - - {item} -
  • - {/each} -
-
- {/each} -
-
- - -
-
-
-
- - - -
-
-

Premium Infrastructure

-

Hetzner Cloud Servers

-
-
-

- Unsere Server laufen auf modernster Hetzner-Infrastruktur in deutschen Rechenzentren. Mit - automatischer Skalierung und Load Balancing gewährleisten wir beste Performance. -

-
- - Frankfurt - - - Nürnberg - - - Falkenstein - -
-
- -
-
-
- - - -
-
-

Status & Monitoring

-

Transparente Verfügbarkeit

-
-
-

- Wir überwachen unsere Systeme 24/7 und informieren proaktiv über Wartungen. Unser - öffentliches Status-Dashboard zeigt die aktuelle Verfügbarkeit aller Services. -

- - Status-Seite besuchen - - - - -
-
- - -
-

- Zertifizierungen & Standards -

-
-
-
🔐
- SSL/TLS - Let's Encrypt -
-
-
📋
- DSGVO - EU Compliant -
-
-
🛡️
- ISO 27001 - In Progress -
-
-
- PCI DSS - Level 1 -
-
-
- - -
-

Fragen zur Sicherheit?

-

- Unser Security-Team beantwortet gerne alle deine Fragen zum Datenschutz und zur Sicherheit. -

- - - - - security@ulo.ad - -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte deleted file mode 100644 index 2fb2dd087..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte +++ /dev/null @@ -1,533 +0,0 @@ - - -
-
- -
-
-

- {link.title || link.short_code} -

- {#if link.description} -

{link.description}

- {/if} -
- - -
- ', - color: '#6366f1', - action: () => { - copyToClipboard(formatUrl(link.short_code), link.id, link.short_code); - }, - }, - { - label: 'QR Code', - icon: '', - color: '#10b981', - action: toggleQRCode, - }, - { - label: 'Analytics', - href: `/my/analytics/${link.short_code}`, - icon: '', - color: '#2563eb', - }, - { - label: 'Edit', - icon: '', - 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 - ? '' - : '', - color: link.is_active ? '#ea580c' : '#16a34a', - }, - { - divider: true, - }, - { - label: 'Delete', - icon: '', - 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" - /> -
-
- - -
-
- - -
-
- - -
- Destination: - - {link.original_url} - -
- - -
- {#if link.expand?.folder} - - {link.expand.folder.icon} - {link.expand.folder.display_name} - - {/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} - - {/if} - {/each} - {/if} - {#if !link.is_active} - - - Inactive - - {/if} - {#if isExpired} - - - - - Expired - - {/if} - {#if link.password} - - - - - Protected - - {/if} -
- - -
-
- - - - - - {link.clicks || 0} - - clicks -
- - {#if link.max_clicks} -
- - - - - {link.max_clicks} - - max -
- {/if} - - {#if link.expires_at} -
- - - - - {new Date(link.expires_at).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: '2-digit', - })} - -
- {/if} - -
- - - - - {new Date(link.created).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - })} - -
-
-
- - {#if showQRCode} -
-
-
- QR Code for {link.short_code} -
- -
-
- -
- - - -
-
- -
- - -
- -
- -
- {#each [0, 45, 90, 135, 180, 225, 270, 315] as angle} - - {/each} -
-
-
- -
- - -
-
-
- {/if} -
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte deleted file mode 100644 index 09fa9a323..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte +++ /dev/null @@ -1,234 +0,0 @@ - - -
-
-
-
-
- {#if link.title} -

{link.title}

- {:else} -

Untitled Link

- {/if} - {#if !link.is_active} - Inactive - {/if} -
- - {#if link.expand?.folder} - - {link.expand.folder.icon} - {link.expand.folder.display_name} - - {/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} - - {/if} - {/each} -
- {/if} -
- -
- - - {#if dropdownOpen} -
- - 📊 Analytics - - -
- - - -
- -
-
{ - 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(); - } - } - }; - }} - > - - -
-
-
- {/if} -
-
- - - -
-
- Clicks: {link.clicks || 0} - {#if link.expires_at} - - Expires: {new Date(link.expires_at).toLocaleDateString()} - - {/if} - {#if link.max_clicks} - Max: {link.max_clicks} - {/if} - {#if link.password} - 🔒 - {/if} -
- - -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte deleted file mode 100644 index f83490f92..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte +++ /dev/null @@ -1,340 +0,0 @@ - - -
-
- -
-
-
- + -
-

- {showBulkCreate - ? 'Mehrere Links erstellen' - : editingLink - ? 'Link bearbeiten' - : 'Neuen Link erstellen'} -

-
- -
- - - - - -
-
- - - {#if showBulkCreate} - -
-
- - -

- - - - {bulkUrls.split('\n').filter((line) => line.trim()).length} URLs erkannt -

-
- - {#if user} -
- - -
- - {#if folders.length > 0} -
- - -
- {/if} - - -
-
URL-Format
- -
-
-
- {/if} - - - - - - {#if createdLinks.length > 0} -
-

- ✅ {createdLinks.length} Links erfolgreich erstellt: -

-
- {#each createdLinks as link, i} -
- - {link.url} - - -
- {/each} -
-
- {/if} -
- {:else} - - - {/if} -
-
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte deleted file mode 100644 index 37c3a3de2..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte +++ /dev/null @@ -1,1002 +0,0 @@ - - -
console.log('📤 Form onsubmit event fired!')} -> - {#if editingLink} - - {/if} - - - {#if generatedCode} - - {/if} -
- -
-
- -
-
- - - -
- handleKeydown(e, 1)} - class="w-full rounded-lg border-2 pl-10 pr-10 {isValidUrl - ? 'border-green-500 bg-green-50/50 focus:border-green-500 focus:ring-green-500 dark:bg-green-900/20' - : error && formData.url - ? 'border-red-500 bg-red-50/50 focus:border-red-500 focus:ring-red-500 dark:bg-red-900/20' - : 'border-theme-border bg-theme-surface focus:border-theme-accent focus:ring-2 focus:ring-theme-accent'} px-4 py-2 text-theme-text placeholder-theme-text-muted shadow-sm transition-all hover:shadow-md focus:outline-none" - /> - {#if isValidUrl} -
- - - -
- {/if} -
-
- {#if urlPreview && !isValidUrl} -
- - - - Bitte geben Sie eine gültige URL ein (z.B. https://beispiel.de) -
- {/if} -
- - - {#if showShortlinkPreview && isValidUrl} -
- -
-
-
-
- - - -
-
-
-

- ✨ Ihre kurze URL wird sein: -

-
- - {linkPreview || `${window.location.origin}/[code]`} - - -
-
-
-
- - - {#if workspace?.slug} -
- - - - - Workspace-Link: /w/{workspace.slug}/ - -
- - - {#if mode === 'advanced'} - { - 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="hover:border-theme-border-hover 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 transition-all focus:outline-none focus:ring-2 focus:ring-theme-accent" - /> - {/if} - {/if} - - - {#if user && !workspace?.slug} -
- - - - {#if mode === 'advanced' && useUsername} - { - 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="hover:border-theme-border-hover 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 transition-all focus:outline-none focus:ring-2 focus:ring-theme-accent" - /> - {/if} -
- {/if} -
- {/if} - - - {#if currentStep >= 3} -
- - (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 transition-all focus:outline-none focus:ring-2 focus:ring-theme-accent" - /> -
- {/if} - - - {#if currentStep >= 4 && user} -
- -
- {#if mode === 'advanced' && folders.length > 0} -
- - -
- {/if} -
- - - {#each selectedTags as tag} - - {/each} -
-
-
- {/if} - - - {#if currentStep >= 3 && mode === 'advanced'} -
- -
- {/if} - - {#if showAdvancedOptions && currentStep >= 3} -
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- - {#if mode === 'advanced'} -
-
- - -

- Link ist inaktiv bis zu diesem Zeitpunkt -

-
-
- - -

- Überschreibt "Läuft ab in Tagen" wenn gesetzt -

-
-
- {/if} -
- {/if} - - {#if mode === 'advanced'} - -
- -
- - {#if showSocialMediaOptions} -
-

- Passen Sie an, wie Ihr Link erscheint, wenn er auf Social-Media-Plattformen geteilt wird -

- -
-
- - -
-
- - -
-
- -
- - -
-
- {/if} - {/if} - - - {#if isValidUrl} -
- - {#if onCancel} - - {/if} -
- {/if} -
-
- -{#if error} -
-
- - - - {error} -
-
-{/if} - -{#if createdLink && !error} -
-
- - - -
-

Link erfolgreich erstellt!

-
- - {createdLink.url} - - -
-
-
-
-{/if} diff --git a/apps/uload/apps/web/src/lib/components/links/LinkList.svelte b/apps/uload/apps/web/src/lib/components/links/LinkList.svelte deleted file mode 100644 index 4c28a8775..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkList.svelte +++ /dev/null @@ -1,200 +0,0 @@ - - -{#if links && links.items && links.items.length > 0} - {#if viewMode === 'cards'} -
-
- {#each links.items as link} -
- {#if isSelectMode} -
- onToggleSelect(link.id)} - class="h-5 w-5 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary" - /> -
- {/if} - -
- {/each} -
-
- {:else} -
- - - - - -
- {#each links.items as link} - onToggleSelect(link.id)} - /> - {/each} -
-
- {/if} - - {#if links.totalPages > 1} -
- {#if links.page > 1} - - {/if} - - {#each Array(Math.min(5, links.totalPages)) as _, i} - {@const pageNum = Math.max(1, links.page - 2) + i} - {#if pageNum <= links.totalPages} - - {/if} - {/each} - - {#if links.page < links.totalPages} - - {/if} -
- {/if} -{:else} -
-

- Keine Links gefunden. Versuchen Sie Ihre Filter anzupassen oder erstellen Sie Ihren ersten - Link! -

- - - -
-{/if} diff --git a/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte b/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte deleted file mode 100644 index 5814b3aca..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte +++ /dev/null @@ -1,541 +0,0 @@ - - - - - - - - - -
-
- - {#if isSelectMode} -
- -
- {/if} - - - - -
-
{link.original_url}
-
- - - {#if link.expand?.['link_tags(link_id)']?.length > 0} -
- {#each link.expand['link_tags(link_id)'] as linkTag} - {#if linkTag.expand?.tag_id} - - {/if} - {/each} -
- {/if} - - -
-
- - - {link.clicks || 0} clicks - - - - {new Date(link.created).toLocaleDateString('de-DE')} - -
-
- {#if !link.is_active} - Inactive - {/if} - {#if link.password} - - {/if} -
-
- - -
- - - Analytics - - ', - color: '#16a34a', - action: () => { - window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link })); - }, - }, - { - label: 'Edit', - icon: '', - 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 - ? '' - : '', - color: link.is_active ? '#ea580c' : '#16a34a', - }, - { - divider: true, - }, - { - label: 'Delete', - icon: '', - 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" - /> -
-
-
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte b/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte deleted file mode 100644 index f5f22b157..000000000 --- a/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte +++ /dev/null @@ -1,314 +0,0 @@ - - -
- -
- -
-
-
- -
-
- - {Math.abs(parseFloat(stats().clickTrend))}% -
-
-
-

{formatNumber(stats().totalClicks)}

-

Total Clicks

-
-
- - -
-
-
- -
-
-
-

{stats().activeLinks}

-

Active Links

-
-
- - -
-
-
- -
-
-
-

{stats().avgCtr}%

-

Avg. Engagement

-
-
- - -
-
-
- -
-
-
-

- {stats().activeLinks > 0 ? Math.floor(stats().totalClicks / stats().activeLinks) : 0} -

-

Clicks per Link

-
-
-
- - -
- -
-

- - Click Activity (24h) -

-
- {#each stats().hourlyDistribution as hour} -
-
- {hour.hour}:00 -
-
- {/each} -
-
- 00:00 - 06:00 - 12:00 - 18:00 - 23:00 -
-
- - -
-

- - Device Types -

-
-
-
- Desktop - {stats().deviceBreakdown.desktop} -
-
-
-
-
-
-
- Mobile - {stats().deviceBreakdown.mobile} -
-
-
-
-
-
-
- Tablet - {stats().deviceBreakdown.tablet} -
-
-
-
-
-
-
-
- - -
- -
-

- - Top Performing Links -

-
- {#each stats().topLinks as link, i} -
-
- #{i + 1} -
-

- {link.title || link.short_url} -

-

- {link.short_url} -

-
-
-
- {link.clicks || 0} - clicks - - - -
-
- {:else} -

No links with clicks yet

- {/each} -
-
- - -
-

- - Recently Created -

-
- {#each stats().recentLinks as link} -
-
-

- {link.title || link.short_url} -

-

- {new Date(link.created).toLocaleDateString()} -

-
-
- {link.clicks || 0} - clicks - - - -
-
- {:else} -

No links created yet

- {/each} -
-
-
-
diff --git a/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte b/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte deleted file mode 100644 index 8184e1c83..000000000 --- a/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte +++ /dev/null @@ -1,233 +0,0 @@ - - -{#if showBanner} - -
-
- -
-
- -
- - - -
- - -
-

Install uLoad

-

- Add to home screen for quick access -

-
-
- - - -
- - -
-
- - - - Works offline -
-
- - - - Fast loading -
-
- - - - Native app feel -
-
- - -
- - - -
-
-
-{/if} - - diff --git a/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte b/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte deleted file mode 100644 index 56374b14c..000000000 --- a/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte +++ /dev/null @@ -1,411 +0,0 @@ - - -
- -
-
- - - -
-

Zwei-Faktor-Authentifizierung einrichten

-

Erhöhen Sie die Sicherheit Ihres Kontos mit 2FA

-
- - -
-
- {#each [1, 2, 3] as stepNumber} -
-
- {stepNumber} -
- {#if stepNumber < 3} -
- {/if} -
- {/each} -
-
- - {#if step === 1} - -
-

1. Authenticator-App einrichten

- -
- -
- {#if qrCodeURL} - -
-
- QR Code -
-

- Scannen Sie diesen Code mit Ihrer Authenticator-App -

-
- {:else} -
- {/if} -
- -

- Scannen Sie den QR-Code mit einer Authenticator-App wie Google Authenticator, Authy oder - 1Password -

- - -
- - Manueller Setup-Code - -
-

Falls Sie den QR-Code nicht scannen können:

- - {secret} - - -
-
-
- -
- - -
-
- {:else if step === 2} - -
-

2. Code verifizieren

- -
-

- Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein: -

- - -
- -
- - - {#if import.meta.env.DEV} -
-

Aktueller Code: {currentToken}

-

Läuft ab in: {timeRemaining}s

-
- {/if} - -

Der Code ändert sich alle 30 Sekunden

-
- -
- - -
-
- {:else if step === 3} - -
-

3. Backup-Codes sichern

- -
-
-
- - - -
-

Wichtig: Backup-Codes sichern

-

- Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können verwendet werden, - wenn Sie keinen Zugang zu Ihrer Authenticator-App haben. -

-
-
-
- - -
- {#each backupCodes as code} -
- {code} -
- {/each} -
- - -
- - -
-
- -
- -
-
- {/if} -
- - diff --git a/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte b/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte deleted file mode 100644 index 3ad69e97e..000000000 --- a/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte +++ /dev/null @@ -1,409 +0,0 @@ - - -
- -
- -
-
-
- -
-
-
-

{stats().totalTags}

-

Total Tags

-
-
- - -
-
-
- -
-
-
-

{stats().usedTags}

-

Active Tags

-
-
- - -
-
-
- -
-
-
-

{formatNumber(stats().totalClicks)}

-

Tag Clicks

-
-
- - -
-
-
- -
-
-
-

{stats().avgLinksPerTag}

-

Links per Tag

-
-
-
- - -
- -
-

- - Usage Distribution -

-
-
-
- - - High Usage (10+ links) - - {stats().distribution.highUsage} -
-
-
-
-
-
-
- - - Medium Usage (5-10 links) - - {stats().distribution.mediumUsage} -
-
-
-
-
-
-
- - - Low Usage (1-4 links) - - {stats().distribution.lowUsage} -
-
-
-
-
-
-
- - - Unused - - {stats().distribution.unused} -
-
-
-
-
-
-
- - -
-

- - Color Distribution -

-
- {#each Object.entries(stats().colorDistribution).slice(0, 8) as [color, count]} -
-
- {count} -
-

{color}

-
- {/each} -
-
-
- - -
- -
-

- - Top by Clicks -

-
- {#each stats().topByClicks as tag, i} -
-
- #{i + 1} - - {tag.name} - -
-
- {formatNumber(tag.totalClicks || 0)} - clicks -
-
- {:else} -

No tags with clicks yet

- {/each} -
-
- - -
-

- - Most Used -

-
- {#each stats().mostUsedTags as tag} - {@const usage = getUsageLevel(tag.linkCount || 0)} -
-
- - {tag.name} - -
-
- - {tag.linkCount || 0} - links -
-
- {:else} -

No tags used yet

- {/each} -
-
- - -
-

- - Recently Created -

-
- {#each stats().recentTags as tag} -
-
- - {tag.name} - -
-

- {new Date(tag.created).toLocaleDateString()} -

-
- {:else} -

No tags created yet

- {/each} -
-
-
- - -
-

- - Tag Performance Insights -

-
-
-

- {((stats().usedTags / stats().totalTags) * 100).toFixed(0)}% -

-

Tag Utilization Rate

-
-
-

{stats().avgEngagement}%

-

Average Engagement

-
-
-

- {stats().usedTags > 0 ? Math.floor(stats().totalClicks / stats().usedTags) : 0} -

-

Clicks per Active Tag

-
-
-
-
diff --git a/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte b/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte deleted file mode 100644 index 3e9f81640..000000000 --- a/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte +++ /dev/null @@ -1,322 +0,0 @@ - - -{#if show && card} - - -{/if} diff --git a/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte b/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte deleted file mode 100644 index cebb3fa47..000000000 --- a/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - -
- -
-
- -
- - -
- {#if template.is_featured} - - Featured - - {/if} -
- -
- {#if template.category} - - {template.category} - - {/if} -
- - -
- -
-
- - -
-
-

- {template.metadata?.name || 'Unnamed Template'} -

- {#if template.metadata?.description && !compact} -

- {template.metadata.description} -

- {/if} -
- - - {#if template.tags && template.tags.length > 0 && !compact} -
- {#each template.tags.slice(0, 3) as tag} - - {tag} - - {/each} - {#if template.tags.length > 3} - - +{template.tags.length - 3} - - {/if} -
- {/if} - - -
-
- - - - - - {template.usage_count || 0} - - - - - - - - {template.likes_count || 0} - -
- - - {#if template.created && !compact} - {new Date(template.created).toLocaleDateString()} - {/if} -
- - -
- {#if !compact} - - {/if} - -
- - - {#if !compact} -
- - - - -
- - -
-
- {/if} -
-
diff --git a/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte b/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte deleted file mode 100644 index 164dbc3f1..000000000 --- a/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte +++ /dev/null @@ -1,267 +0,0 @@ - - -{#if show && template} - - -{/if} diff --git a/apps/uload/apps/web/src/lib/content/index.ts b/apps/uload/apps/web/src/lib/content/index.ts deleted file mode 100644 index e8c8a233f..000000000 --- a/apps/uload/apps/web/src/lib/content/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - blogSchema, - authorSchema, - type BlogPost, - type Author, - type BlogPostWithMeta, -} from '../../content/config'; -import { error } from '@sveltejs/kit'; -import { dev } from '$app/environment'; - -// Cache für Performance -const contentCache = new Map(); -const CACHE_DURATION = dev ? 0 : 1000 * 60 * 5; // 5 Min in Production - -export async function getCollection(collection: 'blog' | 'authors'): Promise { - const cacheKey = `collection-${collection}`; - const cached = contentCache.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return cached.data; - } - - let items: T[] = []; - - if (collection === 'blog') { - items = (await getBlogPosts()) as T[]; - } else if (collection === 'authors') { - items = (await getAuthors()) as T[]; - } - - contentCache.set(cacheKey, { - data: items, - timestamp: Date.now(), - }); - - return items; -} - -async function getBlogPosts(): Promise { - const postModules = import.meta.glob('/src/content/blog/**/*.md'); - const posts: BlogPostWithMeta[] = []; - - for (const [path, resolver] of Object.entries(postModules)) { - // Skip drafts in production - if (!dev && path.includes('_drafts')) continue; - - try { - const module = (await resolver()) as any; - const { metadata } = module; - - // Validiere mit Zod Schema - const validatedPost = blogSchema.parse(metadata); - - // Skip drafts based on frontmatter - if (!dev && validatedPost.draft) continue; - - // Füge zusätzliche Metadaten hinzu - const slug = path - .split('/') - .pop() - ?.replace('.md', '') - .replace(/^\d{4}-\d{2}-\d{2}-/, ''); // Datum aus Filename entfernen - - if (!slug) continue; - - posts.push({ - ...validatedPost, - slug, - readingTime: calculateReadingTime(module.default?.default || module.default || ''), - path, - }); - } catch (err) { - console.error(`Error loading ${path}:`, err); - if (dev) throw err; // In Dev Fehler werfen - } - } - - // Sortiere nach Datum (neueste zuerst) - return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); -} - -async function getAuthors(): Promise { - const authorModules = import.meta.glob('/src/content/authors/*.json', { - import: 'default', - }); - - const authors: Author[] = []; - - for (const [path, resolver] of Object.entries(authorModules)) { - try { - const data = (await resolver()) as any; - const validated = authorSchema.parse(data); - authors.push(validated); - } catch (err) { - console.error(`Error loading author ${path}:`, err); - } - } - - return authors; -} - -export async function getEntry(collection: 'blog' | 'authors', slug: string): Promise { - const items = await getCollection(collection); - - if (collection === 'blog') { - return (items as any[]).find((item) => item.slug === slug) || null; - } - - return (items as any[]).find((item) => item.id === slug) || null; -} - -// Helper Functions -function calculateReadingTime(content: string): number { - const wordsPerMinute = 200; - const text = content.replace(/<[^>]*>/g, ''); // Strip HTML - const words = text.split(/\s+/).length; - return Math.ceil(words / wordsPerMinute); -} - -// Blog-spezifische Helpers -export async function getBlogPostsByTag(tag: string): Promise { - const posts = await getCollection('blog'); - return posts.filter((post) => post.tags.includes(tag)); -} - -export async function getBlogPostsByCategory(category: string): Promise { - const posts = await getCollection('blog'); - return posts.filter((post) => post.category === category); -} - -export async function getFeaturedPosts(): Promise { - const posts = await getCollection('blog'); - return posts.filter((post) => post.featured); -} - -export async function getRelatedPosts(currentSlug: string, limit = 3): Promise { - const posts = await getCollection('blog'); - const current = posts.find((p) => p.slug === currentSlug); - - if (!current) return []; - - // Finde Posts mit ähnlichen Tags - const related = posts - .filter((p) => p.slug !== currentSlug) - .map((post) => ({ - post, - score: post.tags.filter((tag) => current.tags.includes(tag)).length, - })) - .filter((item) => item.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map((item) => item.post); - - return related; -} - -// Categories und Tags -export async function getAllCategories() { - const posts = await getCollection('blog'); - const categories = new Map(); - - posts.forEach((post) => { - categories.set(post.category, (categories.get(post.category) || 0) + 1); - }); - - return Array.from(categories.entries()).map(([name, count]) => ({ - name, - slug: name.toLowerCase(), - count, - })); -} - -export async function getAllTags() { - const posts = await getCollection('blog'); - const tags = new Map(); - - posts.forEach((post) => { - post.tags.forEach((tag) => { - tags.set(tag, (tags.get(tag) || 0) + 1); - }); - }); - - return Array.from(tags.entries()) - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count); -} diff --git a/apps/uload/apps/web/src/lib/db/index.ts b/apps/uload/apps/web/src/lib/db/index.ts deleted file mode 100644 index e34e9152c..000000000 --- a/apps/uload/apps/web/src/lib/db/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './schema'; - -// Get connection string from environment -const connectionString = - process.env.DATABASE_URL || 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev'; - -// Connection pool for queries -export const client = postgres(connectionString, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, -}); - -// Drizzle instance with schema -export const db = drizzle(client, { schema }); - -// Types for convenience -export type DB = typeof db; -export type TX = Parameters[0]>[0]; - -// Export all schema tables and relations for easy access -export * from './schema'; diff --git a/apps/uload/apps/web/src/lib/db/schema.ts b/apps/uload/apps/web/src/lib/db/schema.ts deleted file mode 100644 index 1fe321259..000000000 --- a/apps/uload/apps/web/src/lib/db/schema.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { - pgTable, - uuid, - text, - boolean, - integer, - timestamp, - jsonb, - index, -} from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; - -// ============================================ -// Users Table -// ============================================ -export const users = pgTable( - 'users', - { - id: uuid('id').primaryKey().defaultRandom(), - externalAuthId: text('external_auth_id').unique(), // For external auth provider - email: text('email').unique().notNull(), - username: text('username').unique().notNull(), - name: text('name'), - avatarUrl: text('avatar_url'), - bio: text('bio'), - location: text('location'), - website: text('website'), - github: text('github'), - twitter: text('twitter'), - linkedin: text('linkedin'), - instagram: text('instagram'), - publicProfile: boolean('public_profile').default(false), - showClickStats: boolean('show_click_stats').default(true), - emailNotifications: boolean('email_notifications').default(true), - defaultExpiry: integer('default_expiry'), - profileBackground: text('profile_background'), - verified: boolean('verified').default(false), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - emailIdx: index('users_email_idx').on(table.email), - usernameIdx: index('users_username_idx').on(table.username), - externalAuthIdIdx: index('users_external_auth_id_idx').on(table.externalAuthId), - }) -); - -// ============================================ -// Accounts Table (Business/Team Accounts) -// ============================================ -export const accounts = pgTable( - 'accounts', - { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - owner: uuid('owner') - .references(() => users.id) - .notNull(), - isActive: boolean('is_active').default(true), - planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default('free'), - settings: jsonb('settings'), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - ownerIdx: index('accounts_owner_idx').on(table.owner), - }) -); - -// ============================================ -// Workspaces Table -// ============================================ -export const workspaces = pgTable( - 'workspaces', - { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - slug: text('slug').unique().notNull(), - type: text('type', { enum: ['personal', 'team'] }).notNull(), - owner: uuid('owner') - .references(() => users.id) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - slugIdx: index('workspaces_slug_idx').on(table.slug), - ownerIdx: index('workspaces_owner_idx').on(table.owner), - }) -); - -// ============================================ -// Links Table -// ============================================ -export const links = pgTable( - 'links', - { - id: uuid('id').primaryKey().defaultRandom(), - shortCode: text('short_code').unique().notNull(), - customCode: text('custom_code'), - originalUrl: text('original_url').notNull(), - title: text('title'), - description: text('description'), - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), - isActive: boolean('is_active').default(true), - password: text('password'), // hashed - maxClicks: integer('max_clicks'), - expiresAt: timestamp('expires_at'), - clickCount: integer('click_count').default(0), - qrCodeUrl: text('qr_code_url'), // File Storage URL - tags: jsonb('tags').$type(), - utmSource: text('utm_source'), - utmMedium: text('utm_medium'), - utmCampaign: text('utm_campaign'), - accountOwner: uuid('account_owner').references(() => accounts.id), - workspaceId: uuid('workspace_id').references(() => workspaces.id), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('links_user_id_idx').on(table.userId), - shortCodeIdx: index('links_short_code_idx').on(table.shortCode), - workspaceIdIdx: index('links_workspace_id_idx').on(table.workspaceId), - accountOwnerIdx: index('links_account_owner_idx').on(table.accountOwner), - isActiveIdx: index('links_is_active_idx').on(table.isActive), - }) -); - -// ============================================ -// Clicks Table (Analytics) -// ============================================ -export const clicks = pgTable( - 'clicks', - { - id: uuid('id').primaryKey().defaultRandom(), - linkId: uuid('link_id') - .references(() => links.id, { onDelete: 'cascade' }) - .notNull(), - ipHash: text('ip_hash'), - userAgent: text('user_agent'), - referer: text('referer'), - browser: text('browser'), - deviceType: text('device_type'), - os: text('os'), - country: text('country'), - city: text('city'), - clickedAt: timestamp('clicked_at').defaultNow().notNull(), - utmSource: text('utm_source'), - utmMedium: text('utm_medium'), - utmCampaign: text('utm_campaign'), - createdAt: timestamp('created_at').defaultNow().notNull(), - }, - (table) => ({ - linkIdIdx: index('clicks_link_id_idx').on(table.linkId), - clickedAtIdx: index('clicks_clicked_at_idx').on(table.clickedAt), - countryIdx: index('clicks_country_idx').on(table.country), - }) -); - -// ============================================ -// Tags Table -// ============================================ -export const tags = pgTable( - 'tags', - { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - slug: text('slug').notNull(), - color: text('color'), - icon: text('icon'), - isPublic: boolean('is_public').default(false), - usageCount: integer('usage_count').default(0), - userId: uuid('user_id').references(() => users.id), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('tags_user_id_idx').on(table.userId), - slugIdx: index('tags_slug_idx').on(table.slug), - }) -); - -// ============================================ -// Link-Tags Junction Table -// ============================================ -export const linkTags = pgTable( - 'link_tags', - { - id: uuid('id').primaryKey().defaultRandom(), - linkId: uuid('link_id') - .references(() => links.id, { onDelete: 'cascade' }) - .notNull(), - tagId: uuid('tag_id') - .references(() => tags.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - }, - (table) => ({ - linkIdIdx: index('link_tags_link_id_idx').on(table.linkId), - tagIdIdx: index('link_tags_tag_id_idx').on(table.tagId), - uniqueLinkTag: index('link_tags_unique_idx').on(table.linkId, table.tagId), - }) -); - -// ============================================ -// Notifications Table -// ============================================ -export const notifications = pgTable( - 'notifications', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - type: text('type').notNull(), - title: text('title').notNull(), - message: text('message').notNull(), - data: jsonb('data'), - read: boolean('read').default(false), - actionUrl: text('action_url'), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('notifications_user_id_idx').on(table.userId), - readIdx: index('notifications_read_idx').on(table.read), - }) -); - -// ============================================ -// Shared Access Table (Team Invitations) -// ============================================ -export const sharedAccess = pgTable( - 'shared_access', - { - id: uuid('id').primaryKey().defaultRandom(), - owner: uuid('owner') - .references(() => users.id) - .notNull(), - userId: uuid('user_id').references(() => users.id), - permissions: jsonb('permissions'), - invitationStatus: text('invitation_status', { - enum: ['pending', 'accepted', 'declined'], - }).default('pending'), - acceptedAt: timestamp('accepted_at'), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - ownerIdx: index('shared_access_owner_idx').on(table.owner), - userIdIdx: index('shared_access_user_id_idx').on(table.userId), - statusIdx: index('shared_access_status_idx').on(table.invitationStatus), - }) -); - -// ============================================ -// Pending Invitations Table -// ============================================ -export const pendingInvitations = pgTable( - 'pending_invitations', - { - id: uuid('id').primaryKey().defaultRandom(), - email: text('email').notNull(), - token: text('token').unique().notNull(), - owner: uuid('owner') - .references(() => users.id) - .notNull(), - expiresAt: timestamp('expires_at').notNull(), - acceptedAt: timestamp('accepted_at'), - acceptedBy: uuid('accepted_by').references(() => users.id), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - emailIdx: index('pending_invitations_email_idx').on(table.email), - tokenIdx: index('pending_invitations_token_idx').on(table.token), - ownerIdx: index('pending_invitations_owner_idx').on(table.owner), - }) -); - -// ============================================ -// Feature Requests Table -// ============================================ -export const featureRequests = pgTable( - 'feature_requests', - { - id: uuid('id').primaryKey().defaultRandom(), - title: text('title').notNull(), - description: text('description').notNull(), - userId: uuid('user_id') - .references(() => users.id) - .notNull(), - status: text('status', { - enum: ['pending', 'reviewing', 'planned', 'completed', 'rejected'], - }).default('pending'), - voteCount: integer('vote_count').default(0), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('feature_requests_user_id_idx').on(table.userId), - statusIdx: index('feature_requests_status_idx').on(table.status), - voteCountIdx: index('feature_requests_vote_count_idx').on(table.voteCount), - }) -); - -// ============================================ -// Feature Votes Table -// ============================================ -export const featureVotes = pgTable( - 'feature_votes', - { - id: uuid('id').primaryKey().defaultRandom(), - featureRequestId: uuid('feature_request_id') - .references(() => featureRequests.id, { onDelete: 'cascade' }) - .notNull(), - userId: uuid('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - }, - (table) => ({ - featureRequestIdIdx: index('feature_votes_feature_request_id_idx').on(table.featureRequestId), - userIdIdx: index('feature_votes_user_id_idx').on(table.userId), - uniqueVote: index('feature_votes_unique_idx').on(table.featureRequestId, table.userId), - }) -); - -// ============================================ -// Folders Table (minimal usage, keep for future) -// ============================================ -export const folders = pgTable( - 'folders', - { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - userId: uuid('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('folders_user_id_idx').on(table.userId), - }) -); - -// ============================================ -// Relations (for Drizzle Relational Queries) -// ============================================ -export const usersRelations = relations(users, ({ many }) => ({ - links: many(links), - tags: many(tags), - notifications: many(notifications), - ownedAccounts: many(accounts), - ownedWorkspaces: many(workspaces), - featureRequests: many(featureRequests), - featureVotes: many(featureVotes), - folders: many(folders), -})); - -export const linksRelations = relations(links, ({ one, many }) => ({ - user: one(users, { fields: [links.userId], references: [users.id] }), - account: one(accounts, { fields: [links.accountOwner], references: [accounts.id] }), - workspace: one(workspaces, { fields: [links.workspaceId], references: [workspaces.id] }), - clicks: many(clicks), - linkTags: many(linkTags), -})); - -export const clicksRelations = relations(clicks, ({ one }) => ({ - link: one(links, { fields: [clicks.linkId], references: [links.id] }), -})); - -export const tagsRelations = relations(tags, ({ one, many }) => ({ - user: one(users, { fields: [tags.userId], references: [users.id] }), - linkTags: many(linkTags), -})); - -export const linkTagsRelations = relations(linkTags, ({ one }) => ({ - link: one(links, { fields: [linkTags.linkId], references: [links.id] }), - tag: one(tags, { fields: [linkTags.tagId], references: [tags.id] }), -})); - -export const accountsRelations = relations(accounts, ({ one, many }) => ({ - owner: one(users, { fields: [accounts.owner], references: [users.id] }), - links: many(links), -})); - -export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ - owner: one(users, { fields: [workspaces.owner], references: [users.id] }), - links: many(links), -})); - -export const notificationsRelations = relations(notifications, ({ one }) => ({ - user: one(users, { fields: [notifications.userId], references: [users.id] }), -})); - -export const featureRequestsRelations = relations(featureRequests, ({ one, many }) => ({ - user: one(users, { fields: [featureRequests.userId], references: [users.id] }), - votes: many(featureVotes), -})); - -export const featureVotesRelations = relations(featureVotes, ({ one }) => ({ - featureRequest: one(featureRequests, { - fields: [featureVotes.featureRequestId], - references: [featureRequests.id], - }), - user: one(users, { fields: [featureVotes.userId], references: [users.id] }), -})); - -export const foldersRelations = relations(folders, ({ one }) => ({ - user: one(users, { fields: [folders.userId], references: [users.id] }), -})); diff --git a/apps/uload/apps/web/src/lib/email.ts b/apps/uload/apps/web/src/lib/email.ts deleted file mode 100644 index edefe3892..000000000 --- a/apps/uload/apps/web/src/lib/email.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Resend } from 'resend'; -import { env } from '$env/dynamic/private'; -import { env as publicEnv } from '$env/dynamic/public'; - -// Initialize Resend client -const resend = new Resend(env.RESEND_API_KEY); - -const FROM_EMAIL = env.RESEND_FROM_EMAIL || 'noreply@ulo.ad'; -const APP_URL = publicEnv.PUBLIC_APP_URL || 'https://ulo.ad'; - -/** - * Send a team invitation email - */ -export async function sendTeamInvitationEmail( - recipientEmail: string, - inviterName: string, - inviteToken: string -): Promise { - try { - const inviteUrl = `${APP_URL}/register?invite=${inviteToken}`; - - await resend.emails.send({ - from: `ulo.ad <${FROM_EMAIL}>`, - to: recipientEmail, - subject: `${inviterName} hat dich zu seinem Team eingeladen - ulo.ad`, - html: ` -
- -
-

- 🔗 ulo.ad -

-
- - -
-

- Du wurdest zum Team eingeladen! 🎉 -

- -

- ${inviterName} hat dich eingeladen, seinem Team bei ulo.ad beizutreten. - Als Team-Mitglied kannst du Links erstellen und verwalten. -

- - -
-

- Als Team-Mitglied kannst du: -

-
    -
  • Links erstellen und verwalten
  • -
  • Deine eigenen Links bearbeiten und löschen
  • -
  • Mit dem Team zusammenarbeiten
  • -
-
- - - - - -
-

- Falls der Button nicht funktioniert, kopiere diesen Link: -

-

- ${inviteUrl} -

-
- - -
-

- ⏰ Diese Einladung ist 7 Tage gültig -

-
-
- - -
-

- Diese Einladung wurde an ${recipientEmail} gesendet. -

-

- © ${new Date().getFullYear()} ulo.ad · ulo.ad -

-
-
`, - }); - - console.log('[EMAIL] Team invitation sent to:', recipientEmail); - return true; - } catch (error) { - console.error('[EMAIL] Failed to send invitation email:', error); - return false; - } -} - -/** - * Send notification when invitation is accepted - */ -export async function sendInvitationAcceptedEmail( - ownerEmail: string, - memberName: string -): Promise { - try { - await resend.emails.send({ - from: `ulo.ad <${FROM_EMAIL}>`, - to: ownerEmail, - subject: `${memberName} hat deine Einladung angenommen - ulo.ad`, - html: ` -
- -
-

- 🔗 ulo.ad -

-
- - -
-

- Neues Team-Mitglied! 🎊 -

- -

- ${memberName} hat deine Einladung angenommen und ist jetzt Teil deines Teams. -

- - -
-

- ✅ Das Team-Mitglied kann jetzt Links in deinem Account erstellen und verwalten. -

-
- - - -
- - -
-

- © ${new Date().getFullYear()} ulo.ad · ulo.ad -

-
-
`, - }); - - console.log('[EMAIL] Acceptance notification sent to:', ownerEmail); - return true; - } catch (error) { - console.error('[EMAIL] Failed to send acceptance notification:', error); - return false; - } -} - -/** - * Send welcome email to new users - */ -export async function sendWelcomeEmail(to: string, username: string): Promise { - try { - await resend.emails.send({ - from: `ulo.ad <${FROM_EMAIL}>`, - to, - subject: 'Willkommen bei ulo.ad!', - html: ` -
-
-

- 🔗 ulo.ad -

-
- -
-

Willkommen, ${username}!

-

Danke, dass du bei ulo.ad dabei bist. Wir freuen uns, dich an Bord zu haben.

-

Mit ulo.ad kannst du:

-
    -
  • URLs kürzen und anpassen
  • -
  • Click-Analytics verfolgen
  • -
  • Links mit Tags und Workspaces organisieren
  • -
  • QR-Codes generieren
  • -
  • Ablaufdaten und Click-Limits setzen
  • -
- -
- -
-

- © ${new Date().getFullYear()} ulo.ad -

-
-
`, - }); - - return true; - } catch (error) { - console.error('[EMAIL] Failed to send welcome email:', error); - return false; - } -} diff --git a/apps/uload/apps/web/src/lib/gdpr/compliance.ts b/apps/uload/apps/web/src/lib/gdpr/compliance.ts deleted file mode 100644 index f3d741bc2..000000000 --- a/apps/uload/apps/web/src/lib/gdpr/compliance.ts +++ /dev/null @@ -1,422 +0,0 @@ -// GDPR Compliance Implementierung für uLoad -// Datenschutz-Grundverordnung (DSGVO) Konformität - -export interface GDPRConsent { - necessary: boolean; // Immer true, technisch erforderlich - analytics: boolean; - marketing: boolean; - preferences: boolean; - timestamp: string; - version: string; -} - -export interface DataProcessingPurpose { - id: string; - name: string; - description: string; - legalBasis: - | 'consent' - | 'contract' - | 'legal_obligation' - | 'vital_interests' - | 'public_task' - | 'legitimate_interests'; - dataTypes: string[]; - retention: string; - required: boolean; -} - -// GDPR-konforme Datenverarbeitungszwecke für uLoad -export const DATA_PROCESSING_PURPOSES: DataProcessingPurpose[] = [ - { - id: 'account_management', - name: 'Account-Verwaltung', - description: 'Bereitstellung und Verwaltung Ihres Benutzerkontos', - legalBasis: 'contract', - dataTypes: ['email', 'username', 'password_hash', 'profile_data'], - retention: 'Bis zur Kontolöschung', - required: true, - }, - { - id: 'link_service', - name: 'Link-Verkürrungs-Service', - description: 'Erstellung und Verwaltung von kurzen Links', - legalBasis: 'contract', - dataTypes: ['original_urls', 'short_codes', 'link_metadata'], - retention: 'Bis zur manuellen Löschung oder Kontolöschung', - required: true, - }, - { - id: 'click_analytics', - name: 'Click-Analytics', - description: 'Anonyme Analyse von Link-Klicks für Statistiken', - legalBasis: 'legitimate_interests', - dataTypes: ['anonymized_ip', 'user_agent', 'referer', 'timestamp'], - retention: '12 Monate', - required: false, - }, - { - id: 'security', - name: 'Sicherheit und Betrug-Prävention', - description: 'Schutz vor Missbrauch und Sicherheit der Plattform', - legalBasis: 'legitimate_interests', - dataTypes: ['ip_address', 'user_agent', 'access_logs'], - retention: '6 Monate', - required: true, - }, - { - id: 'communication', - name: 'Service-Kommunikation', - description: 'Wichtige Mitteilungen zum Service (Updates, Sicherheit)', - legalBasis: 'contract', - dataTypes: ['email', 'communication_preferences'], - retention: 'Bis zur Kontolöschung', - required: true, - }, - { - id: 'marketing', - name: 'Marketing und Newsletter', - description: 'Produktneuigkeiten und Marketing-Kommunikation', - legalBasis: 'consent', - dataTypes: ['email', 'usage_patterns', 'preferences'], - retention: 'Bis zum Widerruf der Einwilligung', - required: false, - }, - { - id: 'analytics', - name: 'Website-Analytics', - description: 'Analyse der Website-Nutzung zur Verbesserung', - legalBasis: 'consent', - dataTypes: ['anonymized_usage_data', 'page_views', 'session_data'], - retention: '14 Monate', - required: false, - }, -]; - -// Standard GDPR Consent -export const DEFAULT_CONSENT: GDPRConsent = { - necessary: true, - analytics: false, - marketing: false, - preferences: false, - timestamp: new Date().toISOString(), - version: '1.0', -}; - -// GDPR Consent Manager -export class GDPRManager { - private static readonly CONSENT_KEY = 'gdpr_consent'; - private static readonly CONSENT_VERSION = '1.0'; - - // Aktuelle Einwilligung laden - static getConsent(): GDPRConsent | null { - if (typeof localStorage === 'undefined') return null; - - try { - const stored = localStorage.getItem(this.CONSENT_KEY); - if (!stored) return null; - - const consent = JSON.parse(stored) as GDPRConsent; - - // Prüfe Version - bei Änderungen neue Einwilligung erforderlich - if (consent.version !== this.CONSENT_VERSION) { - this.clearConsent(); - return null; - } - - return consent; - } catch (error) { - console.error('Error loading GDPR consent:', error); - return null; - } - } - - // Einwilligung speichern - static setConsent(consent: Partial): void { - if (typeof localStorage === 'undefined') return; - - const fullConsent: GDPRConsent = { - ...DEFAULT_CONSENT, - ...consent, - timestamp: new Date().toISOString(), - version: this.CONSENT_VERSION, - }; - - try { - localStorage.setItem(this.CONSENT_KEY, JSON.stringify(fullConsent)); - - // Event für andere Teile der App - window.dispatchEvent( - new CustomEvent('gdpr:consent-updated', { - detail: fullConsent, - }) - ); - - console.log('GDPR consent updated:', fullConsent); - } catch (error) { - console.error('Error saving GDPR consent:', error); - } - } - - // Einwilligung löschen - static clearConsent(): void { - if (typeof localStorage === 'undefined') return; - - localStorage.removeItem(this.CONSENT_KEY); - - window.dispatchEvent(new CustomEvent('gdpr:consent-cleared')); - console.log('GDPR consent cleared'); - } - - // Prüfe ob Einwilligung erforderlich ist - static needsConsent(): boolean { - const consent = this.getConsent(); - return consent === null; - } - - // Prüfe spezifische Einwilligung - static hasConsent(type: keyof Omit): boolean { - const consent = this.getConsent(); - if (!consent) return type === 'necessary'; // Nur notwendige Cookies ohne Einwilligung - - return consent[type]; - } - - // Benutzerrechte verwalten - static async exerciseUserRights(request: UserRightRequest): Promise { - switch (request.type) { - case 'access': - return this.handleDataAccess(request); - case 'rectification': - return this.handleDataRectification(request); - case 'erasure': - return this.handleDataErasure(request); - case 'portability': - return this.handleDataPortability(request); - case 'restriction': - return this.handleProcessingRestriction(request); - case 'objection': - return this.handleProcessingObjection(request); - default: - throw new Error('Unknown user right request'); - } - } - - // Recht auf Auskunft (Art. 15 DSGVO) - private static async handleDataAccess(request: UserRightRequest): Promise { - // Sammle alle Benutzerdaten - const userData = { - account: { - email: request.userEmail, - created: request.accountCreated, - lastLogin: request.lastLogin, - }, - links: request.userLinks || [], - analytics: request.userAnalytics || [], - consent: this.getConsent(), - purposes: DATA_PROCESSING_PURPOSES.filter((p) => p.required || this.hasConsent(p.id as any)), - }; - - return { - success: true, - type: 'access', - data: userData, - message: 'Ihre personenbezogenen Daten wurden zusammengestellt', - }; - } - - // Recht auf Berichtigung (Art. 16 DSGVO) - private static async handleDataRectification( - request: UserRightRequest - ): Promise { - // In einer echten Implementation würde hier eine API-Anfrage an den Server gehen - return { - success: true, - type: 'rectification', - message: 'Ihr Antrag auf Datenberichtigung wurde eingereicht', - }; - } - - // Recht auf Löschung (Art. 17 DSGVO) - private static async handleDataErasure(request: UserRightRequest): Promise { - // Lokale Consent-Daten löschen - this.clearConsent(); - - return { - success: true, - type: 'erasure', - message: 'Ihr Antrag auf Datenlöschung wurde eingereicht', - }; - } - - // Recht auf Datenübertragbarkeit (Art. 20 DSGVO) - private static async handleDataPortability( - request: UserRightRequest - ): Promise { - const exportData = { - links: request.userLinks || [], - analytics: request.userAnalytics || [], - profile: request.userProfile || {}, - exportDate: new Date().toISOString(), - format: 'JSON', - }; - - return { - success: true, - type: 'portability', - data: exportData, - message: 'Ihre Daten wurden für den Export vorbereitet', - }; - } - - // Recht auf Einschränkung (Art. 18 DSGVO) - private static async handleProcessingRestriction( - request: UserRightRequest - ): Promise { - return { - success: true, - type: 'restriction', - message: 'Ihr Antrag auf Verarbeitungseinschränkung wurde eingereicht', - }; - } - - // Widerspruchsrecht (Art. 21 DSGVO) - private static async handleProcessingObjection( - request: UserRightRequest - ): Promise { - // Analytics und Marketing deaktivieren - this.setConsent({ - ...this.getConsent(), - analytics: false, - marketing: false, - }); - - return { - success: true, - type: 'objection', - message: 'Ihr Widerspruch wurde verarbeitet', - }; - } -} - -// Interfaces für Benutzerrechte -export interface UserRightRequest { - type: 'access' | 'rectification' | 'erasure' | 'portability' | 'restriction' | 'objection'; - userEmail: string; - accountCreated?: string; - lastLogin?: string; - userLinks?: any[]; - userAnalytics?: any[]; - userProfile?: any; - reason?: string; -} - -export interface UserRightResponse { - success: boolean; - type: string; - data?: any; - message: string; - error?: string; -} - -// Cookie-Banner Utilities -export function shouldShowCookieBanner(): boolean { - return GDPRManager.needsConsent(); -} - -export function acceptAllCookies(): void { - GDPRManager.setConsent({ - necessary: true, - analytics: true, - marketing: true, - preferences: true, - }); -} - -export function acceptNecessaryOnly(): void { - GDPRManager.setConsent({ - necessary: true, - analytics: false, - marketing: false, - preferences: false, - }); -} - -// Data Processing Record (Art. 30 DSGVO) -export function generateProcessingRecord(): any { - return { - controller: { - name: 'uLoad', - contact: 'privacy@ulo.ad', - representative: 'Till Schneider', - dpo: null, // Falls kein Datenschutzbeauftragter erforderlich - }, - purposes: DATA_PROCESSING_PURPOSES, - categories: { - dataSubjects: ['users', 'visitors'], - personalData: ['identification', 'contact', 'usage', 'technical'], - recipients: ['hosting_provider', 'analytics_provider', 'payment_provider'], - transfers: ['within_eu'], - }, - retention: { - criteria: 'Purpose-based retention', - periods: DATA_PROCESSING_PURPOSES.map((p) => ({ - purpose: p.name, - period: p.retention, - })), - }, - security: { - measures: ['encryption', 'access_control', 'regular_backups', 'monitoring'], - certifications: [], - }, - lastUpdated: new Date().toISOString(), - }; -} - -// Anonymisierung von IP-Adressen (für Analytics) -export function anonymizeIP(ip: string): string { - if (ip.includes(':')) { - // IPv6 - entferne die letzten 80 Bits - const parts = ip.split(':'); - return parts.slice(0, 5).join(':') + '::'; - } else { - // IPv4 - entferne das letzte Oktett - const parts = ip.split('.'); - return parts.slice(0, 3).join('.') + '.0'; - } -} - -// Daten-Minimierung prüfen -export function isDataMinimal(dataCollection: any): boolean { - const requiredFields = ['email', 'username']; - const optionalFields = ['name', 'bio', 'website']; - const collectedFields = Object.keys(dataCollection); - - // Prüfe ob nur notwendige und explizit gewünschte Felder gesammelt werden - const unnecessary = collectedFields.filter( - (field) => !requiredFields.includes(field) && !optionalFields.includes(field) - ); - - return unnecessary.length === 0; -} - -// Legal Basis Validation -export function validateLegalBasis( - purpose: string, - hasConsent: boolean, - isRequired: boolean -): boolean { - const purposeConfig = DATA_PROCESSING_PURPOSES.find((p) => p.id === purpose); - if (!purposeConfig) return false; - - switch (purposeConfig.legalBasis) { - case 'consent': - return hasConsent; - case 'contract': - return isRequired; - case 'legitimate_interests': - return true; // Interessenabwägung bereits durchgeführt - default: - return false; - } -} diff --git a/apps/uload/apps/web/src/lib/i18n/index.ts b/apps/uload/apps/web/src/lib/i18n/index.ts deleted file mode 100644 index 7e51106fa..000000000 --- a/apps/uload/apps/web/src/lib/i18n/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { browser } from '$app/environment'; -import { init, register, locale, waitLocale } from 'svelte-i18n'; - -// Register all available locales -register('de', () => import('./locales/de.json')); -register('en', () => import('./locales/en.json')); -register('it', () => import('./locales/it.json')); -register('fr', () => import('./locales/fr.json')); -register('es', () => import('./locales/es.json')); - -// List of supported locales -export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const; -export type SupportedLocale = (typeof supportedLocales)[number]; - -// Default locale -const defaultLocale = 'en'; - -// Get initial locale from browser or localStorage -function getInitialLocale(): SupportedLocale { - if (browser) { - // Check localStorage first - const stored = localStorage.getItem('locale'); - if (stored && supportedLocales.includes(stored as SupportedLocale)) { - return stored as SupportedLocale; - } - - // Fall back to browser language - const browserLang = navigator.language.split('-')[0]; - if (supportedLocales.includes(browserLang as SupportedLocale)) { - return browserLang as SupportedLocale; - } - } - - return defaultLocale; -} - -// Initialize i18n at module scope (required for SSR) -init({ - fallbackLocale: defaultLocale, - initialLocale: getInitialLocale(), -}); - -// Also export initI18n for backwards compatibility -export function initI18n() { - init({ - fallbackLocale: defaultLocale, - initialLocale: getInitialLocale(), - }); -} - -// Set locale and persist to localStorage -export function setLocale(newLocale: SupportedLocale) { - locale.set(newLocale); - if (browser) { - localStorage.setItem('locale', newLocale); - } -} - -// Wait for locale to be loaded (useful for SSR) -export { waitLocale }; diff --git a/apps/uload/apps/web/src/lib/i18n/locales/de.json b/apps/uload/apps/web/src/lib/i18n/locales/de.json deleted file mode 100644 index bcee7d5aa..000000000 --- a/apps/uload/apps/web/src/lib/i18n/locales/de.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "nav_login": "Anmelden", - "nav_register": "Registrieren", - "nav_dashboard": "Dashboard", - "nav_folders": "Ordner", - "nav_profile": "Profil", - "nav_logout": "Abmelden", - "home_title": "Links intelligenter teilen", - "home_subtitle": "Erstelle verkürzte Links mit QR-Codes, benutzerdefinierten Namen und Analysen", - "home_url_label_qr": "URL zum Kodieren", - "home_url_label": "URL zum Kürzen", - "home_title_label": "Titel", - "home_title_placeholder": "Gib deinem Link einen Namen", - "home_description_label": "Beschreibung", - "home_description_placeholder": "Füge eine Beschreibung hinzu (optional)", - "home_expires_label": "Ablauf", - "home_expires_placeholder": "z.B. 7 Tage, 1 Monat", - "home_max_clicks_label": "Max. Klicks", - "home_max_clicks_placeholder": "Anzahl der Klicks begrenzen", - "home_password_label": "Passwort", - "home_password_placeholder": "Mit Passwort schützen", - "home_guest_info": "Du verwendest uload als Gast", - "auth_modal_signin": "Anmelden", - "home_guest_signin_hint": "um auf erweiterte Funktionen zuzugreifen", - "home_processing": "Verarbeitung...", - "home_submit_button_qr": "QR-Code generieren", - "home_submit_button": "Link erstellen" -} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/en.json b/apps/uload/apps/web/src/lib/i18n/locales/en.json deleted file mode 100644 index d65d949e4..000000000 --- a/apps/uload/apps/web/src/lib/i18n/locales/en.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "nav_login": "Login", - "nav_register": "Register", - "nav_dashboard": "Dashboard", - "nav_folders": "Folders", - "nav_profile": "Profile", - "nav_logout": "Logout", - "nav_pricing": "Pricing", - - "home_title": "Share Links Smarter", - "home_subtitle": "Create shortened links with QR codes, custom names, and analytics", - "home_url_label_qr": "URL to encode", - "home_url_label": "URL to shorten", - "home_title_label": "Title", - "home_title_placeholder": "Give your link a name", - "home_description_label": "Description", - "home_description_placeholder": "Add a description (optional)", - "home_expires_label": "Expiration", - "home_expires_placeholder": "e.g., 7 days, 1 month", - "home_max_clicks_label": "Max clicks", - "home_max_clicks_placeholder": "Limit number of clicks", - "home_password_label": "Password", - "home_password_placeholder": "Protect with password", - "home_guest_info": "You're using uload as a guest", - "home_guest_signin_hint": "to access advanced features", - "home_processing": "Processing...", - "home_submit_button_qr": "Generate QR Code", - "home_submit_button": "Create Link", - - "auth_modal_signin": "Sign in", - "auth_sign_in": "Sign In", - "auth_login_button": "Login", - "auth_login_button_loading": "Logging in...", - "auth_register_button": "Register", - "auth_register_button_loading": "Creating account...", - "auth_email_label": "Email", - "auth_email_placeholder": "Enter your email", - "auth_email_address_label": "Email Address", - "auth_password_label": "Password", - "auth_password_confirm_label": "Confirm Password", - "auth_forgot_password": "Forgot password?", - "auth_no_account": "Don't have an account?", - "auth_have_account": "Already have an account?", - "auth_create_account": "Create Account", - "auth_create_account_title": "Create Account", - "auth_create_account_subtitle": "Join us to start shortening links", - "auth_welcome_back": "Welcome Back", - "auth_welcome_back_subtitle": "Sign in to continue", - "auth_back_to_login": "Back to login", - "auth_go_to_login": "Go to login", - "auth_remember_password": "Remember your password?", - "auth_username_auto": "Username will be generated automatically", - "auth_registration_tip": "You'll receive a verification email", - "auth_registration_success": "Registration successful!", - "auth_registration_success_message": "Please check your email to verify your account.", - - "auth_reset_password_title": "Reset Password", - "auth_reset_password_subtitle": "Enter your email to receive a reset link", - "auth_reset_password_button": "Reset Password", - "auth_reset_password_button_loading": "Resetting...", - "auth_send_reset_button": "Send Reset Link", - "auth_send_reset_button_loading": "Sending...", - "auth_reset_email_sent_title": "Email Sent", - "auth_reset_email_sent_message": "Check your inbox for the password reset link.", - "auth_request_new_reset_link": "Request new link", - - "auth_set_new_password_title": "Set New Password", - "auth_set_new_password_subtitle": "Enter your new password below", - "auth_new_password_label": "New Password", - "auth_new_password_placeholder": "Enter new password", - "auth_confirm_new_password_label": "Confirm New Password", - "auth_confirm_new_password_placeholder": "Confirm new password", - "auth_password_reset_success": "Password Reset", - "auth_password_reset_success_message": "Your password has been successfully reset.", - - "auth_invalid_reset_link": "Invalid Reset Link", - "auth_invalid_reset_link_message": "This password reset link is invalid or has expired.", - "auth_invalid_verification_link": "Invalid Verification Link", - "auth_invalid_verification_link_message": "This verification link is invalid or has expired.", - "auth_verification_link_expired": "Link Expired", - "auth_verification_link_expired_message": "This verification link has expired. Please request a new one.", - "auth_email_verified": "Email Verified", - "auth_email_verified_message": "Your email has been successfully verified.", - "auth_email_already_verified": "Already Verified", - "auth_email_already_verified_message": "Your email is already verified.", - "auth_email_already_verified_notify": "Already verified", - "auth_email_already_verified_notify_desc": "Your email was already verified. You can log in now.", - "auth_token_expired_notify": "Session Expired", - "auth_token_expired_notify_desc": "Your session has expired. Please log in again.", - - "auth_add_account": "Add Account", - "auth_add_account_info": "Add another account to quickly switch between them", - "auth_add_account_subtitle": "Sign in with another account", - "auth_add_account_switch_info": "You can switch between accounts anytime", - - "account_my_account": "My Account", - "account_add_account": "Add Account", - "account_team_accounts": "Team Accounts", - "account_no_team_accounts": "No team accounts", - "account_team_invite_info": "Invite team members to collaborate", - "account_team_member": "Team Member", - - "workspace_switch": "Switch Workspace", - "workspace_personal": "Personal", - "workspace_create": "Create Workspace", - - "hero_control_headline": "Share Links Smarter", - "hero_control_subheadline": "Create shortened links with analytics and QR codes", - "hero_control_cta": "Get Started", - "hero_free_text": "Free to start", - "hero_trust_badge_": "Trusted by thousands", - "hero_a": "Hero A", - "hero_b": "Hero B", - "hero_c": "Hero C", - - "toast_login_success": "Login successful", - "toast_login_error": "Login failed", - "toast_logout_success": "Logged out successfully", - "toast_register_success": "Account created successfully", - "toast_link_created": "Link created successfully", - "toast_link_updated": "Link updated successfully", - "toast_link_deleted": "Link deleted successfully", - "toast_link_copied": "Link copied to clipboard", - "toast_profile_updated": "Profile updated successfully", - "toast_avatar_uploaded": "Avatar uploaded successfully", - "toast_password_changed": "Password changed successfully", - "toast_password_reset_sent": "Password reset email sent", - "toast_email_verified": "Email verified successfully", - "toast_session_expired": "Session expired", - "toast_session_expired_desc": "Please log in again to continue.", - "toast_network_error": "Network error", - "toast_network_error_desc": "Please check your connection and try again.", - "toast_permission_denied": "Permission denied", - "toast_payment_failed": "Payment failed", - "toast_payment_failed_desc": "Please try again or use a different payment method.", - "toast_subscription_upgraded": "Subscription upgraded", - "toast_subscription_cancelled": "Subscription cancelled", - "toast_unsupported_format": "Unsupported format", - - "error_link_creation": "Failed to create links", - "error_link_creation_single": "Failed to create link", - "error_password_change": "Failed to change password", - "error_save": "Failed to save changes" -} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/es.json b/apps/uload/apps/web/src/lib/i18n/locales/es.json deleted file mode 100644 index 51e517dad..000000000 --- a/apps/uload/apps/web/src/lib/i18n/locales/es.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "nav_login": "Iniciar sesión", - "nav_register": "Registrarse", - "nav_dashboard": "Panel", - "nav_folders": "Carpetas", - "nav_profile": "Perfil", - "nav_logout": "Cerrar sesión", - "home_title": "Comparte Enlaces de Forma Inteligente", - "home_subtitle": "Crea enlaces acortados con códigos QR, nombres personalizados y análisis", - "home_url_label_qr": "URL para codificar", - "home_url_label": "URL para acortar", - "home_title_label": "Título", - "home_title_placeholder": "Dale un nombre a tu enlace", - "home_description_label": "Descripción", - "home_description_placeholder": "Añadir una descripción (opcional)", - "home_expires_label": "Vencimiento", - "home_expires_placeholder": "ej., 7 días, 1 mes", - "home_max_clicks_label": "Clics máximos", - "home_max_clicks_placeholder": "Limitar número de clics", - "home_password_label": "Contraseña", - "home_password_placeholder": "Proteger con contraseña", - "home_guest_info": "Estás usando uload como invitado", - "auth_modal_signin": "Iniciar sesión", - "home_guest_signin_hint": "para acceder a funciones avanzadas", - "home_processing": "Procesando...", - "home_submit_button_qr": "Generar Código QR", - "home_submit_button": "Crear Enlace" -} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/fr.json b/apps/uload/apps/web/src/lib/i18n/locales/fr.json deleted file mode 100644 index fbbcfdc70..000000000 --- a/apps/uload/apps/web/src/lib/i18n/locales/fr.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "nav_login": "Connexion", - "nav_register": "S'inscrire", - "nav_dashboard": "Tableau de bord", - "nav_folders": "Dossiers", - "nav_profile": "Profil", - "nav_logout": "Déconnexion", - "home_title": "Partagez des Liens Intelligemment", - "home_subtitle": "Créez des liens raccourcis avec codes QR, noms personnalisés et analyses", - "home_url_label_qr": "URL à encoder", - "home_url_label": "URL à raccourcir", - "home_title_label": "Titre", - "home_title_placeholder": "Donnez un nom à votre lien", - "home_description_label": "Description", - "home_description_placeholder": "Ajouter une description (optionnel)", - "home_expires_label": "Expiration", - "home_expires_placeholder": "ex., 7 jours, 1 mois", - "home_max_clicks_label": "Clics maximum", - "home_max_clicks_placeholder": "Limiter le nombre de clics", - "home_password_label": "Mot de passe", - "home_password_placeholder": "Protéger avec mot de passe", - "home_guest_info": "Vous utilisez uload en tant qu'invité", - "auth_modal_signin": "Se connecter", - "home_guest_signin_hint": "pour accéder aux fonctionnalités avancées", - "home_processing": "Traitement...", - "home_submit_button_qr": "Générer Code QR", - "home_submit_button": "Créer Lien" -} diff --git a/apps/uload/apps/web/src/lib/i18n/locales/it.json b/apps/uload/apps/web/src/lib/i18n/locales/it.json deleted file mode 100644 index 528b4ecb7..000000000 --- a/apps/uload/apps/web/src/lib/i18n/locales/it.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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" -} diff --git a/apps/uload/apps/web/src/lib/index.ts b/apps/uload/apps/web/src/lib/index.ts deleted file mode 100644 index 856f2b6c3..000000000 --- a/apps/uload/apps/web/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte b/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte deleted file mode 100644 index a63a8eaf5..000000000 --- a/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte +++ /dev/null @@ -1,263 +0,0 @@ - - - - {seo.title || title} | uload Blog - - - - - - - {#each tags as tag} - - {/each} - {#if image} - - {/if} - - - -
- -
-
- -
-
- {#if series} -
- Serie: {series} -
- {/if} - -

{title}

- -
- - - - {category} - - - {readingTime} Min. Lesezeit -
- - {#if tags.length > 0} -
- {#each tags as tag} - - #{tag} - - {/each} -
- {/if} - - {#if image} - {title} - {/if} -
- - -
- -
- -
- - - - {#if authorData} -
-

Über den Autor

-
- {#if authorData.avatar} - {authorData.name} - {/if} -
-

{authorData.name}

- {#if authorData.bio} -

{authorData.bio}

- {/if} -
-
-
- {/if} -
-
- - - -
-
- - diff --git a/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte b/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte deleted file mode 100644 index d66520547..000000000 --- a/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - - {#if title} - {title} | uload - {/if} - - -
- -
diff --git a/apps/uload/apps/web/src/lib/locale.ts b/apps/uload/apps/web/src/lib/locale.ts deleted file mode 100644 index 37bfd7960..000000000 --- a/apps/uload/apps/web/src/lib/locale.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { browser } from '$app/environment'; -import { locale } from 'svelte-i18n'; -import { get } from 'svelte/store'; -import '$lib/i18n'; // Initialize i18n - -export function initLocale() { - if (browser) { - const savedLang = localStorage.getItem('preferred-language'); - const browserLang = navigator.language.split('-')[0]; - const supportedLangs = ['en', 'de', 'it', 'fr', 'es']; - - let targetLang = 'en'; // default - - if (savedLang && supportedLangs.includes(savedLang)) { - targetLang = savedLang; - } else if (supportedLangs.includes(browserLang)) { - targetLang = browserLang; - } - - try { - locale.set(targetLang); - } catch (e) { - console.warn('Failed to set locale:', e); - locale.set('en'); - } - } -} - -export function getCurrentLocale(): string { - try { - return get(locale) || 'en'; - } catch { - return 'en'; - } -} - -export function setCurrentLocale(lang: string) { - locale.set(lang); - if (browser) { - localStorage.setItem('preferred-language', lang); - } -} diff --git a/apps/uload/apps/web/src/lib/pwa.ts b/apps/uload/apps/web/src/lib/pwa.ts deleted file mode 100644 index cb8829d41..000000000 --- a/apps/uload/apps/web/src/lib/pwa.ts +++ /dev/null @@ -1,172 +0,0 @@ -// PWA Installation und Service Worker Management -import { browser } from '$app/environment'; -import { writable, get } from 'svelte/store'; - -// PWA Installation State - using Svelte stores for SSR compatibility -export const deferredPromptStore = writable(null); -export const isInstallableStore = writable(false); -export const isInstalledStore = writable(false); -export const isStandaloneStore = writable(false); - -// Service Worker Registration -export const serviceWorkerRegistrationStore = writable(null); -export const isOfflineStore = writable(false); - -// Export getters for convenience -export const getDeferredPrompt = () => get(deferredPromptStore); -export const getIsInstallable = () => get(isInstallableStore); -export const getIsInstalled = () => get(isInstalledStore); -export const getIsStandalone = () => get(isStandaloneStore); -export const getServiceWorkerRegistration = () => get(serviceWorkerRegistrationStore); -export const getIsOffline = () => get(isOfflineStore); - -if (browser) { - // Check if app is already installed (standalone mode) - const standalone = - window.matchMedia('(display-mode: standalone)').matches || - (window.navigator as any).standalone || - document.referrer.includes('android-app://'); - isStandaloneStore.set(standalone); - - // Listen for beforeinstallprompt event - window.addEventListener('beforeinstallprompt', (e) => { - console.log('PWA: Install prompt available'); - e.preventDefault(); - deferredPromptStore.set(e); - isInstallableStore.set(true); - }); - - // Listen for app installation - window.addEventListener('appinstalled', () => { - console.log('PWA: App installed'); - isInstalledStore.set(true); - isInstallableStore.set(false); - deferredPromptStore.set(null); - }); - - // Online/Offline status tracking - const updateOnlineStatus = () => { - isOfflineStore.set(!navigator.onLine); - }; - - window.addEventListener('online', updateOnlineStatus); - window.addEventListener('offline', updateOnlineStatus); - updateOnlineStatus(); -} - -// Install PWA function -export async function installPWA(): Promise { - const deferredPrompt = get(deferredPromptStore); - - if (!deferredPrompt) { - console.log('PWA: No install prompt available'); - return false; - } - - // Show the install prompt - deferredPrompt.prompt(); - - // Wait for the user's response - const { outcome } = await deferredPrompt.userChoice; - - console.log(`PWA: User response to install prompt: ${outcome}`); - - // Clear the deferred prompt - deferredPromptStore.set(null); - isInstallableStore.set(false); - - if (outcome === 'accepted') { - isInstalledStore.set(true); - return true; - } - - return false; -} - -// Register Service Worker -export async function registerServiceWorker(): Promise { - if (!browser || !('serviceWorker' in navigator)) { - console.log('PWA: Service Worker not supported'); - return null; - } - - try { - const registration = await navigator.serviceWorker.register('/sw.js', { - scope: '/', - }); - - console.log('PWA: Service Worker registered', registration); - serviceWorkerRegistrationStore.set(registration); - - // Check for updates on focus - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - registration.update(); - } - }); - - // Handle updates - registration.addEventListener('updatefound', () => { - const newWorker = registration.installing; - if (newWorker) { - newWorker.addEventListener('statechange', () => { - if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { - // New content available - console.log('PWA: New content available'); - // You might want to show a notification to the user - } - }); - } - }); - - return registration; - } catch (error) { - console.error('PWA: Service Worker registration failed:', error); - return null; - } -} - -// Initialize PWA features -export function initializePWA() { - if (!browser) return; - - // Register service worker - registerServiceWorker(); - - // Additional PWA features can be initialized here - console.log('PWA: Initialized'); -} - -// Check if update is available -export async function checkForUpdates(): Promise { - const registration = get(serviceWorkerRegistrationStore); - - if (!registration) { - return false; - } - - try { - await registration.update(); - return registration.waiting !== null; - } catch (error) { - console.error('PWA: Error checking for updates:', error); - return false; - } -} - -// Skip waiting and activate new service worker -export async function activateUpdate(): Promise { - const registration = get(serviceWorkerRegistrationStore); - - if (!registration || !registration.waiting) { - return; - } - - // Tell the waiting service worker to activate - registration.waiting.postMessage({ type: 'SKIP_WAITING' }); - - // Reload the page once the new service worker is activated - navigator.serviceWorker.addEventListener('controllerchange', () => { - window.location.reload(); - }); -} diff --git a/apps/uload/apps/web/src/lib/qrcode.ts b/apps/uload/apps/web/src/lib/qrcode.ts deleted file mode 100644 index b216bd946..000000000 --- a/apps/uload/apps/web/src/lib/qrcode.ts +++ /dev/null @@ -1,175 +0,0 @@ -export type QRCodeColor = 'black' | 'white' | 'gold'; -export type QRCodeFormat = 'png' | 'svg' | 'jpg'; -export type QRCodeRotation = 0 | 45 | 90 | 135 | 180 | 225 | 270 | 315; - -export const QR_COLORS = { - black: { color: '000000', bg: 'ffffff' }, - white: { color: 'ffffff', bg: '000000' }, - gold: { color: 'f8d62b', bg: '000000' }, -}; - -export function generateQRCodeURL( - text: string, - size: number = 200, - color: QRCodeColor = 'black', - format: QRCodeFormat = 'png' -): string { - const encodedText = encodeURIComponent(text); - const colorConfig = QR_COLORS[color]; - return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodedText}&color=${colorConfig.color}&bgcolor=${colorConfig.bg}&format=${format}`; -} - -export function generateQRCodeSVG( - text: string, - size: number = 200, - color: QRCodeColor = 'black' -): string { - return generateQRCodeURL(text, size, color, 'svg'); -} - -export function generateQRCodeDataURL( - text: string, - size: number = 200, - color: QRCodeColor = 'black' -): string { - return generateQRCodeURL(text, size, color, 'png'); -} - -export function createQRCodeElement( - text: string, - size: number = 200, - color: QRCodeColor = 'black' -): HTMLImageElement { - const img = new Image(); - img.src = generateQRCodeURL(text, size, color, 'png'); - img.width = size; - img.height = size; - img.alt = 'QR Code'; - return img; -} - -export async function downloadQRCode( - text: string, - filename: string = 'qrcode', - size: number = 400, - color: QRCodeColor = 'black', - format: QRCodeFormat = 'png', - rotation: QRCodeRotation = 0 -) { - const url = generateQRCodeURL(text, size, color, format); - const fullFilename = `${filename}.${format}`; - - if (format === 'svg') { - // Handle SVG with or without rotation - fetch(url) - .then((response) => response.text()) - .then((svgText) => { - let finalSvg = svgText; - - if (rotation !== 0) { - // Apply rotation transform to SVG - const parser = new DOMParser(); - const doc = parser.parseFromString(svgText, 'image/svg+xml'); - const svgElement = doc.documentElement; - - // Get original dimensions - const width = parseInt(svgElement.getAttribute('width') || `${size}`); - const height = parseInt(svgElement.getAttribute('height') || `${size}`); - - // Calculate new dimensions for rotated SVG - const radians = (rotation * Math.PI) / 180; - const sin = Math.abs(Math.sin(radians)); - const cos = Math.abs(Math.cos(radians)); - const newWidth = Math.round(width * cos + height * sin); - const newHeight = Math.round(width * sin + height * cos); - - // Update SVG dimensions - svgElement.setAttribute('width', `${newWidth}`); - svgElement.setAttribute('height', `${newHeight}`); - - // Add a group with rotation transform - const g = doc.createElementNS('http://www.w3.org/2000/svg', 'g'); - g.setAttribute( - 'transform', - `translate(${newWidth / 2},${newHeight / 2}) rotate(${rotation}) translate(${-width / 2},${-height / 2})` - ); - - // Move all existing children into the group - while (svgElement.firstChild) { - g.appendChild(svgElement.firstChild); - } - svgElement.appendChild(g); - - // Serialize back to string - const serializer = new XMLSerializer(); - finalSvg = serializer.serializeToString(doc); - } - - const blob = new Blob([finalSvg], { type: 'image/svg+xml' }); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = objectUrl; - a.download = fullFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(objectUrl); - }); - } else if (rotation === 0) { - // No rotation needed for PNG/JPG - fetch(url) - .then((response) => response.blob()) - .then((blob) => { - const a = document.createElement('a'); - const objectUrl = URL.createObjectURL(blob); - a.href = objectUrl; - a.download = fullFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(objectUrl); - }); - } else { - // Apply rotation using canvas for PNG/JPG - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.src = url; - - img.onload = () => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Calculate new dimensions for rotated image - const radians = (rotation * Math.PI) / 180; - const sin = Math.abs(Math.sin(radians)); - const cos = Math.abs(Math.cos(radians)); - - const newWidth = Math.round(img.width * cos + img.height * sin); - const newHeight = Math.round(img.width * sin + img.height * cos); - - canvas.width = newWidth; - canvas.height = newHeight; - - // Apply rotation - ctx.translate(newWidth / 2, newHeight / 2); - ctx.rotate(radians); - ctx.drawImage(img, -img.width / 2, -img.height / 2); - - // Convert and download - const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png'; - canvas.toBlob((blob) => { - if (blob) { - const a = document.createElement('a'); - const objectUrl = URL.createObjectURL(blob); - a.href = objectUrl; - a.download = fullFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(objectUrl); - } - }, mimeType); - }; - } -} diff --git a/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts b/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts deleted file mode 100644 index 96aff9056..000000000 --- a/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { z } from 'zod'; - -// Base schemas -export const RenderModeSchema = z.enum(['beginner', 'advanced', 'expert']); - -export const CardVariantSchema = z.enum([ - 'default', - 'compact', - 'hero', - 'minimal', - 'glass', - 'gradient', -]); - -// Metadata schema -export const CardMetadataSchema = z.object({ - name: z.string().max(100).optional(), - description: z.string().max(500).optional(), - author: z.string().optional(), - version: z.string().optional(), - created: z.string().datetime().optional(), - updated: z.string().datetime().optional(), - tags: z.array(z.string()).max(10).optional(), - page: z.string().optional(), - position: z.number().nonnegative().optional(), - isActive: z.boolean().optional(), - isPublic: z.boolean().optional(), -}); - -// Constraints schema -export const CardConstraintsSchema = z.object({ - aspectRatio: z - .string() - .regex(/^(\d+\/\d+|auto)$/) - .optional(), - maxWidth: z.string().optional(), - minHeight: z.string().optional(), - maxHeight: z.string().optional(), - maxModules: z.number().min(1).max(50).optional(), - maxHTMLSize: z.number().min(1000).max(200000).optional(), - maxCSSSize: z.number().min(1000).max(100000).optional(), -}); - -// Theme schema -export const ThemeSchema = z.object({ - id: z.string().optional(), - name: z.string().optional(), - colors: z.record(z.string(), z.string()).optional(), - typography: z - .object({ - fontFamily: z.string().optional(), - fontSize: z.record(z.string(), z.string()).optional(), - fontWeight: z.record(z.string(), z.number()).optional(), - lineHeight: z.record(z.string(), z.string()).optional(), - }) - .optional(), - spacing: z.record(z.string(), z.string()).optional(), - borderRadius: z.record(z.string(), z.string()).optional(), - shadows: z.record(z.string(), z.string()).optional(), -}); - -// Module schema -export const ModuleSchema = z.object({ - id: z.string(), - type: z.enum(['header', 'content', 'footer', 'media', 'stats', 'actions', 'links', 'custom']), - props: z.record(z.string(), z.any()), - order: z.number(), - visibility: z.enum(['always', 'desktop', 'mobile']).optional(), - grid: z - .object({ - col: z.number().optional(), - row: z.number().optional(), - colSpan: z.number().optional(), - rowSpan: z.number().optional(), - }) - .optional(), - className: z.string().optional(), -}); - -// Template variable schema -export const TemplateVariableSchema = z.object({ - name: z.string(), - type: z.enum(['text', 'number', 'image', 'link', 'list', 'boolean', 'color']), - label: z.string(), - default: z.any().optional(), - required: z.boolean().optional(), - placeholder: z.string().optional(), - options: z - .array( - z.object({ - label: z.string(), - value: z.any(), - }) - ) - .optional(), -}); - -// Card configuration schemas (discriminated union) -export const BeginnerConfigSchema = z.object({ - mode: z.literal('beginner'), - modules: z.array(ModuleSchema).min(1).max(20), - theme: ThemeSchema.optional(), - layout: z - .object({ - columns: z.number().min(1).max(4).optional(), - gap: z.string().optional(), - padding: z.string().optional(), - }) - .optional(), - animations: z - .object({ - hover: z.boolean().optional(), - entrance: z.enum(['fade', 'slide', 'scale', 'none']).optional(), - }) - .optional(), -}); - -export const AdvancedConfigSchema = z.object({ - mode: z.literal('advanced'), - template: z.string().min(1).max(100000), - css: z.string().max(50000).optional(), - variables: z.array(TemplateVariableSchema), - values: z.record(z.string(), z.any()), -}); - -export const ExpertConfigSchema = z.object({ - mode: z.literal('expert'), - html: z.string().min(1).max(100000), - css: z.string().min(1).max(50000), - javascript: z.string().optional(), // Note: Will be rejected in validation -}); - -export const CardConfigSchema = z.discriminatedUnion('mode', [ - BeginnerConfigSchema, - AdvancedConfigSchema, - ExpertConfigSchema, -]); - -// Main Card schema -export const CardSchema = z.object({ - id: z.string(), - config: CardConfigSchema, - metadata: CardMetadataSchema, - constraints: CardConstraintsSchema, - variant: CardVariantSchema.optional(), -}); - -// Database Card schema -export const DBCardSchema = z.object({ - id: z.string(), - user_id: z.string(), - config: z.string(), // JSON string - metadata: z.string(), // JSON string - constraints: z.string(), // JSON string - variant: z.string().optional(), - created: z.string().datetime(), - updated: z.string().datetime(), -}); - -// Module Props schemas -export const HeaderModulePropsSchema = z.object({ - title: z.string().optional(), - subtitle: z.string().optional(), - avatar: z.string().url().optional(), - badge: z.string().optional(), - icon: z.string().optional(), -}); - -export const ContentModulePropsSchema = z.object({ - text: z.string().optional(), - html: z.string().optional(), - truncate: z.boolean().optional(), - maxLines: z.number().optional(), -}); - -export const LinksModulePropsSchema = z.object({ - links: z.array( - z.object({ - label: z.string(), - href: z.string(), - icon: z.string().optional(), - description: z.string().optional(), - }) - ), - style: z.enum(['button', 'list', 'card']).optional(), - columns: z.literal(1).or(z.literal(2)).optional(), - target: z.enum(['_blank', '_self']).optional(), -}); - -export const MediaModulePropsSchema = z.object({ - type: z.enum(['image', 'video', 'qr']), - src: z.string().optional(), - alt: z.string().optional(), - aspectRatio: z.string().optional(), - qrData: z.string().optional(), -}); - -export const StatsModulePropsSchema = z.object({ - stats: z.array( - z.object({ - label: z.string(), - value: z.union([z.string(), z.number()]), - change: z.number().optional(), - icon: z.string().optional(), - }) - ), - layout: z.enum(['grid', 'list']).optional(), -}); - -export const ActionsModulePropsSchema = z.object({ - actions: z.array( - z.object({ - label: z.string(), - href: z.string().optional(), - variant: z.enum(['primary', 'secondary', 'ghost']).optional(), - icon: z.string().optional(), - }) - ), - layout: z.enum(['horizontal', 'vertical']).optional(), -}); - -export const FooterModulePropsSchema = z.object({ - text: z.string().optional(), - links: z - .array( - z.object({ - label: z.string(), - href: z.string(), - }) - ) - .optional(), - copyright: z.string().optional(), -}); - -// Validation helpers -export function validateCard(data: unknown) { - return CardSchema.safeParse(data); -} - -export function validateCardConfig(data: unknown) { - return CardConfigSchema.safeParse(data); -} - -export function validateModule(data: unknown) { - return ModuleSchema.safeParse(data); -} - -// Type exports -export type Card = z.infer; -export type CardConfig = z.infer; -export type CardMetadata = z.infer; -export type CardConstraints = z.infer; -export type Module = z.infer; -export type Theme = z.infer; -export type RenderMode = z.infer; -export type CardVariant = z.infer; diff --git a/apps/uload/apps/web/src/lib/scripts/update-links-collection.js b/apps/uload/apps/web/src/lib/scripts/update-links-collection.js deleted file mode 100644 index e70a76b45..000000000 --- a/apps/uload/apps/web/src/lib/scripts/update-links-collection.js +++ /dev/null @@ -1,87 +0,0 @@ -// Script to add account_owner field to links collection -// Run this with: node src/lib/scripts/update-links-collection.js - -import PocketBase from 'pocketbase'; - -// Use environment variable or fallback to production -const POCKETBASE_URL = process.env.PUBLIC_POCKETBASE_URL || 'https://pb.ulo.ad'; -const pb = new PocketBase(POCKETBASE_URL); - -console.log(`Connecting to PocketBase at: ${POCKETBASE_URL}`); - -// You'll need to authenticate as admin first -// This is just a placeholder - do not commit real credentials -const ADMIN_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL; -const ADMIN_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD; - -async function updateLinksCollection() { - try { - // Authenticate as admin - await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); - console.log('✅ Authenticated as admin'); - - // Get the current links collection - const collection = await pb.collections.getOne('links'); - console.log('✅ Retrieved links collection'); - - // Add account_owner field to the existing fields - const updatedFields = [...collection.fields]; - - // Check if account_owner already exists - const hasAccountOwner = updatedFields.some((f) => f.name === 'account_owner'); - if (!hasAccountOwner) { - // Insert account_owner field after user_id - const userIdIndex = updatedFields.findIndex((f) => f.name === 'user_id'); - updatedFields.splice(userIdIndex + 1, 0, { - name: 'account_owner', - type: 'relation', - required: false, - collectionId: '_pb_users_auth_', - cascadeDelete: false, - maxSelect: 1, - minSelect: 0, - }); - } - - // Update the collection - await pb.collections.update('links', { - ...collection, - fields: updatedFields, - // Update rules to include account_owner checks - listRule: - 'user_id = @request.auth.id || created_by = @request.auth.id || account_owner = @request.auth.id || is_active = true', - viewRule: - 'user_id = @request.auth.id || created_by = @request.auth.id || account_owner = @request.auth.id || is_active = true', - updateRule: - 'created_by = @request.auth.id || (account_owner = @request.auth.id && created_by = @request.auth.id)', - deleteRule: - 'created_by = @request.auth.id || (account_owner = @request.auth.id && created_by = @request.auth.id)', - }); - - console.log('✅ Successfully updated links collection with account_owner field'); - - // Migrate existing data: set account_owner = user_id for all existing links - console.log('🔄 Migrating existing links...'); - - const allLinks = await pb.collection('links').getFullList(); - let migrated = 0; - - for (const link of allLinks) { - if (!link.account_owner && link.user_id) { - await pb.collection('links').update(link.id, { - account_owner: link.user_id, - created_by: link.created_by || link.user_id, - }); - migrated++; - } - } - - console.log(`✅ Migrated ${migrated} existing links`); - } catch (error) { - console.error('❌ Error:', error); - process.exit(1); - } -} - -// Run the update -updateLinksCollection(); diff --git a/apps/uload/apps/web/src/lib/security/totp.ts b/apps/uload/apps/web/src/lib/security/totp.ts deleted file mode 100644 index bebf8a140..000000000 --- a/apps/uload/apps/web/src/lib/security/totp.ts +++ /dev/null @@ -1,284 +0,0 @@ -// TOTP (Time-based One-Time Password) Implementation für 2FA -// Verwendet RFC 6238 Standard - -import { createHmac } from 'crypto'; - -// TOTP Configuration -export interface TOTPConfig { - secret: string; - window?: number; // Zeitfenster in 30-Sekunden-Schritten (default: 1) - digits?: number; // Anzahl Ziffern (default: 6) - period?: number; // Zeitperiode in Sekunden (default: 30) - algorithm?: 'sha1' | 'sha256' | 'sha512'; // Hash-Algorithmus (default: sha1) -} - -export interface TOTPResult { - token: string; - timeRemaining: number; - window: number; -} - -// Base32 Encoding/Decoding für Secrets -const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; -const BASE32_MAP: { [key: string]: number } = {}; -for (let i = 0; i < BASE32_CHARS.length; i++) { - BASE32_MAP[BASE32_CHARS[i]] = i; -} - -function base32Decode(encoded: string): Buffer { - encoded = encoded.replace(/=+$/, '').toUpperCase(); - let bits = 0; - let value = 0; - let output = Buffer.alloc(Math.ceil((encoded.length * 5) / 8)); - let index = 0; - - for (const char of encoded) { - value = (value << 5) | BASE32_MAP[char]; - bits += 5; - - if (bits >= 8) { - output[index++] = (value >>> (bits - 8)) & 255; - bits -= 8; - } - } - - return output.slice(0, index); -} - -function base32Encode(buffer: Buffer): string { - let encoded = ''; - let bits = 0; - let value = 0; - - for (const byte of buffer) { - value = (value << 8) | byte; - bits += 8; - - while (bits >= 5) { - encoded += BASE32_CHARS[(value >>> (bits - 5)) & 31]; - bits -= 5; - } - } - - if (bits > 0) { - encoded += BASE32_CHARS[(value << (5 - bits)) & 31]; - } - - // Padding hinzufügen - while (encoded.length % 8 !== 0) { - encoded += '='; - } - - return encoded; -} - -// Secret generieren -export function generateSecret(length: number = 32): string { - const buffer = Buffer.alloc(length); - - // Sichere Zufallsbytes generieren - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - // Browser - const array = new Uint8Array(length); - crypto.getRandomValues(array); - return base32Encode(Buffer.from(array)); - } else { - // Node.js - const { randomBytes } = require('crypto'); - return base32Encode(randomBytes(length)); - } -} - -// Aktuellen Zeitslot berechnen -function getCurrentTimeSlot(period: number = 30): number { - return Math.floor(Date.now() / 1000 / period); -} - -// HMAC-basierte OTP generieren -function generateHOTP( - secret: string, - counter: number, - digits: number = 6, - algorithm: string = 'sha1' -): string { - const key = base32Decode(secret); - - // Counter als 8-Byte Big-Endian Buffer - const counterBuffer = Buffer.alloc(8); - counterBuffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0); - counterBuffer.writeUInt32BE(counter & 0xffffffff, 4); - - // HMAC berechnen - const hmac = createHmac(algorithm, key); - hmac.update(counterBuffer); - const hash = hmac.digest(); - - // Dynamic truncation (RFC 4226) - const offset = hash[hash.length - 1] & 0x0f; - const code = - ((hash[offset] & 0x7f) << 24) | - ((hash[offset + 1] & 0xff) << 16) | - ((hash[offset + 2] & 0xff) << 8) | - (hash[offset + 3] & 0xff); - - // Auf gewünschte Anzahl Ziffern reduzieren - const otp = (code % Math.pow(10, digits)).toString(); - return otp.padStart(digits, '0'); -} - -// TOTP Token generieren -export function generateTOTP(config: TOTPConfig): TOTPResult { - const { secret, digits = 6, period = 30, algorithm = 'sha1' } = config; - - const timeSlot = getCurrentTimeSlot(period); - const token = generateHOTP(secret, timeSlot, digits, algorithm); - - // Verbleibende Zeit bis zum nächsten Token - const timeRemaining = period - (Math.floor(Date.now() / 1000) % period); - - return { - token, - timeRemaining, - window: timeSlot, - }; -} - -// TOTP Token verifizieren -export function verifyTOTP(token: string, config: TOTPConfig): boolean { - const { secret, window = 1, digits = 6, period = 30, algorithm = 'sha1' } = config; - - const currentTimeSlot = getCurrentTimeSlot(period); - - // Prüfe aktuelles und benachbarte Zeitfenster - for (let i = -window; i <= window; i++) { - const timeSlot = currentTimeSlot + i; - const expectedToken = generateHOTP(secret, timeSlot, digits, algorithm); - - if (constantTimeEquals(token, expectedToken)) { - return true; - } - } - - return false; -} - -// Constant-time string comparison (verhindert Timing-Angriffe) -function constantTimeEquals(a: string, b: string): boolean { - if (a.length !== b.length) { - return false; - } - - let result = 0; - for (let i = 0; i < a.length; i++) { - result |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - - return result === 0; -} - -// QR Code URL für Authenticator Apps generieren -export function generateQRCodeURL( - secret: string, - accountName: string, - issuer: string = 'uLoad', - algorithm: string = 'SHA1', - digits: number = 6, - period: number = 30 -): string { - const params = new URLSearchParams({ - secret, - issuer, - algorithm, - digits: digits.toString(), - period: period.toString(), - }); - - return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?${params}`; -} - -// Backup Codes generieren -export function generateBackupCodes(count: number = 10): string[] { - const codes: string[] = []; - - for (let i = 0; i < count; i++) { - // 8-stellige Backup-Codes generieren - let code = ''; - for (let j = 0; j < 8; j++) { - code += Math.floor(Math.random() * 10).toString(); - } - // Formatierung: XXXX-XXXX - codes.push(`${code.slice(0, 4)}-${code.slice(4, 8)}`); - } - - return codes; -} - -// Backup Code validieren und als verbraucht markieren -export function validateBackupCode( - code: string, - availableCodes: string[] -): { isValid: boolean; remainingCodes: string[] } { - const normalizedCode = code.replace(/[-\s]/g, ''); - const codeIndex = availableCodes.findIndex( - (availableCode) => availableCode.replace(/[-\s]/g, '') === normalizedCode - ); - - if (codeIndex === -1) { - return { isValid: false, remainingCodes: availableCodes }; - } - - // Code entfernen (als verbraucht markieren) - const remainingCodes = [...availableCodes]; - remainingCodes.splice(codeIndex, 1); - - return { isValid: true, remainingCodes }; -} - -// Hilfsfunktionen für UI -export function formatTOTPToken(token: string): string { - // Format: XXX XXX - if (token.length === 6) { - return `${token.slice(0, 3)} ${token.slice(3, 6)}`; - } - return token; -} - -export function formatBackupCode(code: string): string { - // Format: XXXX-XXXX - const cleanCode = code.replace(/[-\s]/g, ''); - if (cleanCode.length === 8) { - return `${cleanCode.slice(0, 4)}-${cleanCode.slice(4, 8)}`; - } - return code; -} - -// Secret für sichere Speicherung verschlüsseln (vereinfacht) -export function encryptSecret(secret: string, password: string): string { - // In Produktion sollte eine robuste Verschlüsselung verwendet werden - // Dies ist nur ein Beispiel - verwende crypto.subtle.encrypt() oder ähnliches - const encoder = new TextEncoder(); - const data = encoder.encode(secret); - const key = encoder.encode(password.padEnd(32, '0').slice(0, 32)); - - // XOR-basierte "Verschlüsselung" (NUR FÜR DEMO!) - const encrypted = new Uint8Array(data.length); - for (let i = 0; i < data.length; i++) { - encrypted[i] = data[i] ^ key[i % key.length]; - } - - return Buffer.from(encrypted).toString('base64'); -} - -export function decryptSecret(encryptedSecret: string, password: string): string { - // Entsprechende Entschlüsselung (NUR FÜR DEMO!) - const encoder = new TextEncoder(); - const data = Buffer.from(encryptedSecret, 'base64'); - const key = encoder.encode(password.padEnd(32, '0').slice(0, 32)); - - const decrypted = new Uint8Array(data.length); - for (let i = 0; i < data.length; i++) { - decrypted[i] = data[i] ^ key[i % key.length]; - } - - return new TextDecoder().decode(decrypted); -} diff --git a/apps/uload/apps/web/src/lib/services/cardConverter.ts b/apps/uload/apps/web/src/lib/services/cardConverter.ts deleted file mode 100644 index 5681d7d4d..000000000 --- a/apps/uload/apps/web/src/lib/services/cardConverter.ts +++ /dev/null @@ -1,494 +0,0 @@ -import type { CardConfig, Module, TemplateVariable } from '$lib/components/cards/types'; -import { cardSanitizer } from './cardSanitizer'; - -class CardConverter { - /** - * Convert any card config to modular (beginner) format - */ - async toModular(config: CardConfig): Promise> { - if (config.mode === 'beginner') { - return config; - } - - if (config.mode === 'advanced') { - return this.templateToModular(config); - } - - if (config.mode === 'expert') { - return this.customToModular(config); - } - - throw new Error(`Unknown card mode: ${(config as any).mode}`); - } - - /** - * Convert any card config to template (advanced) format - */ - async toTemplate(config: CardConfig): Promise> { - if (config.mode === 'advanced') { - return config; - } - - if (config.mode === 'beginner') { - return this.modularToTemplate(config); - } - - if (config.mode === 'expert') { - return this.customToTemplate(config); - } - - throw new Error(`Unknown card mode: ${(config as any).mode}`); - } - - /** - * Convert any card config to custom (expert) format - */ - async toCustom(config: CardConfig): Promise> { - if (config.mode === 'expert') { - return config; - } - - if (config.mode === 'beginner') { - return this.modularToCustom(config); - } - - if (config.mode === 'advanced') { - return this.templateToCustom(config); - } - - throw new Error(`Unknown card mode: ${(config as any).mode}`); - } - - /** - * Convert template to modular format - */ - private async templateToModular( - config: Extract - ): Promise> { - const modules: Module[] = []; - const parser = new DOMParser(); - const doc = parser.parseFromString(config.template, 'text/html'); - - // Analyze HTML structure and extract modules - let order = 0; - - // Check for headers - const headers = doc.querySelectorAll('h1, h2, h3'); - if (headers.length > 0) { - const header = headers[0]; - const subtitle = - header.nextElementSibling?.tagName === 'P' ? header.nextElementSibling.textContent : ''; - - modules.push({ - id: `header_${order++}`, - type: 'header', - props: { - title: header.textContent || '', - subtitle: subtitle || '', - }, - order, - }); - } - - // Check for images - const images = doc.querySelectorAll('img'); - images.forEach((img) => { - modules.push({ - id: `media_${order++}`, - type: 'media', - props: { - type: 'image', - src: img.getAttribute('src') || '', - alt: img.getAttribute('alt') || '', - }, - order, - }); - }); - - // Check for links - const links = doc.querySelectorAll('a'); - if (links.length > 0) { - const linkItems = Array.from(links).map((link) => ({ - label: link.textContent || '', - href: link.getAttribute('href') || '#', - icon: '', - })); - - modules.push({ - id: `links_${order++}`, - type: 'links', - props: { - links: linkItems, - style: 'button', - }, - order, - }); - } - - // Check for remaining content - const paragraphs = doc.querySelectorAll('p, div'); - if (paragraphs.length > 0) { - const content = Array.from(paragraphs) - .map((p) => p.textContent) - .filter((text) => text && text.trim()) - .join('\n\n'); - - if (content) { - modules.push({ - id: `content_${order++}`, - type: 'content', - props: { - text: content, - }, - order, - }); - } - } - - return { - mode: 'beginner', - modules, - theme: this.extractThemeFromCSS(config.css), - layout: { - columns: 1, - gap: '1rem', - padding: '1.5rem', - }, - }; - } - - /** - * Convert custom HTML to modular format - */ - private async customToModular( - config: Extract - ): Promise> { - // Similar to templateToModular but without variables - const templateConfig: Extract = { - mode: 'advanced', - template: config.html, - css: config.css, - variables: [], - values: {}, - }; - - return this.templateToModular(templateConfig); - } - - /** - * Convert modular to template format - */ - private modularToTemplate( - config: Extract - ): Extract { - let template = '
\n'; - const variables: TemplateVariable[] = []; - const values: Record = {}; - - // Convert each module to template HTML - config.modules.forEach((module) => { - switch (module.type) { - case 'header': - if (module.props.title) { - template += `

{{title}}

\n`; - variables.push({ - name: 'title', - type: 'text', - label: 'Title', - default: module.props.title, - }); - values.title = module.props.title; - } - if (module.props.subtitle) { - template += `

{{subtitle}}

\n`; - variables.push({ - name: 'subtitle', - type: 'text', - label: 'Subtitle', - default: module.props.subtitle, - }); - values.subtitle = module.props.subtitle; - } - break; - - case 'content': - template += `
{{content}}
\n`; - variables.push({ - name: 'content', - type: 'text', - label: 'Content', - default: module.props.text || module.props.html, - }); - values.content = module.props.text || module.props.html; - break; - - case 'media': - if (module.props.type === 'image') { - template += ` {{image_alt}}\n`; - variables.push( - { - name: 'image_url', - type: 'image', - label: 'Image URL', - default: module.props.src, - }, - { - name: 'image_alt', - type: 'text', - label: 'Image Alt Text', - default: module.props.alt, - } - ); - values.image_url = module.props.src; - values.image_alt = module.props.alt; - } - break; - - case 'links': - template += ` \n`; - break; - - case 'stats': - template += `
\n`; - module.props.stats?.forEach((stat: any, index: number) => { - template += `
\n`; - template += ` {{stat${index}_value}}\n`; - template += ` {{stat${index}_label}}\n`; - template += `
\n`; - variables.push( - { - name: `stat${index}_value`, - type: 'number', - label: `Stat ${index + 1} Value`, - default: stat.value, - }, - { - name: `stat${index}_label`, - type: 'text', - label: `Stat ${index + 1} Label`, - default: stat.label, - } - ); - values[`stat${index}_value`] = stat.value; - values[`stat${index}_label`] = stat.label; - }); - template += `
\n`; - break; - } - }); - - template += '
'; - - // Generate CSS from theme - const css = this.generateCSSFromTheme(config.theme); - - return { - mode: 'advanced', - template, - css, - variables, - values, - }; - } - - /** - * Convert custom HTML to template format - */ - private async customToTemplate( - config: Extract - ): Promise> { - // Extract variables from HTML - const variables = cardSanitizer.extractVariables(config.html); - const values: Record = {}; - - // Set default values - variables.forEach((variable) => { - values[variable.name] = variable.default || ''; - }); - - return { - mode: 'advanced', - template: config.html, - css: config.css, - variables, - values, - }; - } - - /** - * Convert modular to custom HTML format - */ - private modularToCustom( - config: Extract - ): Extract { - // First convert to template - const templateConfig = this.modularToTemplate(config); - - // Then replace variables with actual values - let html = cardSanitizer.replaceVariables(templateConfig.template, templateConfig.values); - - return { - mode: 'expert', - html, - css: templateConfig.css || '', - }; - } - - /** - * Convert template to custom HTML format - */ - private templateToCustom( - config: Extract - ): Extract { - // Replace variables with actual values - const html = cardSanitizer.replaceVariables(config.template, config.values); - - return { - mode: 'expert', - html, - css: config.css || '', - }; - } - - /** - * Extract theme from CSS - */ - private extractThemeFromCSS(css?: string): any { - if (!css) return undefined; - - const theme: any = { - colors: {}, - }; - - // Extract color variables - const colorRegex = /--([^:]+):\s*([^;]+);/g; - let match; - while ((match = colorRegex.exec(css)) !== null) { - const varName = match[1].trim(); - const value = match[2].trim(); - if (varName.includes('color') || varName.includes('bg')) { - theme.colors[varName] = value; - } - } - - return Object.keys(theme.colors).length > 0 ? theme : undefined; - } - - /** - * Generate CSS from theme - */ - private generateCSSFromTheme(theme?: any): string { - let css = ` -.card-content { - padding: 1.5rem; - height: 100%; -} - -h2 { - margin-bottom: 0.5rem; - font-size: 1.5rem; - font-weight: 600; - color: var(--text-primary, #1f2937); -} - -.subtitle { - color: var(--text-muted, #6b7280); - margin-bottom: 1rem; -} - -.content { - margin: 1rem 0; - line-height: 1.6; - color: var(--text-primary, #1f2937); -} - -.links { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; -} - -.link-button { - padding: 0.5rem 1rem; - background: var(--primary, #3b82f6); - color: white; - text-decoration: none; - border-radius: 0.375rem; - transition: background 0.2s; -} - -.link-button:hover { - background: var(--primary-dark, #2563eb); -} - -.media-image { - width: 100%; - height: auto; - border-radius: 0.5rem; - margin: 1rem 0; -} - -.stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 1rem; - margin: 1rem 0; -} - -.stat-item { - text-align: center; - padding: 1rem; - background: var(--bg-secondary, #f9fafb); - border-radius: 0.5rem; -} - -.stat-value { - display: block; - font-size: 1.5rem; - font-weight: 600; - color: var(--primary, #3b82f6); -} - -.stat-label { - display: block; - font-size: 0.875rem; - color: var(--text-muted, #6b7280); - margin-top: 0.25rem; -}`; - - // Add theme variables if available - if (theme?.colors) { - const vars = Object.entries(theme.colors) - .map(([key, value]) => ` --${key}: ${value};`) - .join('\n'); - - css = `:root {\n${vars}\n}\n\n${css}`; - } - - return css; - } -} - -// Export singleton instance -export const cardConverter = new CardConverter(); diff --git a/apps/uload/apps/web/src/lib/services/cardSanitizer.ts b/apps/uload/apps/web/src/lib/services/cardSanitizer.ts deleted file mode 100644 index 076e404ab..000000000 --- a/apps/uload/apps/web/src/lib/services/cardSanitizer.ts +++ /dev/null @@ -1,400 +0,0 @@ -import DOMPurify from 'isomorphic-dompurify'; -import type { CardConstraints, TemplateVariable } from '$lib/components/cards/types'; - -export interface SanitizationOptions { - allowedTags?: string[]; - allowedAttributes?: Record; - allowedStyles?: string[]; - maxNesting?: number; - removeScripts?: boolean; - removeEventHandlers?: boolean; - removeImports?: boolean; -} - -export class CardSanitizer { - private domPurify = DOMPurify; - - /** - * Sanitize HTML content for safe rendering - */ - sanitizeHTML(html: string, options?: SanitizationOptions): string { - const defaultOptions: SanitizationOptions = { - allowedTags: [ - 'div', - 'span', - 'p', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'a', - 'img', - 'ul', - 'ol', - 'li', - 'strong', - 'em', - 'b', - 'i', - 'br', - 'hr', - 'blockquote', - 'pre', - 'code', - 'table', - 'thead', - 'tbody', - 'tr', - 'td', - 'th', - 'section', - 'article', - 'nav', - 'header', - 'footer', - 'aside', - 'main', - 'figure', - 'figcaption', - 'button', - 'svg', - 'path', - 'circle', - 'rect', - 'line', - 'polygon', - ], - allowedAttributes: { - '*': ['class', 'id', 'style'], - a: ['href', 'target', 'rel'], - img: ['src', 'alt', 'width', 'height'], - svg: ['viewBox', 'width', 'height', 'fill', 'stroke'], - path: ['d', 'fill', 'stroke', 'stroke-width'], - button: ['type', 'disabled'], - }, - removeScripts: true, - removeEventHandlers: true, - }; - - const mergedOptions = { ...defaultOptions, ...options }; - - // Configure DOMPurify - const config: any = { - ALLOWED_TAGS: mergedOptions.allowedTags, - ALLOWED_ATTR: [], - FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select'], - FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur'], - }; - - // Build allowed attributes list - if (mergedOptions.allowedAttributes) { - for (const [tag, attrs] of Object.entries(mergedOptions.allowedAttributes)) { - if (tag === '*') { - config.ALLOWED_ATTR.push(...attrs); - } else { - attrs.forEach((attr) => { - config.ALLOWED_ATTR.push(`${tag}:${attr}`); - }); - } - } - } - - // Sanitize HTML - const sanitized = this.domPurify.sanitize(html, config); - - // Convert to string and do additional cleaning for javascript: URLs - const result = String(sanitized); - return result.replace(/javascript:/gi, '').replace(/on\w+\s*=/gi, ''); - } - - /** - * Sanitize CSS content for safe rendering - */ - sanitizeCSS(css: string, options?: SanitizationOptions): string { - const defaultOptions: SanitizationOptions = { - removeImports: true, - allowedStyles: [ - 'color', - 'background', - 'background-color', - 'background-image', - 'border', - 'border-radius', - 'border-color', - 'border-width', - 'padding', - 'margin', - 'width', - 'height', - 'max-width', - 'max-height', - 'min-width', - 'min-height', - 'display', - 'flex', - 'grid', - 'position', - 'top', - 'bottom', - 'left', - 'right', - 'font-size', - 'font-family', - 'font-weight', - 'text-align', - 'text-decoration', - 'line-height', - 'letter-spacing', - 'opacity', - 'visibility', - 'z-index', - 'overflow', - 'transform', - 'transition', - 'animation', - 'box-shadow', - 'cursor', - 'pointer-events', - ], - maxNesting: 3, - }; - - const mergedOptions = { ...defaultOptions, ...options }; - - let sanitized = css; - - // Remove @import statements - if (mergedOptions.removeImports) { - sanitized = sanitized.replace(/@import\s+[^;]+;/gi, ''); - } - - // Remove javascript in CSS - sanitized = sanitized.replace(/javascript:/gi, ''); - sanitized = sanitized.replace(/expression\s*\(/gi, ''); - sanitized = sanitized.replace(/behavior\s*:/gi, ''); - sanitized = sanitized.replace(/-moz-binding\s*:/gi, ''); - - // Remove external URLs (except for safe properties like background-image) - sanitized = sanitized.replace( - /url\s*\(\s*['"]?(?!data:)(?!https:\/\/[^'"]+\.(jpg|jpeg|png|gif|svg|webp))/gi, - 'url(' - ); - - // Limit selector complexity (prevent performance issues) - const lines = sanitized.split('\n'); - const processedLines = lines.map((line) => { - // Count selector depth - const selectorDepth = (line.match(/\s+/g) || []).length; - if (selectorDepth > (mergedOptions.maxNesting || 3)) { - return '/* Selector too deeply nested */'; - } - return line; - }); - - sanitized = processedLines.join('\n'); - - // Remove potentially dangerous properties - const dangerousProperties = ['behavior', '-moz-binding', 'filter', 'content']; - - dangerousProperties.forEach((prop) => { - const regex = new RegExp(`${prop}\\s*:([^;]+);`, 'gi'); - sanitized = sanitized.replace(regex, ''); - }); - - return sanitized; - } - - /** - * Validate card constraints - */ - validateConstraints(html: string, constraints: CardConstraints): boolean { - if (!constraints) return true; - - // Create a temporary DOM element to analyze - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - - // Check for forbidden tags (if defined in constraints) - const allowedTags = (constraints as any).allowedTags; - if (allowedTags && Array.isArray(allowedTags)) { - const allTags = Array.from(doc.body.getElementsByTagName('*')); - for (const element of allTags) { - if (!allowedTags.includes(element.tagName.toLowerCase())) { - console.warn(`Forbidden tag found: ${element.tagName}`); - return false; - } - } - } - - // Check for scripts (should already be removed by sanitizer) - if (constraints.preventScripts) { - if (doc.querySelector('script')) { - console.warn('Script tags are not allowed'); - return false; - } - } - - return true; - } - - /** - * Extract template variables from HTML - */ - extractVariables(html: string): TemplateVariable[] { - const variables: TemplateVariable[] = []; - const seen = new Set(); - - // Match {{variable}} or {{variable:type}} - const regex = /\{\{(\w+)(?::(\w+))?\}\}/g; - let match; - - while ((match = regex.exec(html)) !== null) { - const name = match[1]; - const type = match[2] || 'text'; - - if (!seen.has(name)) { - seen.add(name); - variables.push({ - name, - type: type as any, - required: true, - label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' '), - }); - } - } - - return variables; - } - - /** - * Replace template variables with values - */ - replaceVariables(template: string, values: Record): string { - let result = template; - - // Replace {{variable}} patterns - Object.entries(values).forEach(([key, value]) => { - // Escape the value to prevent XSS - const escapedValue = this.escapeHtml(String(value || '')); - const regex = new RegExp(`\\{\\{${key}(?::\\w+)?\\}\\}`, 'g'); - result = result.replace(regex, escapedValue); - }); - - // Remove any remaining unmatched variables - result = result.replace(/\{\{\w+(?::\w+)?\}\}/g, ''); - - return result; - } - - /** - * Escape HTML characters - */ - private escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', - }; - return text.replace(/[&<>"'/]/g, (char) => map[char]); - } - - /** - * Create safe iframe content - */ - createSafeIframeContent(html: string, css: string, constraints?: CardConstraints): string { - const sanitizedHTML = this.sanitizeHTML(html); - const sanitizedCSS = this.sanitizeCSS(css); - - // Build CSP meta tag - const csp = ` - default-src 'none'; - style-src 'unsafe-inline'; - img-src data: https:; - font-src data: https:; - ` - .replace(/\s+/g, ' ') - .trim(); - - // Build the iframe content - const iframeContent = ` - - - - - - - - - -
- ${sanitizedHTML} -
- - - `; - - return iframeContent; - } - - /** - * Validate CSS property value - */ - private isValidCSSValue(property: string, value: string): boolean { - // Basic validation for common properties - const validPatterns: Record = { - color: /^(#[0-9a-f]{3,8}|rgb|rgba|hsl|hsla|[a-z]+)$/i, - width: /^(\d+(%|px|em|rem|vw|vh)|auto|inherit)$/i, - height: /^(\d+(%|px|em|rem|vw|vh)|auto|inherit)$/i, - 'font-size': /^(\d+(%|px|em|rem)|inherit)$/i, - margin: /^(\d+(%|px|em|rem)|auto|inherit)$/i, - padding: /^(\d+(%|px|em|rem)|inherit)$/i, - }; - - const pattern = validPatterns[property]; - if (pattern) { - return pattern.test(value.trim()); - } - - // Default: allow if no javascript - return !value.includes('javascript:') && !value.includes('expression('); - } -} - -// Export singleton instance -export const cardSanitizer = new CardSanitizer(); diff --git a/apps/uload/apps/web/src/lib/services/cardService.ts b/apps/uload/apps/web/src/lib/services/cardService.ts deleted file mode 100644 index 2ce8511d3..000000000 --- a/apps/uload/apps/web/src/lib/services/cardService.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { pb } from '$lib/pocketbase'; -import type { - Card, - CardConfig, - CardMetadata, - RenderMode, - DBCard, -} from '$lib/components/cards/types'; -import { cardConverter } from './cardConverter'; -import { cardValidator } from './cardValidator'; - -export class CardService { - /** - * Convert card between different modes - */ - async convertCard(card: Card, targetMode: RenderMode): Promise { - let newConfig: CardConfig; - - switch (targetMode) { - case 'beginner': - newConfig = await cardConverter.toModular(card.config); - break; - case 'advanced': - newConfig = await cardConverter.toTemplate(card.config); - break; - case 'expert': - newConfig = await cardConverter.toCustom(card.config); - break; - default: - throw new Error(`Unknown target mode: ${targetMode}`); - } - - return { - ...card, - config: newConfig, - }; - } - - /** - * Save card to database - */ - async saveCard(card: Card): Promise { - const userId = pb.authStore.model?.id; - if (!userId) throw new Error('User not authenticated'); - - // Validate card - const validation = cardValidator.validate(card); - if (!validation.valid) { - throw new Error(`Invalid card: ${validation.errors?.map((e) => e.message).join(', ')}`); - } - - const dbCard: Partial = { - user_id: userId, - config: JSON.stringify(card.config), - metadata: JSON.stringify(card.metadata), - constraints: JSON.stringify(card.constraints), - variant: card.variant, - }; - - let result; - if (card.id && card.id !== 'new') { - // Update existing - result = await pb.collection('cards').update(card.id, dbCard); - } else { - // Create new - result = await pb.collection('cards').create(dbCard); - } - - return result.id; - } - - /** - * Load card from database - */ - async loadCard(id: string): Promise { - try { - const dbCard = await pb.collection('cards').getOne(id); - return this.dbCardToCard(dbCard); - } catch (error) { - console.error('Error loading card:', error); - return null; - } - } - - /** - * Load user's cards - */ - async loadUserCards(filters?: { page?: string; limit?: number }): Promise { - const userId = pb.authStore.model?.id; - if (!userId) return []; - - let filter = `user_id = "${userId}"`; - if (filters?.page) { - filter += ` && metadata.page = "${filters.page}"`; - } - - const records = await pb.collection('cards').getList(1, filters?.limit || 100, { - filter, - sort: 'metadata.position,created', - }); - - return records.items.map((item) => this.dbCardToCard(item)); - } - - /** - * Delete card - */ - async deleteCard(id: string): Promise { - try { - await pb.collection('cards').delete(id); - return true; - } catch (error) { - console.error('Error deleting card:', error); - return false; - } - } - - /** - * Duplicate card - */ - async duplicateCard(card: Card): Promise { - const newCard: Card = { - ...card, - id: 'new', - metadata: { - ...card.metadata, - name: `${card.metadata?.name || 'Card'} (Copy)`, - created: new Date().toISOString(), - updated: new Date().toISOString(), - }, - }; - - const newId = await this.saveCard(newCard); - newCard.id = newId; - return newCard; - } - - /** - * Convert database card to Card type - */ - private dbCardToCard(dbCard: DBCard): Card { - return { - id: dbCard.id, - config: JSON.parse(dbCard.config), - metadata: JSON.parse(dbCard.metadata), - constraints: JSON.parse(dbCard.constraints || '{}'), - variant: dbCard.variant as any, - }; - } -} - -// Export singleton instance -export const cardService = new CardService(); diff --git a/apps/uload/apps/web/src/lib/services/cardValidator.ts b/apps/uload/apps/web/src/lib/services/cardValidator.ts deleted file mode 100644 index 24d0faaa3..000000000 --- a/apps/uload/apps/web/src/lib/services/cardValidator.ts +++ /dev/null @@ -1,454 +0,0 @@ -import type { Card, CardConfig, ValidationResult, Module } from '$lib/components/cards/types'; - -class CardValidator { - /** - * Validate a complete card - */ - validate(card: Card): ValidationResult { - const errors: Array<{ field: string; message: string }> = []; - - // Validate ID - if (!card.id || card.id.trim() === '') { - errors.push({ field: 'id', message: 'Card ID is required' }); - } - - // Validate config - const configErrors = this.validateConfig(card.config); - errors.push(...configErrors); - - // Validate constraints - const constraintErrors = this.validateConstraints(card); - errors.push(...constraintErrors); - - // Validate metadata - const metadataErrors = this.validateMetadata(card); - errors.push(...metadataErrors); - - return { - valid: errors.length === 0, - errors: errors.length > 0 ? errors : undefined, - }; - } - - /** - * Validate card configuration based on mode - */ - private validateConfig(config: CardConfig): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - if (!config.mode) { - errors.push({ field: 'config.mode', message: 'Card mode is required' }); - return errors; - } - - switch (config.mode) { - case 'beginner': - errors.push(...this.validateBeginnerConfig(config)); - break; - case 'advanced': - errors.push(...this.validateAdvancedConfig(config)); - break; - case 'expert': - errors.push(...this.validateExpertConfig(config)); - break; - default: - // This should never happen with proper TypeScript types, but kept for runtime safety - const _exhaustiveCheck: never = config; - errors.push({ field: 'config.mode', message: `Invalid mode: ${(config as any).mode}` }); - } - - return errors; - } - - /** - * Validate beginner mode configuration - */ - private validateBeginnerConfig( - config: Extract - ): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - // Validate modules - if (!Array.isArray(config.modules)) { - errors.push({ field: 'config.modules', message: 'Modules must be an array' }); - } else { - // Check module count - if (config.modules.length === 0) { - errors.push({ field: 'config.modules', message: 'At least one module is required' }); - } - if (config.modules.length > 20) { - errors.push({ field: 'config.modules', message: 'Maximum 20 modules allowed' }); - } - - // Validate each module - config.modules.forEach((module, index) => { - errors.push(...this.validateModule(module, index)); - }); - } - - // Validate layout - if (config.layout) { - if (config.layout.columns && (config.layout.columns < 1 || config.layout.columns > 4)) { - errors.push({ - field: 'config.layout.columns', - message: 'Columns must be between 1 and 4', - }); - } - } - - return errors; - } - - /** - * Validate a single module - */ - private validateModule(module: Module, index: number): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - const prefix = `config.modules[${index}]`; - - if (!module.id) { - errors.push({ field: `${prefix}.id`, message: 'Module ID is required' }); - } - - if (!module.type) { - errors.push({ field: `${prefix}.type`, message: 'Module type is required' }); - } else { - const validTypes = [ - 'header', - 'content', - 'footer', - 'media', - 'stats', - 'actions', - 'links', - 'custom', - ]; - if (!validTypes.includes(module.type)) { - errors.push({ field: `${prefix}.type`, message: `Invalid module type: ${module.type}` }); - } - } - - if (typeof module.order !== 'number') { - errors.push({ field: `${prefix}.order`, message: 'Module order must be a number' }); - } - - // Validate module-specific props - if (module.type === 'links' && module.props) { - if (!Array.isArray(module.props.links)) { - errors.push({ field: `${prefix}.props.links`, message: 'Links must be an array' }); - } - } - - if (module.type === 'media' && module.props) { - if (!module.props.type) { - errors.push({ field: `${prefix}.props.type`, message: 'Media type is required' }); - } - if (module.props.type === 'image' && !module.props.src) { - errors.push({ field: `${prefix}.props.src`, message: 'Image source is required' }); - } - } - - return errors; - } - - /** - * Validate advanced mode configuration - */ - private validateAdvancedConfig( - config: Extract - ): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - // Validate template - if (!config.template || config.template.trim() === '') { - errors.push({ field: 'config.template', message: 'Template is required' }); - } else { - // Check template size - if (config.template.length > 100000) { - errors.push({ - field: 'config.template', - message: 'Template exceeds maximum size of 100KB', - }); - } - - // Check for dangerous patterns - if (this.containsDangerousPatterns(config.template)) { - errors.push({ - field: 'config.template', - message: 'Template contains potentially dangerous patterns', - }); - } - } - - // Validate CSS - if (config.css) { - if (config.css.length > 50000) { - errors.push({ - field: 'config.css', - message: 'CSS exceeds maximum size of 50KB', - }); - } - - if (this.containsDangerousCSS(config.css)) { - errors.push({ - field: 'config.css', - message: 'CSS contains potentially dangerous patterns', - }); - } - } - - // Validate variables - if (!Array.isArray(config.variables)) { - errors.push({ field: 'config.variables', message: 'Variables must be an array' }); - } else { - config.variables.forEach((variable, index) => { - if (!variable.name) { - errors.push({ - field: `config.variables[${index}].name`, - message: 'Variable name is required', - }); - } - if (!variable.type) { - errors.push({ - field: `config.variables[${index}].type`, - message: 'Variable type is required', - }); - } - }); - } - - // Validate values match variables - if (config.values && config.variables) { - const requiredVars = config.variables.filter((v) => v.required); - requiredVars.forEach((variable) => { - if (!(variable.name in config.values)) { - errors.push({ - field: `config.values.${variable.name}`, - message: `Required variable '${variable.name}' is missing`, - }); - } - }); - } - - return errors; - } - - /** - * Validate expert mode configuration - */ - private validateExpertConfig( - config: Extract - ): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - // Validate HTML - if (!config.html || config.html.trim() === '') { - errors.push({ field: 'config.html', message: 'HTML is required' }); - } else { - if (config.html.length > 100000) { - errors.push({ - field: 'config.html', - message: 'HTML exceeds maximum size of 100KB', - }); - } - - if (this.containsDangerousPatterns(config.html)) { - errors.push({ - field: 'config.html', - message: 'HTML contains potentially dangerous patterns', - }); - } - } - - // Validate CSS - if (!config.css || config.css.trim() === '') { - errors.push({ field: 'config.css', message: 'CSS is required' }); - } else { - if (config.css.length > 50000) { - errors.push({ - field: 'config.css', - message: 'CSS exceeds maximum size of 50KB', - }); - } - - if (this.containsDangerousCSS(config.css)) { - errors.push({ - field: 'config.css', - message: 'CSS contains potentially dangerous patterns', - }); - } - } - - // JavaScript is not allowed in expert mode for security - if (config.javascript) { - errors.push({ - field: 'config.javascript', - message: 'JavaScript is not allowed for security reasons', - }); - } - - return errors; - } - - /** - * Validate card constraints - */ - private validateConstraints(card: Card): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - if (!card.constraints) { - return errors; - } - - // Validate aspect ratio - if (card.constraints.aspectRatio) { - const validRatios = ['16/9', '4/3', '1/1', '3/2', 'auto']; - if (!validRatios.includes(card.constraints.aspectRatio)) { - // Check if it's a custom ratio like "21/9" - const ratioPattern = /^\d+\/\d+$/; - if (!ratioPattern.test(card.constraints.aspectRatio)) { - errors.push({ - field: 'constraints.aspectRatio', - message: 'Invalid aspect ratio format', - }); - } - } - } - - // Validate size constraints - if (card.constraints.maxModules && card.constraints.maxModules < 1) { - errors.push({ - field: 'constraints.maxModules', - message: 'Maximum modules must be at least 1', - }); - } - - if (card.constraints.maxHTMLSize && card.constraints.maxHTMLSize < 1000) { - errors.push({ - field: 'constraints.maxHTMLSize', - message: 'Maximum HTML size must be at least 1KB', - }); - } - - if (card.constraints.maxCSSSize && card.constraints.maxCSSSize < 1000) { - errors.push({ - field: 'constraints.maxCSSSize', - message: 'Maximum CSS size must be at least 1KB', - }); - } - - return errors; - } - - /** - * Validate card metadata - */ - private validateMetadata(card: Card): Array<{ field: string; message: string }> { - const errors: Array<{ field: string; message: string }> = []; - - if (!card.metadata) { - return errors; - } - - // Validate name length - if (card.metadata.name && card.metadata.name.length > 100) { - errors.push({ - field: 'metadata.name', - message: 'Name must be 100 characters or less', - }); - } - - // Validate description length - if (card.metadata.description && card.metadata.description.length > 500) { - errors.push({ - field: 'metadata.description', - message: 'Description must be 500 characters or less', - }); - } - - // Validate tags - if (card.metadata.tags) { - if (!Array.isArray(card.metadata.tags)) { - errors.push({ - field: 'metadata.tags', - message: 'Tags must be an array', - }); - } else if (card.metadata.tags.length > 10) { - errors.push({ - field: 'metadata.tags', - message: 'Maximum 10 tags allowed', - }); - } - } - - // Position is now directly on the Card, not in metadata - // No need to validate here since it's handled at the Card level - - return errors; - } - - /** - * Check for dangerous HTML patterns - */ - private containsDangerousPatterns(html: string): boolean { - const dangerousPatterns = [ - / - -
-

Titel

-

Beschreibung

- -
-``` - -## Theme Transitions - -Theme-Wechsel werden automatisch mit sanften Übergängen animiert. Die Klasse `theme-transitioning` wird während des Wechsels auf das HTML-Element angewendet. - -## Best Practices - -1. **Verwende immer Theme-Farben** anstatt hardcodierte Farben -2. **Teste neue Komponenten** mit allen verfügbaren Themes -3. **Beachte Kontraste** für Barrierefreiheit -4. **Nutze semantische Farbnamen** (primary, accent) statt spezifischer Farben (blue, green) diff --git a/apps/uload/apps/web/src/lib/themes/presets.ts b/apps/uload/apps/web/src/lib/themes/presets.ts deleted file mode 100644 index 24ca279ef..000000000 --- a/apps/uload/apps/web/src/lib/themes/presets.ts +++ /dev/null @@ -1,207 +0,0 @@ -export interface ColorScheme { - primary: string; - primaryHover: string; - background: string; - surface: string; - surfaceHover: string; - text: string; - textMuted: string; - border: string; - accent: string; - accentHover: string; -} - -export interface ThemePreset { - id: string; - name: string; - description: string; - font: { - family: string; - import?: string; // Google Fonts import URL - }; - colors: { - light: ColorScheme; - dark: ColorScheme; - }; -} - -export const themes: Record = { - minimal: { - id: 'minimal', - name: 'Minimal', - description: 'Ruhiges, minimalistisches Design', - font: { - family: 'Inter, system-ui, -apple-system, sans-serif', - import: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', - }, - colors: { - light: { - primary: '#171717', - primaryHover: '#0a0a0a', - background: '#f5f5f5', - surface: '#fafafa', - surfaceHover: '#eeeeee', - text: '#171717', - textMuted: '#737373', - border: '#d4d4d4', - accent: '#525252', - accentHover: '#404040', - }, - dark: { - primary: '#b8b8b8', - primaryHover: '#ffffff', - background: '#0a0a0a', - surface: '#171717', - surfaceHover: '#262626', - text: '#fafafa', - textMuted: '#a3a3a3', - border: '#404040', - accent: '#d4d4d4', - accentHover: '#e5e5e5', - }, - }, - }, - ocean: { - id: 'ocean', - name: 'Ocean', - description: 'Beruhigende Blautöne', - font: { - family: 'Poppins, system-ui, -apple-system, sans-serif', - import: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap', - }, - colors: { - light: { - primary: '#0ea5e9', - primaryHover: '#0284c7', - background: '#e0f2fe', - surface: '#f0f9ff', - surfaceHover: '#bae6fd', - text: '#0c4a6e', - textMuted: '#475569', - border: '#7dd3fc', - accent: '#06b6d4', - accentHover: '#0891b2', - }, - dark: { - primary: '#38bdf8', - primaryHover: '#7dd3fc', - background: '#082f49', - surface: '#0c4a6e', - surfaceHover: '#075985', - text: '#f0f9ff', - textMuted: '#94a3b8', - border: '#1e3a8a', - accent: '#22d3ee', - accentHover: '#67e8f9', - }, - }, - }, - forest: { - id: 'forest', - name: 'Forest', - description: 'Natürliche Grüntöne', - font: { - family: 'Lora, Georgia, serif', - import: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap', - }, - colors: { - light: { - primary: '#16a34a', - primaryHover: '#15803d', - background: '#dcfce7', - surface: '#f0fdf4', - surfaceHover: '#bbf7d0', - text: '#14532d', - textMuted: '#4b5563', - border: '#86efac', - accent: '#84cc16', - accentHover: '#65a30d', - }, - dark: { - primary: '#4ade80', - primaryHover: '#86efac', - background: '#052e16', - surface: '#14532d', - surfaceHover: '#166534', - text: '#f0fdf4', - textMuted: '#86b896', - border: '#15803d', - accent: '#a3e635', - accentHover: '#bef264', - }, - }, - }, - sunset: { - id: 'sunset', - name: 'Sunset', - description: 'Warme Orange- und Rottöne', - font: { - family: 'Raleway, system-ui, -apple-system, sans-serif', - import: 'https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap', - }, - colors: { - light: { - primary: '#ea580c', - primaryHover: '#c2410c', - background: '#fed7aa', - surface: '#fff7ed', - surfaceHover: '#fdba74', - text: '#7c2d12', - textMuted: '#57534e', - border: '#fb923c', - accent: '#f97316', - accentHover: '#fb923c', - }, - dark: { - primary: '#fb923c', - primaryHover: '#fdba74', - background: '#431407', - surface: '#7c2d12', - surfaceHover: '#9a3412', - text: '#fff7ed', - textMuted: '#94a3b8', - border: '#c2410c', - accent: '#fbbf24', - accentHover: '#fcd34d', - }, - }, - }, - lavender: { - id: 'lavender', - name: 'Lavender', - description: 'Sanfte Violett-Töne', - font: { - family: 'Playfair Display, Georgia, serif', - import: - 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap', - }, - colors: { - light: { - primary: '#9333ea', - primaryHover: '#7e22ce', - background: '#f3e8ff', - surface: '#faf5ff', - surfaceHover: '#e9d5ff', - text: '#581c87', - textMuted: '#525252', - border: '#d8b4fe', - accent: '#a855f7', - accentHover: '#c084fc', - }, - dark: { - primary: '#c084fc', - primaryHover: '#d8b4fe', - background: '#3b0764', - surface: '#581c87', - surfaceHover: '#6b21a8', - text: '#faf5ff', - textMuted: '#94a3b8', - border: '#7e22ce', - accent: '#d946ef', - accentHover: '#e879f9', - }, - }, - }, -}; - -export const defaultTheme = 'minimal'; diff --git a/apps/uload/apps/web/src/lib/themes/theme-store.ts b/apps/uload/apps/web/src/lib/themes/theme-store.ts deleted file mode 100644 index 04f864d88..000000000 --- a/apps/uload/apps/web/src/lib/themes/theme-store.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { browser } from '$app/environment'; -import { themes, defaultTheme } from './presets'; -import type { ThemePreset } from './presets'; -import { writable, derived, get } from 'svelte/store'; - -export type ThemeMode = 'light' | 'dark' | 'system'; - -class ThemeStore { - // Using Svelte stores instead of runes for SSR compatibility - private presetStore = writable(defaultTheme); - private modeStore = writable('system'); - private systemPrefersDarkStore = writable(false); - private transitioningStore = writable(false); - - // Public readable stores - public preset = { subscribe: this.presetStore.subscribe }; - public mode = { subscribe: this.modeStore.subscribe }; - public transitioning = { subscribe: this.transitioningStore.subscribe }; - - // Derived stores - public currentPreset = derived( - this.presetStore, - ($preset) => themes[$preset] || themes[defaultTheme] - ); - - public isDark = derived( - [this.modeStore, this.systemPrefersDarkStore], - ([$mode, $systemPrefersDark]) => { - return $mode === 'system' ? $systemPrefersDark : $mode === 'dark'; - } - ); - - public colors = derived([this.currentPreset, this.isDark], ([$currentPreset, $isDark]) => { - return $isDark ? $currentPreset.colors.dark : $currentPreset.colors.light; - }); - - public font = derived(this.currentPreset, ($currentPreset) => $currentPreset.font); - - constructor() { - if (browser) { - this.init(); - } - } - - private init() { - // Load saved preferences - const savedPreset = localStorage.getItem('theme-preset'); - const savedMode = localStorage.getItem('theme-mode') as ThemeMode; - - if (savedPreset && themes[savedPreset]) { - this.presetStore.set(savedPreset); - } - - if (savedMode) { - this.modeStore.set(savedMode); - } - - // Detect system preference - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - this.systemPrefersDarkStore.set(mediaQuery.matches); - - mediaQuery.addEventListener('change', (e) => { - this.systemPrefersDarkStore.set(e.matches); - if (get(this.modeStore) === 'system') { - this.applyTheme(); - } - }); - - // Apply initial theme - this.applyTheme(); - - // Subscribe to changes - this.presetStore.subscribe(() => this.applyTheme()); - this.modeStore.subscribe(() => this.applyTheme()); - this.isDark.subscribe(() => this.applyTheme()); - } - - // Apply theme to DOM - applyTheme() { - if (!browser) return; - - const root = document.documentElement; - const colors = get(this.colors); - const font = get(this.font); - const isDark = get(this.isDark); - - // Apply dark class - if (isDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - - // Apply CSS variables - Object.entries(colors).forEach(([key, value]) => { - // Convert camelCase to kebab-case for CSS variables - const cssKey = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); - const varName = `--theme-${cssKey}`; - root.style.setProperty(varName, value); - }); - - // Apply font - root.style.setProperty('--theme-font-family', font.family); - - // Load Google Font if needed - if (font.import) { - const preset = get(this.presetStore); - const fontId = `theme-font-${preset}`; - let existingFont = document.getElementById(fontId); - - // Remove old font links - document.querySelectorAll('link[id^="theme-font-"]').forEach((link) => { - if (link.id !== fontId) { - link.remove(); - } - }); - - // Add new font link if not exists - if (!existingFont) { - const link = document.createElement('link'); - link.id = fontId; - link.rel = 'stylesheet'; - link.href = font.import; - document.head.appendChild(link); - } - } - - // Save to localStorage - localStorage.setItem('theme-preset', get(this.presetStore)); - localStorage.setItem('theme-mode', get(this.modeStore)); - } - - // Change theme preset with transition - async setPreset(presetId: string) { - if (!themes[presetId]) return; - - if (browser) { - this.transitioningStore.set(true); - document.documentElement.classList.add('theme-transitioning'); - - // Small delay for transition start - await new Promise((resolve) => setTimeout(resolve, 50)); - - this.presetStore.set(presetId); - - // Wait for transition to complete - await new Promise((resolve) => setTimeout(resolve, 300)); - - document.documentElement.classList.remove('theme-transitioning'); - this.transitioningStore.set(false); - } else { - this.presetStore.set(presetId); - } - } - - // Change theme mode - setMode(mode: ThemeMode) { - this.modeStore.set(mode); - } - - // Toggle between light and dark - toggle() { - const currentMode = get(this.modeStore); - const systemPrefersDark = get(this.systemPrefersDarkStore); - - if (currentMode === 'system') { - // If system mode, switch to opposite of current system preference - this.modeStore.set(systemPrefersDark ? 'light' : 'dark'); - } else { - // Toggle between light and dark - this.modeStore.set(currentMode === 'light' ? 'dark' : 'light'); - } - } - - // Get all available themes - get availableThemes() { - return Object.values(themes); - } -} - -export const themeStore = new ThemeStore(); diff --git a/apps/uload/apps/web/src/lib/types/accounts.ts b/apps/uload/apps/web/src/lib/types/accounts.ts deleted file mode 100644 index 7f32c595a..000000000 --- a/apps/uload/apps/web/src/lib/types/accounts.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Simplified Account Types for Team Collaboration - -export interface User { - id: string; - email: string; - username: string; - name?: string; - avatar?: string; - bio?: string; - location?: string; - website?: string; - github?: string; - twitter?: string; - linkedin?: string; - instagram?: string; - showClickStats?: boolean; - subscription_status?: 'free' | 'pro' | 'team' | 'team_plus' | 'cancelled' | 'past_due'; - stripe_customer_id?: string; - stripe_subscription_id?: string; - current_period_end?: string; - links_created_this_month?: number; - monthly_reset_date?: string; - team_members_count?: number; - created: string; - updated: string; - verified?: boolean; -} - -// Shared access for team collaboration -export interface SharedAccess { - id: string; - owner: string; // User who owns the account - user: string; // User who has access - permissions?: TeamPermissions; - invitation_token?: string; - invitation_status?: 'pending' | 'accepted' | 'declined'; - invited_at?: string; - accepted_at?: string; - created: string; - updated: string; - expand?: { - owner?: User; - user?: User; - }; -} - -// Team member permissions -export interface TeamPermissions { - view_stats: boolean; - create_links: boolean; - edit_own: boolean; - delete_own: boolean; - manage_team?: boolean; // Only for team admins -} - -// Default permissions for new team members -export const DEFAULT_PERMISSIONS: TeamPermissions = { - view_stats: true, - create_links: true, - edit_own: true, - delete_own: true, - manage_team: false, -}; - -// Subscription plans with updated limits -export const SUBSCRIPTION_PLANS = { - free: { - name: 'Free', - price: 0, - currency: 'EUR', - team_members: 1, // Can invite 1 team member - links_per_month: 10, // Updated to match pricing page - features: [ - '10 links per month', - '1 team member', - 'Basic Analytics', - 'QR Codes', - 'Link Customization', - ], - }, - pro: { - name: 'Pro Monthly', - price: 4.99, - currency: 'EUR', - team_members: 3, // Can invite up to 3 team members - links_per_month: 300, // Updated to match pricing page - features: [ - '300 links per month', - 'Up to 3 team members', - 'Advanced Analytics', - 'Custom QR Codes', - 'Priority Support', - ], - }, - team: { - name: 'Pro Yearly', - price: 39.99, - currency: 'EUR', - team_members: 5, // Can invite up to 5 team members - links_per_month: 600, // Updated to match pricing page (yearly = 600/month) - features: [ - '600 links per month', - 'Up to 5 team members', - 'Advanced Analytics', - 'Custom QR Codes', - 'Priority Support', - ], - }, - team_plus: { - name: 'Pro Lifetime', - price: 129.99, - currency: 'EUR', - team_members: -1, // unlimited team members - links_per_month: -1, // unlimited - features: [ - 'Unlimited links', - 'Unlimited team members', - 'All Pro Features', - 'API Access', - 'Early Access to new Features', - ], - }, -}; - -// Helper to check if user can add team members (now everyone can) -export function canAddTeamMembers(subscription_status?: string): boolean { - return true; // Everyone can invite team members -} - -// Helper to get team member limit -export function getTeamMemberLimit(subscription_status?: string): number { - if (!subscription_status || !(subscription_status in SUBSCRIPTION_PLANS)) { - return SUBSCRIPTION_PLANS.free.team_members; // Default to free plan limit - } - const limit = - SUBSCRIPTION_PLANS[subscription_status as keyof typeof SUBSCRIPTION_PLANS].team_members; - return limit === -1 ? Infinity : limit; // -1 means unlimited -} - -// Helper to get links per month limit -export function getLinksPerMonthLimit(subscription_status?: string): number { - if (!subscription_status || !(subscription_status in SUBSCRIPTION_PLANS)) { - return SUBSCRIPTION_PLANS.free.links_per_month; - } - return SUBSCRIPTION_PLANS[subscription_status as keyof typeof SUBSCRIPTION_PLANS].links_per_month; -} diff --git a/apps/uload/apps/web/src/lib/username.spec.ts b/apps/uload/apps/web/src/lib/username.spec.ts deleted file mode 100644 index 2c42059bb..000000000 --- a/apps/uload/apps/web/src/lib/username.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { validateUsername, generateUsernameFromEmail, RESERVED_USERNAMES } from './username'; - -describe('Username Utilities', () => { - describe('validateUsername', () => { - it('should accept valid usernames', () => { - const validUsernames = [ - 'john_doe', - 'user123', - 'test-user', - 'JohnDoe', - 'a1b2c3', - 'user_name-123', - ]; - - validUsernames.forEach((username) => { - const result = validateUsername(username); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - }); - - it('should reject usernames shorter than 3 characters', () => { - const result = validateUsername('ab'); - expect(result.valid).toBe(false); - expect(result.error).toContain('at least 3 characters'); - }); - - it('should reject usernames longer than 30 characters', () => { - const longUsername = 'a'.repeat(31); - const result = validateUsername(longUsername); - expect(result.valid).toBe(false); - expect(result.error).toContain('less than 30 characters'); - }); - - it('should reject usernames with special characters', () => { - const invalidUsernames = [ - 'user@name', - 'user.name', - 'user name', - 'user!name', - 'user#name', - 'user$name', - ]; - - invalidUsernames.forEach((username) => { - const result = validateUsername(username); - expect(result.valid).toBe(false); - expect(result.error).toContain('letters, numbers, underscore and hyphen'); - }); - }); - - it('should reject usernames not starting with letter or number', () => { - const invalidStarts = ['_username', '-username', '__test']; - - invalidStarts.forEach((username) => { - const result = validateUsername(username); - expect(result.valid).toBe(false); - expect(result.error).toContain('start with a letter or number'); - }); - }); - - it('should reject reserved usernames', () => { - const reserved = ['admin', 'api', 'dashboard', 'login', 'settings']; - - reserved.forEach((username) => { - const result = validateUsername(username); - expect(result.valid).toBe(false); - expect(result.error).toContain('reserved'); - }); - }); - - it('should reject reserved usernames case-insensitively', () => { - const result = validateUsername('ADMIN'); - expect(result.valid).toBe(false); - expect(result.error).toContain('reserved'); - }); - }); - - describe('generateUsernameFromEmail', () => { - it('should extract local part from email', () => { - const username = generateUsernameFromEmail('john.doe@example.com'); - expect(username).toContain('john'); - expect(username).not.toContain('@'); - expect(username).not.toContain('example.com'); - }); - - it('should remove special characters', () => { - const username = generateUsernameFromEmail('john.doe+test@example.com'); - expect(username).toBe('johndoetest'); - }); - - it('should handle emails with numbers', () => { - const username = generateUsernameFromEmail('user123@example.com'); - expect(username).toBe('user123'); - }); - - it('should preserve underscores and hyphens', () => { - const username = generateUsernameFromEmail('john_doe-123@example.com'); - expect(username).toBe('john_doe-123'); - }); - - it('should add prefix if starting with invalid character', () => { - const username = generateUsernameFromEmail('_test@example.com'); - expect(username).toMatch(/^user_test/); - }); - - it('should ensure minimum length of 3', () => { - const username = generateUsernameFromEmail('a@example.com'); - expect(username.length).toBeGreaterThanOrEqual(3); - expect(username).toMatch(/^a[a-z0-9]+$/); - }); - - it('should truncate if longer than 30 characters', () => { - const longEmail = 'a'.repeat(40) + '@example.com'; - const username = generateUsernameFromEmail(longEmail); - expect(username.length).toBeLessThanOrEqual(30); - }); - - it('should handle empty local part', () => { - const username = generateUsernameFromEmail('@example.com'); - expect(username.length).toBeGreaterThanOrEqual(3); - expect(username).toMatch(/^user/); - }); - - it('should handle complex email formats', () => { - const testCases = [ - { email: 'first.last@example.com', expected: 'firstlast' }, - { email: 'user+tag@example.com', expected: 'usertag' }, - { email: '123user@example.com', expected: '123user' }, - { email: 'test.test.test@example.com', expected: 'testtesttest' }, - ]; - - testCases.forEach(({ email, expected }) => { - const username = generateUsernameFromEmail(email); - expect(username).toBe(expected); - }); - }); - }); - - describe('RESERVED_USERNAMES', () => { - it('should contain common reserved names', () => { - const essentialReserved = [ - 'admin', - 'api', - 'login', - 'logout', - 'register', - 'settings', - 'dashboard', - 'user', - 'users', - ]; - - essentialReserved.forEach((name) => { - expect(RESERVED_USERNAMES).toContain(name); - }); - }); - - it('should not have duplicates', () => { - const uniqueNames = new Set(RESERVED_USERNAMES); - expect(uniqueNames.size).toBe(RESERVED_USERNAMES.length); - }); - - it('should be all lowercase', () => { - RESERVED_USERNAMES.forEach((name) => { - expect(name).toBe(name.toLowerCase()); - }); - }); - }); -}); diff --git a/apps/uload/apps/web/src/lib/username.ts b/apps/uload/apps/web/src/lib/username.ts deleted file mode 100644 index 093078f41..000000000 --- a/apps/uload/apps/web/src/lib/username.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Reserved usernames that cannot be used -export const RESERVED_USERNAMES = [ - 'admin', - 'api', - 'app', - 'blog', - 'dashboard', - 'help', - 'login', - 'logout', - 'register', - 'settings', - 'support', - 'www', - 'mail', - 'ftp', - 'email', - 'about', - 'privacy', - 'terms', - 'security', - 'contact', - 'legal', - 'docs', - 'documentation', - 'status', - 'cdn', - 'assets', - 'public', - 'static', - 'media', - 'css', - 'js', - 'images', - 'img', - 'fonts', - 'download', - 'downloads', - 'u', - 'user', - 'users', - 'profile', - 'account', - 'accounts', - 'auth', - 'oauth', - 'signin', - 'signup', - 'signout', - 'reset', - 'verify', - 'confirm', - 'analytics', -]; - -export function validateUsername(username: string): { valid: boolean; error?: string } { - // Check length - if (username.length < 3) { - return { valid: false, error: 'Username must be at least 3 characters' }; - } - if (username.length > 30) { - return { valid: false, error: 'Username must be less than 30 characters' }; - } - - // Check format (alphanumeric, underscore, hyphen) - if (!/^[a-zA-Z0-9_-]+$/.test(username)) { - return { - valid: false, - error: 'Username can only contain letters, numbers, underscore and hyphen', - }; - } - - // Must start with letter or number - if (!/^[a-zA-Z0-9]/.test(username)) { - return { valid: false, error: 'Username must start with a letter or number' }; - } - - // Check reserved names - if (RESERVED_USERNAMES.includes(username.toLowerCase())) { - return { valid: false, error: 'This username is reserved' }; - } - - return { valid: true }; -} - -export function generateUsernameFromEmail(email: string): string { - const localPart = email.split('@')[0]; - // Remove special characters and convert to valid username - let username = localPart.replace(/[^a-zA-Z0-9_-]/g, ''); - - // Ensure it starts with letter or number - if (!/^[a-zA-Z0-9]/.test(username)) { - username = 'user' + username; - } - - // Ensure minimum length - if (username.length < 3) { - username = username + Math.random().toString(36).substring(2, 5); - } - - // Truncate if too long - if (username.length > 30) { - username = username.substring(0, 30); - } - - return username; -} diff --git a/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts b/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts deleted file mode 100644 index d172bad8b..000000000 --- a/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts +++ /dev/null @@ -1,684 +0,0 @@ -/** - * Reserved slugs that cannot be used for workspace URLs - * to prevent conflicts with system routes, common usernames, - * and potential brand confusion - */ -export const RESERVED_SLUGS = [ - // uload specific routes - 'uload', - 'ulo', - 'u', - 'w', - 'p', - 'link', - 'links', - 'card', - 'cards', - 'workspace', - 'workspaces', - 'my', - 'analytics', - 'stats', - 'statistics', - 'click', - 'clicks', - 'qr', - 'qrcode', - 'preview', - 'embed', - 'widget', - 'share', - 'invite', - 'invites', - 'invitation', - 'invitations', - 'member', - 'members', - 'owner', - 'owners', - - // System/Admin routes - 'admin', - 'api', - 'www', - 'mail', - 'support', - 'help', - 'docs', - 'documentation', - 'blog', - 'legal', - 'privacy', - 'terms', - 'tos', - 'contact', - 'about', - 'pricing', - 'prices', - 'features', - 'security', - 'status', - 'health', - 'ping', - 'webhook', - 'webhooks', - 'callback', - 'auth', - 'oauth', - 'sso', - 'login', - 'logout', - 'register', - 'signup', - 'signin', - 'signout', - 'dashboard', - 'settings', - 'preferences', - 'profile', - 'account', - 'accounts', - 'billing', - 'subscription', - 'subscriptions', - 'plan', - 'plans', - 'upgrade', - 'downgrade', - 'cancel', - 'delete', - 'remove', - 'edit', - 'update', - 'create', - 'add', - 'new', - 'manage', - 'management', - 'admin-panel', - 'control-panel', - 'cpanel', - - // Common service names - 'cdn', - 'assets', - 'static', - 'public', - 'images', - 'img', - 'css', - 'js', - 'fonts', - 'uploads', - 'files', - 'download', - 'downloads', - 'archive', - 'backup', - 'export', - 'import', - 'sync', - - // Common usernames/brands - 'admin', - 'administrator', - 'root', - 'system', - 'service', - 'bot', - 'user', - 'users', - 'team', - 'teams', - 'group', - 'groups', - 'org', - 'organization', - 'company', - 'corp', - 'inc', - 'llc', - 'gmbh', - 'ag', - 'sa', - 'ltd', - 'limited', - - // Potential phishing/confusion - Tech Giants - 'google', - 'facebook', - 'meta', - 'twitter', - 'x', - 'instagram', - 'threads', - 'linkedin', - 'github', - 'gitlab', - 'bitbucket', - 'microsoft', - 'windows', - 'xbox', - 'apple', - 'iphone', - 'ipad', - 'mac', - 'amazon', - 'aws', - 'netflix', - 'spotify', - 'slack', - 'discord', - 'telegram', - 'whatsapp', - 'signal', - 'zoom', - 'teams', - 'skype', - 'notion', - 'trello', - 'asana', - 'jira', - 'confluence', - 'atlassian', - 'adobe', - 'figma', - 'canva', - 'dropbox', - 'box', - 'drive', - 'onedrive', - 'icloud', - 'oracle', - 'salesforce', - 'hubspot', - 'mailchimp', - 'sendgrid', - 'twilio', - 'stripe', - 'paypal', - 'square', - 'shopify', - 'wix', - 'wordpress', - 'squarespace', - 'godaddy', - 'namecheap', - 'cloudflare', - 'vercel', - 'netlify', - 'heroku', - 'digitalocean', - 'linode', - 'vultr', - 'docker', - 'kubernetes', - 'redis', - 'mongodb', - 'postgres', - 'mysql', - 'firebase', - 'supabase', - 'openai', - 'chatgpt', - 'anthropic', - 'claude', - 'gemini', - 'bard', - 'copilot', - 'midjourney', - 'stability', - 'huggingface', - - // German companies/brands - 'telekom', - 'vodafone', - 'o2', - '1und1', - 'bmw', - 'mercedes', - 'mercedes-benz', - 'volkswagen', - 'vw', - 'audi', - 'porsche', - 'opel', - 'ford', - 'tesla', - 'siemens', - 'sap', - 'allianz', - 'deutsche-bank', - 'deutschebank', - 'commerzbank', - 'sparkasse', - 'volksbank', - 'postbank', - 'dhl', - 'dpd', - 'ups', - 'fedex', - 'hermes', - 'lufthansa', - 'eurowings', - 'ryanair', - 'easyjet', - 'adidas', - 'puma', - 'nike', - 'reebok', - 'bayer', - 'basf', - 'bosch', - 'continental', - 'thyssenkrupp', - 'henkel', - 'beiersdorf', - 'nivea', - 'hugo-boss', - 'hugoboss', - 'zalando', - 'aboutyou', - 'otto', - 'lidl', - 'aldi', - 'edeka', - 'rewe', - 'penny', - 'netto', - 'kaufland', - 'real', - 'metro', - 'saturn', - 'mediamarkt', - 'conrad', - 'dm', - 'rossmann', - 'mueller', - 'douglas', - 'tchibo', - 'ikea', - 'hornbach', - 'obi', - 'bauhaus', - 'toom', - 'hagebau', - - // Banks & Financial - 'visa', - 'mastercard', - 'amex', - 'americanexpress', - 'discover', - 'bank', - 'banking', - 'credit', - 'debit', - 'loan', - 'mortgage', - 'insurance', - 'crypto', - 'bitcoin', - 'ethereum', - 'binance', - 'coinbase', - 'kraken', - 'revolut', - 'n26', - 'klarna', - 'wise', - 'transferwise', - - // Social Media & Dating - 'youtube', - 'tiktok', - 'snapchat', - 'pinterest', - 'reddit', - 'tumblr', - 'flickr', - 'vimeo', - 'twitch', - 'medium', - 'substack', - 'patreon', - 'onlyfans', - 'tinder', - 'bumble', - 'hinge', - 'badoo', - 'lovoo', - 'parship', - 'elitepartner', - - // E-commerce & Marketplaces - 'ebay', - 'etsy', - 'alibaba', - 'aliexpress', - 'wish', - 'shein', - 'wayfair', - 'booking', - 'expedia', - 'airbnb', - 'uber', - 'lyft', - 'bolt', - 'deliveroo', - 'doordash', - 'ubereats', - 'lieferando', - 'wolt', - 'gorillas', - 'getir', - 'flink', - - // News & Media - 'nytimes', - 'bbc', - 'cnn', - 'reuters', - 'bloomberg', - 'forbes', - 'wsj', - 'guardian', - 'spiegel', - 'bild', - 'zeit', - 'faz', - 'sueddeutsche', - 'stern', - 'focus', - 'welt', - 'handelsblatt', - 'tagesschau', - 'zdf', - 'ard', - 'rtl', - 'sat1', - 'prosieben', - - // Gaming & Entertainment - 'steam', - 'epic', - 'epicgames', - 'ubisoft', - 'ea', - 'electronicarts', - 'activision', - 'blizzard', - 'riot', - 'riotgames', - 'valve', - 'nintendo', - 'playstation', - 'sony', - 'xbox', - 'minecraft', - 'fortnite', - 'roblox', - 'pubg', - 'gta', - 'cod', - 'lol', - 'leagueoflegends', - 'valorant', - 'overwatch', - 'warcraft', - - // Automotive & Transportation - 'uber', - 'lyft', - 'grab', - 'didi', - 'bolt', - 'freenow', - 'mytaxi', - 'blablacar', - 'flixbus', - 'flixmobility', - 'db', - 'deutschebahn', - 'bahn', - 'ice', - 'sbahn', - 'ubahn', - 'share-now', - 'car2go', - 'sixt', - 'europcar', - 'hertz', - 'avis', - 'enterprise', - - // Food & Beverage Brands - 'mcdonalds', - 'burgerking', - 'kfc', - 'subway', - 'starbucks', - 'dunkin', - 'dominos', - 'pizzahut', - 'papajohns', - 'coca-cola', - 'cocacola', - 'pepsi', - 'redbull', - 'monster', - 'nestle', - 'danone', - 'unilever', - 'kelloggs', - 'heinz', - 'nutella', - 'ferrero', - 'haribo', - 'ritter-sport', - 'rittersport', - 'milka', - 'lindt', - - // Telecom & ISPs - 'att', - 'verizon', - 'tmobile', - 't-mobile', - 'orange', - 'bt', - 'sky', - 'virgin', - 'comcast', - 'spectrum', - 'cox', - 'unitymedia', - 'kabel-deutschland', - 'kabeldeutschland', - 'pyur', - 'netcologne', - 'mnet', - - // Universities & Education - 'harvard', - 'stanford', - 'mit', - 'oxford', - 'cambridge', - 'yale', - 'princeton', - 'coursera', - 'udemy', - 'udacity', - 'edx', - 'khan', - 'khanacademy', - 'duolingo', - 'babbel', - 'rosetta', - 'rosettastone', - - // Healthcare & Pharma - 'pfizer', - 'moderna', - 'johnson', - 'jnj', - 'novartis', - 'roche', - 'merck', - 'abbott', - 'medtronic', - 'cvs', - 'walgreens', - 'doctolib', - 'jameda', - 'aponeo', - 'docmorris', - 'shop-apotheke', - 'shopapotheke', - - // Short/valuable names - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - 'ai', - 'ml', - 'io', - 'app', - 'web', - 'dev', - 'pro', - 'premium', - 'plus', - 'max', - 'ultra', - 'super', - 'mega', - 'nano', - 'micro', - 'mini', - 'test', - 'demo', - 'example', - 'sample', - 'temp', - 'tmp', - 'new', - 'old', - 'beta', - 'alpha', - 'v1', - 'v2', - 'v3', - 'latest', - 'stable', - 'main', - 'master', - 'default', - 'null', - 'undefined', - 'true', - 'false', - 'yes', - 'no', - 'on', - 'off', - 'all', - 'none', - 'one', - 'two', - 'three', - 'free', - 'trial', - 'sandbox', - 'staging', - 'production', - 'localhost', - 'www', - 'ftp', - 'sftp', - 'ssh', - 'http', - 'https', - 'ssl', - 'tls', - 'dns', - 'mx', - 'ns', - 'cname', - 'txt', - 'spf', - 'dkim', - 'dmarc', -] as const; - -/** - * Check if a slug is reserved - */ -export function isSlugReserved(slug: string): boolean { - return RESERVED_SLUGS.includes(slug.toLowerCase() as any); -} - -/** - * Validate a slug for workspace creation - * Returns an error message if invalid, null if valid - */ -export function validateWorkspaceSlug(slug: string): string | null { - if (!slug) { - return null; // Empty slug is allowed (auto-generated) - } - - // Check format - if (!/^[a-z0-9\-]+$/.test(slug)) { - return 'Workspace URL can only contain lowercase letters, numbers, and hyphens'; - } - - // Check length - if (slug.length < 2) { - return 'Workspace URL must be at least 2 characters long'; - } - - if (slug.length > 50) { - return 'Workspace URL cannot be longer than 50 characters'; - } - - // Check reserved - if (isSlugReserved(slug)) { - return 'This workspace URL is reserved and cannot be used'; - } - - // Check start/end with hyphen - if (slug.startsWith('-') || slug.endsWith('-')) { - return 'Workspace URL cannot start or end with a hyphen'; - } - - // Check consecutive hyphens - if (slug.includes('--')) { - return 'Workspace URL cannot contain consecutive hyphens'; - } - - return null; // Valid -} diff --git a/apps/uload/apps/web/src/paraglide/messages.ts b/apps/uload/apps/web/src/paraglide/messages.ts deleted file mode 100644 index 4baa6e5e7..000000000 --- a/apps/uload/apps/web/src/paraglide/messages.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Compatibility layer: Paraglide-style API using svelte-i18n -// This allows existing code using m.key() to work with svelte-i18n -import { _, locale } from 'svelte-i18n'; -import { get } from 'svelte/store'; -import '$lib/i18n'; // Initialize i18n - -// Create a Proxy that returns translation functions for any key -const messageProxy = new Proxy( - {}, - { - get(_target, prop: string) { - // Return a function that gets the translation - return () => { - const translate = get(_); - return translate(prop); - }; - }, - } -) as Record string>; - -// Export everything from the proxy -export const { - // Navigation - nav_login, - nav_register, - nav_dashboard, - nav_folders, - nav_profile, - nav_logout, - nav_pricing, - - // Home - home_title, - home_subtitle, - home_url_label_qr, - home_url_label, - home_title_label, - home_title_placeholder, - home_description_label, - home_description_placeholder, - home_expires_label, - home_expires_placeholder, - home_max_clicks_label, - home_max_clicks_placeholder, - home_password_label, - home_password_placeholder, - home_guest_info, - home_guest_signin_hint, - home_processing, - home_submit_button_qr, - home_submit_button, - - // Auth - auth_modal_signin, - auth_sign_in, - auth_login_button, - auth_login_button_loading, - auth_register_button, - auth_register_button_loading, - auth_email_label, - auth_email_placeholder, - auth_email_address_label, - auth_password_label, - auth_password_confirm_label, - auth_forgot_password, - auth_no_account, - auth_have_account, - auth_create_account, - auth_create_account_title, - auth_create_account_subtitle, - auth_welcome_back, - auth_welcome_back_subtitle, - auth_back_to_login, - auth_go_to_login, - auth_remember_password, - auth_username_auto, - auth_registration_tip, - auth_registration_success, - auth_registration_success_message, - auth_reset_password_title, - auth_reset_password_subtitle, - auth_reset_password_button, - auth_reset_password_button_loading, - auth_send_reset_button, - auth_send_reset_button_loading, - auth_reset_email_sent_title, - auth_reset_email_sent_message, - auth_request_new_reset_link, - auth_set_new_password_title, - auth_set_new_password_subtitle, - auth_new_password_label, - auth_new_password_placeholder, - auth_confirm_new_password_label, - auth_confirm_new_password_placeholder, - auth_password_reset_success, - auth_password_reset_success_message, - auth_invalid_reset_link, - auth_invalid_reset_link_message, - auth_invalid_verification_link, - auth_invalid_verification_link_message, - auth_verification_link_expired, - auth_verification_link_expired_message, - auth_email_verified, - auth_email_verified_message, - auth_email_already_verified, - auth_email_already_verified_message, - auth_email_already_verified_notify, - auth_email_already_verified_notify_desc, - auth_token_expired_notify, - auth_token_expired_notify_desc, - auth_add_account, - auth_add_account_info, - auth_add_account_subtitle, - auth_add_account_switch_info, - - // Account - account_my_account, - account_add_account, - account_team_accounts, - account_no_team_accounts, - account_team_invite_info, - account_team_member, - - // Workspace - workspace_switch, - workspace_personal, - workspace_create, - - // Hero - hero_control_headline, - hero_control_subheadline, - hero_control_cta, - hero_free_text, - hero_trust_badge_, - hero_a, - hero_b, - hero_c, - - // Toast - toast_login_success, - toast_login_error, - toast_logout_success, - toast_register_success, - toast_link_created, - toast_link_updated, - toast_link_deleted, - toast_link_copied, - toast_profile_updated, - toast_avatar_uploaded, - toast_password_changed, - toast_password_reset_sent, - toast_email_verified, - toast_session_expired, - toast_session_expired_desc, - toast_network_error, - toast_network_error_desc, - toast_permission_denied, - toast_payment_failed, - toast_payment_failed_desc, - toast_subscription_upgraded, - toast_subscription_cancelled, - toast_unsupported_format, - - // Errors - error_link_creation, - error_link_creation_single, - error_password_change, - error_save, -} = messageProxy; - -// Re-export locale utilities -export { locale }; - -// Default export for `import * as m from` -export default messageProxy; diff --git a/apps/uload/apps/web/src/routes/(app)/+layout.svelte b/apps/uload/apps/web/src/routes/(app)/+layout.svelte index 69b8eb93b..64dfdb96d 100644 --- a/apps/uload/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/uload/apps/web/src/routes/(app)/+layout.svelte @@ -12,7 +12,7 @@ let { children } = $props(); - const appItems = getPillAppItems('uload'); + const appItems = getPillAppItems(); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); @@ -103,8 +103,6 @@ onLogout={handleLogout} onToggleTheme={handleToggleTheme} {isDark} - {isSidebarMode} - onModeChange={handleModeChange} {isCollapsed} onCollapsedChange={handleCollapsedChange} showThemeToggle={true} diff --git a/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte b/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte deleted file mode 100644 index 2a8074e34..000000000 --- a/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - Alle Apps - uload - - -
- -
- - diff --git a/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte deleted file mode 100644 index c191708f7..000000000 --- a/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/uload/apps/web/src/routes/(app)/my/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/+page.svelte index 199d12cbc..a9579f4b2 100644 --- a/apps/uload/apps/web/src/routes/(app)/my/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/my/+page.svelte @@ -1 +1,6 @@ - + diff --git a/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte index d68466313..f97e18491 100644 --- a/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte @@ -1,341 +1,157 @@ -
-
-
-
-
-

Link Analytics

-

{data.link.title || 'Untitled Link'}

-
- ← Back to Dashboard -
+
+ {#if link.value} +
+

Analytics

+

+ /{link.value.shortCode} + → {link.value.originalUrl} +

-
+ {/if} -
-
-
-
-

Short URL

-

{formatUrl(data.link.short_code)}

-
-
-

Original URL

-

{data.link.original_url}

-
+ {#if loading} +
+ {#each Array(3) as _} +
+ {/each} +
+ {:else if !authStore.isAuthenticated} +
+

Analytics nur für angemeldete Nutzer

+

Lokale Click-Counts: {link.value?.clickCount ?? 0}

+ Anmelden +
+ {:else} + +
+
+

Total Clicks

+

{stats?.totalClicks ?? link.value?.clickCount ?? 0}

- -
-
-

Total Clicks

-

{data.totalClicks}

-
-
-

Status

-

- {#if data.link.is_active} - Active - {:else} - Inactive - {/if} -

-
-
-

Created

-

{new Date(data.link.created).toLocaleDateString()}

-
-
-

Features

-
- {#if data.link.password} - 🔒 Protected - {/if} - {#if data.link.expires_at} - ⏰ Expires - {/if} - {#if data.link.max_clicks} - 🔢 Limited - {/if} -
-
+
+

Unique Visitors

+

{stats?.uniqueVisitors ?? '-'}

+
+
+

Status

+

{link.value?.isActive ? '🟢 Aktiv' : '🔴 Inaktiv'}

-
-
-

QR Code

- -
- {#if showQRCode} -
- QR Code for {data.link.short_code} - -
-
- QR Code Color -
- - - -
-
- -
- - -
-
- -
- - -
- -

- Scan this QR code to access the short link directly -

-
- {/if} -
- -
-
-

Browser Distribution

- {#if data.browserStats.length > 0} -
- {#each data.browserStats as [browser, count]} -
- {browser} -
-
-
-
- {count} -
-
- {/each} -
- {:else} -

No data yet

- {/if} -
- -
-

Device Types

- {#if data.deviceStats.length > 0} -
- {#each data.deviceStats as [device, count]} -
- {device} -
-
-
-
- {count} -
-
- {/each} -
- {:else} -

No data yet

- {/if} -
-
- -
-

Top Referrers

- {#if data.refererStats.length > 0} -
- {#each data.refererStats as [referrer, count]} -
- {referrer} - {count} clicks + + {#if timeline.length > 0} +
+

Clicks (30 Tage)

+
+ {#each timeline as day} + {@const maxCount = Math.max(...timeline.map((t) => t.count), 1)} +
+
{/each}
- {:else} -

No referrer data yet

- {/if} -
+
+ {/if} -
-

Clicks by Day

- {#if data.clicksByDay.length > 0} -
-
- {#each data.clicksByDay as [day, count]} -
-
- {day} + +
+ {#if devices.length > 0} +
+

Geräte

+
+ {#each devices as d} +
+ {d.deviceType || 'Unbekannt'} + {d.count}
{/each}
- {:else} -

No daily data yet

{/if} -
-
-

Recent Clicks

- {#if data.recentClicks.length > 0} -
- - - - - - - - - - - {#each data.recentClicks as click} - - - - - - - {/each} - -
TimeBrowserDeviceReferrer
- {new Date(click.created).toLocaleString()} - {getBrowserFromUserAgent(click.user_agent) || 'Unknown'}{click.device || 'Unknown'} - {#if click.referer} - {@const url = new URL(click.referer)} - {url.hostname} - {:else} - Direct - {/if} -
+ {#if referrers.length > 0} +
+

Referrer

+
+ {#each referrers as r} +
+ {r.referer || 'Direkt'} + {r.count} +
+ {/each} +
- {:else} -

No clicks yet

{/if}
-
+ {/if}
diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte deleted file mode 100644 index e9ae78d13..000000000 --- a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte +++ /dev/null @@ -1,403 +0,0 @@ - - -
-
-
-

Profile Cards

-
- - -
-
- - - {#if showStats} -
-
-

{userCards?.length || 0}

-

Total Cards

-

- {userCards?.filter((c) => c.page === 'profile').length || 0} on profile -

-
-
-

- {userCards?.filter((c) => c.metadata?.is_active !== false).length || 0} -

-

Active Cards

-
- -
- {/if} - - {#if loading} -
-

Loading cards...

-
- {:else if userCards.length > 0} -
-

Your Profile Cards

-

- Drag to reorder. Cards will appear in this order on your profile. -

- -
- {#each userCards as card, index} -
handleDragStart(e, index)} - ondragover={(e) => handleDragOver(e, index)} - ondragleave={handleDragLeave} - ondrop={(e) => handleDrop(e, index)} - ondragend={handleDragEnd} - > - -
- - - -
- - -
- - - -
- {#if card.page === 'profile'} - - On Profile - - {:else} - - Not on Profile - - {/if} - - {#if card.metadata?.is_active === false} - - Hidden - - {/if} -
- - -
- - - - - -
-
-
- {/each} -
-
- {:else} -
-

No cards yet

-

Create your first card to get started

- -
- {/if} -
-
- - -{#if showDeleteConfirm && cardToDelete} -
-
-

Delete Card

-

- Are you sure you want to delete this card? This action cannot be undone. -

-
- - -
-
-
-{/if} diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup deleted file mode 100644 index d5ad4e0e8..000000000 --- a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup +++ /dev/null @@ -1,482 +0,0 @@ - - -
-
-
-

Profile Cards

-
- - - -
-
- - - {#if showStats} -
-
-
-
-

{userCards?.length || 0}

-

Total Cards

-

{userCards?.filter(c => c.page === 'profile').length || 0} on profile

-
- - - -
-
- -
-
-
-

{userCards?.filter(c => c.metadata?.isActive !== false).length || 0}

-

Active Cards

-
- - - -
-
- - -
- {/if} - - - {#if showProfileAppearance} -
-

Profile Appearance

-
- - { - const color = e.currentTarget.value; - try { - const response = await fetch('/settings?/updateProfile', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - profileBackground: color, - name: data.user?.name || '', - email: data.user?.email || '', - bio: data.user?.bio || '', - location: data.user?.location || '', - website: data.user?.website || '', - github: data.user?.github || '', - twitter: data.user?.twitter || '', - linkedin: data.user?.linkedin || '', - instagram: data.user?.instagram || '' - }) - }); - if (response.ok) { - // Update local state - if (data.user) { - data.user.profileBackground = color; - } - } - } catch (error) { - console.error('Failed to update profile background:', error); - } - }} - class="h-10 w-20 cursor-pointer rounded border border-theme-border" - /> - - - Choose a color for your profile page background - -
-
- {/if} - - - {#if loading} -
-

Loading cards...

-
- {:else if userCards.length > 0} -
-

Your Profile Cards

-

- Drag to reorder. Cards will appear in this order on your profile. -

- -
- {#each userCards as card, index} - { - cardToDelete = cardId; - showDeleteConfirm = true; - }} - /> - {/each} -
-
- {:else} -
- - - -

No cards on your profile yet

-

- Create cards with our visual drag-and-drop builder -

- -
- {/if} -
-
- - -{#if showDeleteConfirm && cardToDelete} -
-
-

Delete Card

-

- Are you sure you want to delete this card? This action cannot be undone. -

-
- - -
-
-
-{/if} diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte deleted file mode 100644 index 0236961c8..000000000 --- a/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte +++ /dev/null @@ -1,676 +0,0 @@ - - - - Card Builder - uload - - -
- {#if loading} -
-
-
-

Lade Card...

-
-
- {:else} -
- -
-

- {editingCard ? 'Card bearbeiten' : 'Neue Card'} -

-
- - -
-
- - -
-
- -
- - {#if headerModule} -
- -
- {#if headerModule.props.avatar || userAvatarUrl} - Avatar - {:else} -
- - {(headerModule.props.title || 'U')[0].toUpperCase()} - -
- {/if} - -
- - - {#if editingTitle} - e.key === 'Enter' && saveTitle()} - class="mb-2 w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-2xl font-bold text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent" - autofocus - /> - {:else} -

- {headerModule.props.title || 'Klicke zum Bearbeiten'} -

- {/if} - - - {#if editingSubtitle} - e.key === 'Enter' && saveSubtitle()} - class="w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent" - autofocus - /> - {:else} -

- {headerModule.props.subtitle || 'Position hinzufügen'} -

- {/if} -
- {/if} - - - {#key card.config.modules} - {@const currentLinksModule = card.config.modules?.find((m) => m.type === 'links')} -
-
-

Deine Links

- -
- - {#if showLinkSelector} - -
- {#if loadingLinks} -

- Lade deine Links... -

- {:else if userLinks.length === 0} -
-

Du hast noch keine Links erstellt.

- - - - - Ersten Link erstellen - -
- {:else} -
-

- Wähle die Links aus, die auf deiner Card erscheinen sollen: -

- {#each userLinks as link} - - {/each} -
- {/if} -
- {:else} - - {#if currentLinksModule?.props?.links && currentLinksModule.props.links.length > 0} -
- {#each currentLinksModule.props.links as link} - - {link.icon} - {link.label} - - - - - {/each} -
- {:else} -
- - - -

Noch keine Links hinzugefügt

-

- Klicke auf "Links hinzufügen" um deine uload Links auszuwählen -

-
- {/if} - {/if} -
- {/key} -
- - -
-
- - -
-
-
-
-
- {/if} -
diff --git a/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte deleted file mode 100644 index fed3029ef..000000000 --- a/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
-
-

- PocketBase Debug Information -

- - {#if loading} -
-

Loading debug information...

-
- {:else if error} -
-

Error

-

{error}

-
- {:else if debugData} -
- -
-

User Information

-
-{JSON.stringify(debugData.user, null, 2)}
-
- - -
-

- PocketBase Connection -

-
-{JSON.stringify(debugData.pb, null, 2)}
-
- - -
-

Test Results

- {#each Object.entries(debugData.tests) as [testName, result]} -
-

- {testName}: {result.success ? '✅ Success' : '❌ Failed'} -

-
-{JSON.stringify(result, null, 2)}
-
- {/each} -
- - -
- Raw Debug Data -
-{JSON.stringify(debugData, null, 2)}
-
-
- {/if} -
-
diff --git a/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts b/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts deleted file mode 100644 index a0036f070..000000000 --- a/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { fail } from '@sveltejs/kit'; -import * as actions from './+page.server'; -import { pb, generateTagSlug, DEFAULT_TAG_COLORS } from '$lib/pocketbase'; -import { createTestTag, createTestUser } from '$tests/factories'; - -// Mock @sveltejs/kit -vi.mock('@sveltejs/kit', () => ({ - fail: vi.fn((status, data) => ({ status, data })), -})); - -// Mock PocketBase -vi.mock('$lib/pocketbase', () => ({ - pb: { - collection: vi.fn(), - }, - generateTagSlug: vi.fn((name) => name.toLowerCase().replace(/\s+/g, '-')), - DEFAULT_TAG_COLORS: ['#3B82F6', '#EF4444', '#10B981'], -})); - -describe('Tags Page Server Actions', () => { - let mockCollection: any; - let testUser: any; - - beforeEach(() => { - vi.clearAllMocks(); - - testUser = createTestUser({ - id: 'user123', - email: 'test@example.com', - }); - - // Setup mock collection methods - mockCollection = { - getList: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }; - - (pb.collection as any).mockReturnValue(mockCollection); - }); - - describe('load function', () => { - it('should load tags for authenticated user', async () => { - const mockTags = [ - createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' }), - createTestTag({ id: 'tag2', name: 'Personal', user_id: 'user123' }), - ]; - - mockCollection.getList - .mockResolvedValueOnce({ - items: mockTags, - totalItems: 2, - }) - .mockResolvedValue({ - items: [], - totalItems: 0, - }); - - const result = await actions.load({ - locals: { user: testUser }, - } as any); - - expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, { - filter: `user_id="user123"`, - sort: '-usage_count,name', - }); - - expect(result.tags).toHaveLength(2); - expect(result.tags[0]).toHaveProperty('linkCount', 0); - }); - - it('should return empty array on error', async () => { - mockCollection.getList.mockRejectedValue(new Error('Database error')); - - const result = await actions.load({ - locals: { user: testUser }, - } as any); - - expect(result.tags).toEqual([]); - }); - - it('should include link counts for each tag', async () => { - const mockTag = createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' }); - - mockCollection.getList - .mockResolvedValueOnce({ - items: [mockTag], - totalItems: 1, - }) - .mockResolvedValueOnce({ - items: [], - totalItems: 5, // 5 links using this tag - }); - - const result = await actions.load({ - locals: { user: testUser }, - } as any); - - expect(result.tags[0].linkCount).toBe(5); - }); - }); - - describe('create action', () => { - it('should create a new tag successfully', async () => { - const formData = new FormData(); - formData.append('name', 'New Tag'); - formData.append('color', '#3B82F6'); - formData.append('icon', '🏷️'); - formData.append('is_public', 'on'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - const expectedTag = { - id: 'new-tag-id', - name: 'New Tag', - slug: 'new-tag', - color: '#3B82F6', - icon: '🏷️', - user_id: 'user123', - is_public: true, - usage_count: 0, - }; - - mockCollection.create.mockResolvedValue(expectedTag); - - const result = await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(mockCollection.create).toHaveBeenCalledWith({ - name: 'New Tag', - slug: 'new-tag', - color: '#3B82F6', - icon: '🏷️', - user_id: 'user123', - is_public: true, - usage_count: 0, - }); - - expect(result).toEqual({ success: true, tag: expectedTag }); - }); - - it('should trim tag name', async () => { - const formData = new FormData(); - formData.append('name', ' Trimmed Tag '); - formData.append('color', '#3B82F6'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.create.mockResolvedValue({ id: 'tag-id' }); - - await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(mockCollection.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Trimmed Tag', - slug: 'trimmed-tag', - }) - ); - }); - - it('should use default color if not provided', async () => { - const formData = new FormData(); - formData.append('name', 'Tag'); - formData.append('color', ''); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.create.mockResolvedValue({ id: 'tag-id' }); - - await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(mockCollection.create).toHaveBeenCalledWith( - expect.objectContaining({ - color: DEFAULT_TAG_COLORS[0], - }) - ); - }); - - it('should handle is_public correctly', async () => { - const formData = new FormData(); - formData.append('name', 'Private Tag'); - // is_public not set (checkbox unchecked) - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.create.mockResolvedValue({ id: 'tag-id' }); - - await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(mockCollection.create).toHaveBeenCalledWith( - expect.objectContaining({ - is_public: false, - }) - ); - }); - - it('should fail if name is not provided', async () => { - const formData = new FormData(); - formData.append('name', ''); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - const result = await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Tag name is required' }); - expect(mockCollection.create).not.toHaveBeenCalled(); - }); - - it('should handle database errors', async () => { - const formData = new FormData(); - formData.append('name', 'Test Tag'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.create.mockRejectedValue(new Error('Database error')); - - const result = await actions.actions.create({ - request: mockRequest, - locals: { user: testUser }, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to create tag' }); - }); - }); - - describe('update action', () => { - it('should update tag successfully', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - formData.append('name', 'Updated Tag'); - formData.append('color', '#EF4444'); - formData.append('icon', '⭐'); - formData.append('is_public', 'on'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.update.mockResolvedValue({ id: 'tag123' }); - - const result = await actions.actions.update({ - request: mockRequest, - } as any); - - expect(mockCollection.update).toHaveBeenCalledWith('tag123', { - name: 'Updated Tag', - slug: 'updated-tag', - color: '#EF4444', - icon: '⭐', - is_public: true, - }); - - expect(result).toEqual({ updated: true }); - }); - - it('should fail if id is not provided', async () => { - const formData = new FormData(); - formData.append('name', 'Tag'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - const result = await actions.actions.update({ - request: mockRequest, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' }); - expect(mockCollection.update).not.toHaveBeenCalled(); - }); - - it('should fail if name is not provided', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - formData.append('name', ''); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - const result = await actions.actions.update({ - request: mockRequest, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' }); - }); - - it('should handle database errors', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - formData.append('name', 'Tag'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.update.mockRejectedValue(new Error('Database error')); - - const result = await actions.actions.update({ - request: mockRequest, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to update tag' }); - }); - }); - - describe('delete action', () => { - it('should delete tag and its relationships', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - // Mock linktags relationships - mockCollection.getList.mockResolvedValue({ - items: [{ id: 'link_tag_1' }, { id: 'link_tag_2' }], - }); - - mockCollection.delete.mockResolvedValue(true); - - const result = await actions.actions.delete({ - request: mockRequest, - } as any); - - // Should delete linktags first - expected(pb.collection).toHaveBeenCalledWith('linktags'); - expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, { - filter: `tag_id="tag123"`, - }); - expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_1'); - expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_2'); - - // Then delete the tag - expect(pb.collection).toHaveBeenCalledWith('tags'); - expect(mockCollection.delete).toHaveBeenCalledWith('tag123'); - - expect(result).toEqual({ deleted: true }); - }); - - it('should handle tags with no relationships', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.getList.mockResolvedValue({ - items: [], - }); - - mockCollection.delete.mockResolvedValue(true); - - const result = await actions.actions.delete({ - request: mockRequest, - } as any); - - expect(mockCollection.delete).toHaveBeenCalledTimes(1); - expect(mockCollection.delete).toHaveBeenCalledWith('tag123'); - expect(result).toEqual({ deleted: true }); - }); - - it('should fail if id is not provided', async () => { - const formData = new FormData(); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - const result = await actions.actions.delete({ - request: mockRequest, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID is required' }); - expect(mockCollection.delete).not.toHaveBeenCalled(); - }); - - it('should handle database errors', async () => { - const formData = new FormData(); - formData.append('id', 'tag123'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(formData), - }; - - mockCollection.getList.mockRejectedValue(new Error('Database error')); - - const result = await actions.actions.delete({ - request: mockRequest, - } as any); - - expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to delete tag' }); - }); - }); -}); diff --git a/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte b/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte index 40e052532..9aa9bf00f 100644 --- a/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte @@ -1,337 +1,52 @@ - - Preise - ulo.ad - - +
+

Preise

- - -
-
- -
-

Wähle deinen Plan

-

- Starte kostenlos und upgrade wenn du mehr brauchst -

- - {#if wasCancelled} -
- - - -
-

- Kein Problem! Du kannst jederzeit upgraden, wenn du bereit bist. -

-
-
- {/if} -
- - -
- {#each plans as plan} -
- {#if plan.popular} -
-
- - BELIEBT -
-
- {/if} - - -
-

{plan.name}

-
- {plan.price} - /{plan.period} -
- {#if plan.savings} -

- {plan.savings} -

- {/if} -
- - -
    - {#each plan.features as feature} -
  • - - {feature} -
  • - {/each} - {#each plan.limitations as limitation} -
  • - - {limitation} -
  • - {/each} -
- - -
- {#if plan.priceType === null} - - {:else} - - {/if} -
-
- {/each} -
- - -
-

Häufige Fragen

-
-
+
+ {#each plans as plan} +
+

{plan.name}

+

+ {plan.price === '0' ? 'Kostenlos' : `${plan.price}€/Monat`} +

+
    + {#each plan.features as feature} +
  • + + {feature} +
  • + {/each} +
+ {#if plan.current} +

Aktueller Plan

+ {:else} - {#if openFaq === 1} -
-

- Alle Pro-Pläne haben die gleichen Features, unterscheiden sich aber im Preis: - Monatlich (4,99€/Monat), Jährlich (39,99€/Jahr - spare 20€), oder Lifetime (129,99€ - einmalig - für immer Pro ohne weitere Zahlungen). -

-
- {/if} -
- -
- - {#if openFaq === 2} -
-

- Ja, du kannst jederzeit von Free zu Pro upgraden. Deine Links und Einstellungen - bleiben dabei erhalten. Du kannst auch zwischen den verschiedenen Pro-Plänen - wechseln. -

-
- {/if} -
- -
- - {#if openFaq === 3} -
-

- Der Lifetime-Plan (129,99€) amortisiert sich bereits nach etwa 2,2 Jahren im - Vergleich zum monatlichen Plan. Du erhältst alle Pro-Features für immer, ohne - weitere monatliche Gebühren und hast Zugang zu allen zukünftigen Features. -

-
- {/if} -
- -
- - {#if openFaq === 4} -
-

- Ja, du kannst dein Abo jederzeit in den Einstellungen kündigen. Du behältst den - Zugang bis zum Ende des aktuellen Abrechnungszeitraums. Danach wechselst du - automatisch zum Free Plan. -

-
- {/if} -
+ {/if}
-
+ {/each}
- -