mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
32
apps-archived/uload/.dockerignore
Normal file
32
apps-archived/uload/.dockerignore
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
!README.md
|
||||
!DEPLOYMENT.md
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
test-results
|
||||
e2e
|
||||
tests
|
||||
*.test.*
|
||||
*.spec.*
|
||||
playwright.config.*
|
||||
vitest.config.*
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
backend
|
||||
pb_hooks
|
||||
pb_migrations
|
||||
pocketbase
|
||||
mcp-servers
|
||||
*.sql.gz
|
||||
36
apps-archived/uload/.env.example
Normal file
36
apps-archived/uload/.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SvelteKit Configuration
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
NODE_ENV=production
|
||||
PUBLIC_APP_URL=https://ulo.ad
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Development: Use local Docker container
|
||||
DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
|
||||
# Production: Use your Coolify/Hetzner PostgreSQL container
|
||||
# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
|
||||
|
||||
# File Storage (Cloudflare R2)
|
||||
R2_ACCOUNT_ID=your_cloudflare_account_id
|
||||
R2_ACCESS_KEY_ID=your_r2_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_r2_secret_key
|
||||
R2_BUCKET_AVATARS=uload-avatars
|
||||
R2_BUCKET_QR=uload-qr-codes
|
||||
R2_PUBLIC_URL=https://files.ulo.ad
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_your_resend_api_key
|
||||
RESEND_FROM_EMAIL=noreply@ulo.ad
|
||||
|
||||
# Umami Analytics (optional)
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# External Auth (to be implemented)
|
||||
# AUTH_PROVIDER_CLIENT_ID=
|
||||
# AUTH_PROVIDER_CLIENT_SECRET=
|
||||
|
||||
# Coolify specific (if needed)
|
||||
# These will be set automatically by Coolify
|
||||
# COOLIFY_URL=
|
||||
# COOLIFY_TOKEN=
|
||||
20
apps-archived/uload/.env.production.example
Normal file
20
apps-archived/uload/.env.production.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# SvelteKit Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
PUBLIC_POCKETBASE_URL=https://your-domain.com/api
|
||||
|
||||
# PocketBase Admin Credentials
|
||||
# These will be used to create the admin on first startup
|
||||
POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
|
||||
POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
|
||||
|
||||
# Umami Analytics
|
||||
# Replace with your actual Umami instance and website ID
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# Optional: Additional Configuration
|
||||
# BODY_SIZE_LIMIT=512kb
|
||||
# PROTOCOL_HEADER=x-forwarded-proto
|
||||
# HOST_HEADER=x-forwarded-host
|
||||
17
apps-archived/uload/.env.stripe.example
Normal file
17
apps-archived/uload/.env.stripe.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Stripe Configuration
|
||||
# Copy this to .env.local or add to your .env file
|
||||
|
||||
# Stripe API Keys (get from https://dashboard.stripe.com/test/apikeys)
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
|
||||
STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
|
||||
|
||||
# Stripe Product & Price IDs (will be created automatically by Claude)
|
||||
STRIPE_PRODUCT_PRO=prod_xxx
|
||||
STRIPE_PRICE_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_YEARLY=price_xxx
|
||||
|
||||
# Stripe Webhook Secret (from webhook endpoint in dashboard)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# App URL for redirects
|
||||
PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
|
||||
43
apps-archived/uload/.gitignore
vendored
Normal file
43
apps-archived/uload/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Test results
|
||||
test-results
|
||||
|
||||
# Build output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# MCP Configuration with credentials
|
||||
.mcp.json
|
||||
.mcp.json-dev
|
||||
|
||||
# PocketBase
|
||||
backend/pocketbase
|
||||
backend/pb_data/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
132
apps-archived/uload/CLAUDE.md
Normal file
132
apps-archived/uload/CLAUDE.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
uLoad is a URL shortener and link management platform built with SvelteKit and PocketBase.
|
||||
|
||||
**Live:** https://ulo.ad
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
uload/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web application
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── routes/ # SvelteKit pages
|
||||
│ │ └── lib/ # Components, services, utilities
|
||||
│ ├── static/ # Static assets
|
||||
│ └── e2e/ # End-to-end tests
|
||||
├── backend/ # PocketBase configuration
|
||||
│ ├── pb_migrations/ # Database migrations
|
||||
│ └── pb_schema.json # Schema definition
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
All commands should be run from `uload/apps/web/`:
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm run dev # Start development server (http://localhost:5173)
|
||||
pnpm run preview # Preview production build locally
|
||||
```
|
||||
|
||||
### Build & Deploy
|
||||
|
||||
```bash
|
||||
pnpm run build # Create production build
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run format # Auto-format code with Prettier
|
||||
pnpm run lint # Run ESLint and Prettier checks
|
||||
pnpm run check # Run Svelte type checking
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pnpm run test # Run all tests (unit + e2e)
|
||||
pnpm run test:unit # Run unit tests with Vitest
|
||||
pnpm run test:e2e # Run end-to-end tests with Playwright
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
pnpm run db:generate # Generate Drizzle migrations
|
||||
pnpm run db:migrate # Run migrations
|
||||
pnpm run db:push # Push schema changes
|
||||
pnpm run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: SvelteKit v2.22 with Svelte 5.0
|
||||
- **Backend**: PocketBase (embedded SQLite)
|
||||
- **Database**: PostgreSQL via Drizzle ORM + Redis for caching
|
||||
- **Styling**: Tailwind CSS v4.0
|
||||
- **Testing**: Vitest + Playwright
|
||||
- **Payments**: Stripe
|
||||
- **Email**: Resend
|
||||
- **Storage**: Cloudflare R2
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Svelte 5 Runes Mode
|
||||
|
||||
- **NEVER use `$:` reactive statements** - use `$derived` instead
|
||||
- **NEVER use `let` for reactive values** - use `$state` for reactive state
|
||||
- **For side effects** - use `$effect` instead of `$:` statements
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Svelte 5 runes
|
||||
let headerModule = $derived(card.config.modules?.find((m) => m.type === 'header'));
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
console.log('Count changed:', count);
|
||||
});
|
||||
```
|
||||
|
||||
### PocketBase Usage
|
||||
|
||||
In server-side code (`+page.server.ts`, `+server.ts`):
|
||||
|
||||
- **ALWAYS use `locals.pb`** from the request context
|
||||
- The imported `pb` is for client-side only
|
||||
|
||||
```typescript
|
||||
// Server-side
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const items = await locals.pb.collection('items').getList();
|
||||
};
|
||||
|
||||
// Client-side
|
||||
import { pb } from '$lib/pocketbase';
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `R2_*` - Cloudflare R2 storage credentials
|
||||
- `RESEND_API_KEY` - Email service
|
||||
- `STRIPE_*` - Payment processing (see `.env.stripe.example`)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes for strings
|
||||
- 100 character line width
|
||||
- Prettier auto-sorts Tailwind classes
|
||||
73
apps-archived/uload/Dockerfile
Normal file
73
apps-archived/uload/Dockerfile
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# =============================================================================
|
||||
# uload Web Application Dockerfile
|
||||
# Multi-stage build for production deployment with Coolify
|
||||
#
|
||||
# IMPORTANT: This Dockerfile must be built from the MONOREPO ROOT, not from uload/
|
||||
# docker build -f uload/Dockerfile -t uload-web .
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
# Copy the uload web app
|
||||
COPY uload/apps/web/ ./uload/apps/web/
|
||||
|
||||
# Copy required shared packages
|
||||
COPY packages/shared-auth-ui/ ./packages/shared-auth-ui/
|
||||
COPY packages/shared-branding/ ./packages/shared-branding/
|
||||
|
||||
# Install dependencies with flat structure for Docker compatibility
|
||||
RUN pnpm install --filter @uload/web... --shamefully-hoist
|
||||
|
||||
# Build the app
|
||||
WORKDIR /app/uload/apps/web
|
||||
|
||||
# Note: RESEND_API_KEY is needed at build time for SvelteKit prerendering
|
||||
ENV RESEND_API_KEY=build_placeholder
|
||||
RUN pnpm build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Production Runner
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
# Security: Run as non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 sveltekit
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built app from the correct path
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/build ./build
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/package.json ./
|
||||
|
||||
# Copy hoisted node_modules from root (contains all deps with flat structure)
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Node server
|
||||
CMD ["node", "build"]
|
||||
151
apps-archived/uload/README.md
Normal file
151
apps-archived/uload/README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# uLoad - URL Shortener & Link Management
|
||||
|
||||
A modern URL shortener and link management platform built with SvelteKit and PocketBase.
|
||||
|
||||
## 🚀 Production
|
||||
|
||||
**Live:** https://ulo.ad
|
||||
**Admin:** https://ulo.ad/_/
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
- **Frontend:** SvelteKit 2.0 + Svelte 5
|
||||
- **Backend:** PocketBase (embedded)
|
||||
- **Styling:** Tailwind CSS 4.0
|
||||
- **Deployment:** Docker + Coolify on Hetzner VPS
|
||||
- **Database:** SQLite (via PocketBase)
|
||||
|
||||
## 📦 Features
|
||||
|
||||
- URL shortening with custom codes
|
||||
- QR code generation
|
||||
- Click analytics
|
||||
- User profiles (e.g., ulo.ad/p/username)
|
||||
- Link management dashboard
|
||||
- Real-time statistics
|
||||
|
||||
## 🏃 Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Start with PocketBase backend
|
||||
npm run dev:all
|
||||
|
||||
# Run tests
|
||||
npm run test
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build and run locally
|
||||
docker-compose up --build
|
||||
|
||||
# Access at:
|
||||
# Frontend: http://localhost:3000
|
||||
# PocketBase: http://localhost:8090
|
||||
```
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
- [Deployment Guide](./DEPLOYMENT.md) - Complete Coolify deployment instructions
|
||||
- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
|
||||
- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
|
||||
- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
ORIGIN=https://ulo.ad
|
||||
PUBLIC_POCKETBASE_URL=https://ulo.ad/api
|
||||
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||
POCKETBASE_ADMIN_PASSWORD=secure_password
|
||||
```
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
uload/
|
||||
├── src/ # SvelteKit application
|
||||
│ ├── routes/ # Pages and API routes
|
||||
│ ├── lib/ # Components and utilities
|
||||
│ └── app.html # HTML template
|
||||
├── backend/ # PocketBase configuration
|
||||
│ ├── pb_schema.json # Database schema
|
||||
│ └── init-pocketbase.sh # Setup script
|
||||
├── build/ # Production build output
|
||||
├── static/ # Static assets
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Local development
|
||||
├── supervisord.conf # Process management
|
||||
└── CLAUDE.md # AI assistant context
|
||||
```
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
The application is deployed on Hetzner VPS using Coolify with automatic deployments on push to main branch.
|
||||
|
||||
```bash
|
||||
# Commit and push to deploy
|
||||
git add .
|
||||
git commit -m "Update"
|
||||
git push origin main
|
||||
# Coolify automatically deploys
|
||||
```
|
||||
|
||||
### Manual Deployment Steps:
|
||||
|
||||
1. Set DNS A record to `91.99.221.179`
|
||||
2. Add domain in Coolify
|
||||
3. Update environment variables
|
||||
4. Enable SSL certificate
|
||||
5. Deploy application
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
- **Health Check:** https://ulo.ad/health
|
||||
- **Admin Panel:** https://ulo.ad/_/
|
||||
- **Server:** Hetzner CX21 (2 vCPU, 4GB RAM)
|
||||
- **Uptime:** 99.9% SLA
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- HTTPS enforced
|
||||
- Environment-based configuration
|
||||
- Secure admin authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Regular security updates
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
Common issues and solutions are documented in [DEPLOYMENT_LESSONS_LEARNED.md](./DEPLOYMENT_LESSONS_LEARNED.md)
|
||||
|
||||
For support, check:
|
||||
|
||||
- Application logs in Coolify
|
||||
- Health endpoint status
|
||||
- PocketBase admin panel
|
||||
|
||||
## 📄 License
|
||||
|
||||
Private - Memoro AI © 2024
|
||||
22
apps-archived/uload/apps/backend/.env.example
Normal file
22
apps-archived/uload/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3003
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
|
||||
# Redis (for caching)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-uload-app-id
|
||||
MANA_SERVICE_KEY=
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Short URL base (for generating short links)
|
||||
SHORT_URL_BASE=https://ulo.ad
|
||||
65
apps-archived/uload/apps/backend/Dockerfile
Normal file
65
apps-archived/uload/apps/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy workspace packages
|
||||
COPY packages/uload-database ./packages/uload-database
|
||||
|
||||
# Copy backend source
|
||||
COPY uload/apps/backend ./uload/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the database package first
|
||||
WORKDIR /app/packages/uload-database
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/uload/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/node_modules ./node_modules
|
||||
|
||||
# Copy database package (needed at runtime)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/dist ./node_modules/@manacore/uload-database/dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/package.json ./node_modules/@manacore/uload-database/
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
|
||||
# Start with dumb-init
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main"]
|
||||
8
apps-archived/uload/apps/backend/nest-cli.json
Normal file
8
apps-archived/uload/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
76
apps-archived/uload/apps/backend/package.json
Normal file
76
apps-archived/uload/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "@uload/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ULOAD URL Shortener Backend",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/uload-database": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^18.0.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-cls": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ua-parser-js": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
75
apps-archived/uload/apps/backend/src/app.module.ts
Normal file
75
apps-archived/uload/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ManaCoreModule } from '@mana-core/nestjs-integration';
|
||||
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { LinkRepository } from './database/repositories/link.repository';
|
||||
import { ClickRepository } from './database/repositories/click.repository';
|
||||
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { RedirectController } from './controllers/redirect.controller';
|
||||
import { LinksController } from './controllers/links.controller';
|
||||
import { AnalyticsController } from './controllers/analytics.controller';
|
||||
|
||||
import { LinksService } from './services/links.service';
|
||||
import { RedirectService } from './services/redirect.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Context-Local Storage for request-scoped data
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true, generateId: true },
|
||||
}),
|
||||
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: true,
|
||||
abortEarly: false,
|
||||
},
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
|
||||
// Mana Core Authentication
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL')!,
|
||||
appId: configService.get<string>('APP_ID')!,
|
||||
serviceKey: configService.get<string>('MANA_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}) as any,
|
||||
|
||||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [HealthController, RedirectController, LinksController, AnalyticsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
LinkRepository,
|
||||
ClickRepository,
|
||||
// Services
|
||||
LinksService,
|
||||
RedirectService,
|
||||
AnalyticsService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Add custom middleware here if needed
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import * as Joi from 'joi';
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
// Server
|
||||
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: Joi.string().uri().required(),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: Joi.string().default('localhost'),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||
|
||||
// Mana Core Auth
|
||||
MANA_SERVICE_URL: Joi.string().uri().required(),
|
||||
APP_ID: Joi.string().uuid().required(),
|
||||
MANA_SERVICE_KEY: Joi.string().allow('').optional(),
|
||||
|
||||
// Frontend
|
||||
FRONTEND_URL: Joi.string().uri().optional(),
|
||||
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
import { LinksService } from '../services/links.service';
|
||||
|
||||
@Controller('api/analytics')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly linksService: LinksService
|
||||
) {}
|
||||
|
||||
@Get('links/:linkId')
|
||||
async getLinkAnalytics(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('from') fromDate?: string,
|
||||
@Query('to') toDate?: string
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const stats = await this.analyticsService.getStats(
|
||||
linkId,
|
||||
fromDate ? new Date(fromDate) : undefined,
|
||||
toDate ? new Date(toDate) : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
shortCode: link.shortCode,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('links/:linkId/clicks')
|
||||
async getLinkClicks(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('limit') limit: number = 100
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const { clicks, total } = await this.analyticsService.getRecentClicks(linkId, limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
clicks: clicks.map((click) => ({
|
||||
...click,
|
||||
ipHash: undefined, // Don't expose IP hash
|
||||
})),
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: any) {
|
||||
const userId = user.sub;
|
||||
const totalLinks = await this.linksService.getLinkCount(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalLinks,
|
||||
// Add more overview stats as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthCheckService, HealthCheck, HealthCheckResult } from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'live',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../services/links.service';
|
||||
|
||||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
constructor(private readonly linksService: LinksService) {}
|
||||
|
||||
@Get()
|
||||
async getLinks(
|
||||
@CurrentUser() user: any,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
@Query('search') search?: string,
|
||||
@Query('isActive') isActive?: boolean
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const { items, total } = await this.linksService.getLinks(userId, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
links: items.map((link) => ({
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined, // Never send password to client
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.getLinkById(id, userId);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.createLink(userId, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLink(@CurrentUser() user: any, @Param('id') id: string, @Body() dto: UpdateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.updateLink(id, userId, dto);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const deleted = await this.linksService.deleteLink(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { Controller, Get, Post, Param, Body, Req, Res, HttpStatus, Query } from '@nestjs/common';
|
||||
import { Response, Request } from 'express';
|
||||
import { RedirectService } from '../services/redirect.service';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
|
||||
@Controller()
|
||||
export class RedirectController {
|
||||
constructor(
|
||||
private readonly redirectService: RedirectService,
|
||||
private readonly analyticsService: AnalyticsService
|
||||
) {}
|
||||
|
||||
@Get(':code')
|
||||
async redirect(
|
||||
@Param('code') code: string,
|
||||
@Query('utm_source') utmSource: string,
|
||||
@Query('utm_medium') utmMedium: string,
|
||||
@Query('utm_campaign') utmCampaign: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Skip for API and health routes
|
||||
if (code === 'v1' || code === 'health') {
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.redirectService.getRedirect(code);
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'not_found':
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'Link not found',
|
||||
});
|
||||
|
||||
case 'expired':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has expired',
|
||||
});
|
||||
|
||||
case 'inactive':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link is no longer active',
|
||||
});
|
||||
|
||||
case 'max_clicks':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has reached its maximum clicks',
|
||||
});
|
||||
|
||||
case 'password_required':
|
||||
return response.status(HttpStatus.OK).json({
|
||||
success: false,
|
||||
passwordRequired: true,
|
||||
linkId: result.linkId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Record click asynchronously (don't wait)
|
||||
this.analyticsService
|
||||
.recordClick(result.linkId!, {
|
||||
userAgent: request.headers['user-agent'] || '',
|
||||
referer: request.headers['referer'] || '',
|
||||
ip: request.ip,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
})
|
||||
.catch((err) => console.error('Failed to record click:', err));
|
||||
|
||||
// Perform redirect
|
||||
return response.redirect(302, result.targetUrl!);
|
||||
}
|
||||
|
||||
@Post(':code/unlock')
|
||||
async unlockLink(
|
||||
@Param('code') code: string,
|
||||
@Body('password') password: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const result = await this.redirectService.verifyPassword(code, password);
|
||||
|
||||
if (!result.success) {
|
||||
return response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { getDb, closeDb, type Database } from '@manacore/uload-database';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => {
|
||||
const logger = new Logger('DatabaseModule');
|
||||
logger.log('Initializing database connection');
|
||||
return getDb();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
clicks,
|
||||
type Click,
|
||||
type NewClick,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ClickStats {
|
||||
totalClicks: number;
|
||||
uniqueVisitors: number;
|
||||
topCountries: { country: string; count: number }[];
|
||||
topBrowsers: { browser: string; count: number }[];
|
||||
topDevices: { deviceType: string; count: number }[];
|
||||
clicksByDay: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClickRepository {
|
||||
private readonly logger = new Logger(ClickRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async create(data: NewClick): Promise<Click> {
|
||||
const result = await this.db.insert(clicks).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findByLinkId(
|
||||
linkId: string,
|
||||
options: { limit?: number; offset?: number } = {}
|
||||
): Promise<Click[]> {
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
return this.db
|
||||
.select()
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.orderBy(desc(clicks.clickedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async countByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
|
||||
const conditions = [eq(clicks.linkId, linkId)];
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(gte(clicks.clickedAt, fromDate));
|
||||
}
|
||||
if (toDate) {
|
||||
conditions.push(lte(clicks.clickedAt, toDate));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Total clicks
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Unique visitors (by IP hash)
|
||||
const uniqueResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${clicks.ipHash})::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Top countries
|
||||
const countriesResult = await this.db
|
||||
.select({
|
||||
country: clicks.country,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.country)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top browsers
|
||||
const browsersResult = await this.db
|
||||
.select({
|
||||
browser: clicks.browser,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.browser)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top devices
|
||||
const devicesResult = await this.db
|
||||
.select({
|
||||
deviceType: clicks.deviceType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.deviceType)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Clicks by day (last 30 days)
|
||||
const clicksByDayResult = await this.db
|
||||
.select({
|
||||
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date::text`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
totalClicks: totalResult[0]?.count || 0,
|
||||
uniqueVisitors: uniqueResult[0]?.count || 0,
|
||||
topCountries: countriesResult.map((r) => ({
|
||||
country: r.country || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topBrowsers: browsersResult.map((r) => ({
|
||||
browser: r.browser || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topDevices: devicesResult.map((r) => ({
|
||||
deviceType: r.deviceType || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
clicksByDay: clicksByDayResult.map((r) => ({
|
||||
date: r.date,
|
||||
count: r.count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.returning({ id: clicks.id });
|
||||
return result.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { LinkRepository, type ListLinksOptions } from './link.repository';
|
||||
export { ClickRepository, type ClickStats } from './click.repository';
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
links,
|
||||
type Link,
|
||||
type NewLink,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
or,
|
||||
ilike,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ListLinksOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkRepository {
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByShortCode(shortCode: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Link | null> {
|
||||
const result = await this.db.select().from(links).where(eq(links.id, id)).limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
options: ListLinksOptions = {}
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
const { page = 1, limit = 20, search, isActive } = options;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(links.userId, userId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(links.title, `%${search}%`),
|
||||
ilike(links.originalUrl, `%${search}%`),
|
||||
ilike(links.shortCode, `%${search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(eq(links.isActive, isActive));
|
||||
}
|
||||
|
||||
const [countResult, items] = await Promise.all([
|
||||
this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(and(...conditions)),
|
||||
this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(links.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: countResult[0]?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: NewLink): Promise<Link> {
|
||||
this.logger.debug(`Creating link: ${data.shortCode}`);
|
||||
const result = await this.db.insert(links).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Omit<NewLink, 'id' | 'userId' | 'createdAt'>>
|
||||
): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.update(links)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning({ id: links.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async incrementClickCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(links)
|
||||
.set({ clickCount: sql`${links.clickCount} + 1` })
|
||||
.where(eq(links.id, id));
|
||||
}
|
||||
|
||||
async isShortCodeAvailable(shortCode: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ id: links.id })
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(eq(links.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
47
apps-archived/uload/apps/backend/src/main.ts
Normal file
47
apps-archived/uload/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Global prefix for API routes (except health and redirect)
|
||||
app.setGlobalPrefix('v1', {
|
||||
exclude: ['health', 'health/(.*)', ':code'],
|
||||
});
|
||||
|
||||
const port = configService.get('PORT') || 3003;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`ULOAD Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as UAParser from 'ua-parser-js';
|
||||
import { ClickRepository, type ClickStats } from '../database/repositories';
|
||||
import { RedirectService } from './redirect.service';
|
||||
import type { NewClick } from '@manacore/uload-database';
|
||||
|
||||
export interface RecordClickData {
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
ip?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickRepository: ClickRepository,
|
||||
private readonly redirectService: RedirectService
|
||||
) {}
|
||||
|
||||
async recordClick(linkId: string, data: RecordClickData): Promise<void> {
|
||||
try {
|
||||
// Parse user agent
|
||||
const parser = new UAParser.UAParser(data.userAgent);
|
||||
const browser = parser.getBrowser();
|
||||
const os = parser.getOS();
|
||||
const device = parser.getDevice();
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = data.ip ? this.hashIp(data.ip) : null;
|
||||
|
||||
// Determine device type
|
||||
let deviceType = 'desktop';
|
||||
if (device.type === 'mobile') {
|
||||
deviceType = 'mobile';
|
||||
} else if (device.type === 'tablet') {
|
||||
deviceType = 'tablet';
|
||||
}
|
||||
|
||||
const clickData: NewClick = {
|
||||
linkId,
|
||||
ipHash,
|
||||
userAgent: data.userAgent,
|
||||
referer: data.referer,
|
||||
browser: browser.name || 'Unknown',
|
||||
deviceType,
|
||||
os: os.name || 'Unknown',
|
||||
// TODO: Geo lookup from IP
|
||||
country: null,
|
||||
city: null,
|
||||
utmSource: data.utmSource,
|
||||
utmMedium: data.utmMedium,
|
||||
utmCampaign: data.utmCampaign,
|
||||
};
|
||||
|
||||
await this.clickRepository.create(clickData);
|
||||
|
||||
// Increment click count on the link
|
||||
await this.redirectService.incrementClickCount(linkId);
|
||||
|
||||
this.logger.debug(`Recorded click for link ${linkId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to record click for link ${linkId}:`, error);
|
||||
// Don't throw - click recording should not block redirect
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
|
||||
return this.clickRepository.getStats(linkId, fromDate, toDate);
|
||||
}
|
||||
|
||||
async getRecentClicks(
|
||||
linkId: string,
|
||||
limit: number = 100
|
||||
): Promise<{ clicks: any[]; total: number }> {
|
||||
const [clicks, total] = await Promise.all([
|
||||
this.clickRepository.findByLinkId(linkId, { limit }),
|
||||
this.clickRepository.countByLinkId(linkId),
|
||||
]);
|
||||
|
||||
return { clicks, total };
|
||||
}
|
||||
|
||||
private hashIp(ip: string): string {
|
||||
// Simple hash for privacy - in production use a proper hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
const char = ip.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
137
apps-archived/uload/apps/backend/src/services/links.service.ts
Normal file
137
apps-archived/uload/apps/backend/src/services/links.service.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LinkRepository, type ListLinksOptions } from '../database/repositories';
|
||||
import type { Link, NewLink } from '@manacore/uload-database';
|
||||
|
||||
export interface CreateLinkDto {
|
||||
originalUrl: string;
|
||||
customCode?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
isActive?: boolean;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinksService {
|
||||
private readonly logger = new Logger(LinksService.name);
|
||||
private readonly shortUrlBase: string;
|
||||
|
||||
constructor(
|
||||
private readonly linkRepository: LinkRepository,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad');
|
||||
}
|
||||
|
||||
async createLink(userId: string, dto: CreateLinkDto): Promise<Link> {
|
||||
// Generate or validate short code
|
||||
let shortCode = dto.customCode;
|
||||
|
||||
if (shortCode) {
|
||||
// Validate custom code format
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) {
|
||||
throw new BadRequestException(
|
||||
'Custom code can only contain letters, numbers, hyphens and underscores'
|
||||
);
|
||||
}
|
||||
|
||||
// Check if custom code is available
|
||||
const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode);
|
||||
if (!isAvailable) {
|
||||
throw new BadRequestException('This custom code is already taken');
|
||||
}
|
||||
} else {
|
||||
// Generate random short code
|
||||
shortCode = nanoid(7);
|
||||
|
||||
// Make sure it's unique (very unlikely to collide, but check anyway)
|
||||
let attempts = 0;
|
||||
while (!(await this.linkRepository.isShortCodeAvailable(shortCode)) && attempts < 5) {
|
||||
shortCode = nanoid(7);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
const newLink: NewLink = {
|
||||
shortCode,
|
||||
customCode: dto.customCode,
|
||||
originalUrl: dto.originalUrl,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
userId,
|
||||
password: dto.password, // TODO: Hash password if provided
|
||||
maxClicks: dto.maxClicks,
|
||||
expiresAt: dto.expiresAt,
|
||||
tags: dto.tags,
|
||||
utmSource: dto.utmSource,
|
||||
utmMedium: dto.utmMedium,
|
||||
utmCampaign: dto.utmCampaign,
|
||||
workspaceId: dto.workspaceId,
|
||||
};
|
||||
|
||||
const link = await this.linkRepository.create(newLink);
|
||||
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async updateLink(id: string, userId: string, dto: UpdateLinkDto): Promise<Link | null> {
|
||||
const link = await this.linkRepository.update(id, userId, dto);
|
||||
|
||||
if (link) {
|
||||
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async deleteLink(id: string, userId: string): Promise<boolean> {
|
||||
const deleted = await this.linkRepository.delete(id, userId);
|
||||
|
||||
if (deleted) {
|
||||
this.logger.log(`Deleted link ${id} for user ${userId}`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getLinkById(id: string, userId: string): Promise<Link | null> {
|
||||
return this.linkRepository.findByIdAndUserId(id, userId);
|
||||
}
|
||||
|
||||
async getLinks(
|
||||
userId: string,
|
||||
options: ListLinksOptions
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
return this.linkRepository.findByUserId(userId, options);
|
||||
}
|
||||
|
||||
async getLinkCount(userId: string): Promise<number> {
|
||||
return this.linkRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LinkRepository } from '../database/repositories';
|
||||
import type { Link } from '@manacore/uload-database';
|
||||
|
||||
export interface RedirectResult {
|
||||
success: boolean;
|
||||
targetUrl?: string;
|
||||
linkId?: string;
|
||||
error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedirectService {
|
||||
private readonly logger = new Logger(RedirectService.name);
|
||||
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// Check if link is active
|
||||
if (!link.isActive) {
|
||||
return { success: false, error: 'inactive', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if link has expired
|
||||
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
|
||||
return { success: false, error: 'expired', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check max clicks
|
||||
if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) {
|
||||
return { success: false, error: 'max_clicks', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if password protected
|
||||
if (link.password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(shortCode: string, password: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// TODO: Compare hashed passwords
|
||||
if (link.password !== password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
}
|
||||
23
apps-archived/uload/apps/backend/tsconfig.json
Normal file
23
apps-archived/uload/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
20
apps-archived/uload/apps/landing/astro.config.mjs
Normal file
20
apps-archived/uload/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://ulo.ad',
|
||||
integrations: [
|
||||
tailwind(),
|
||||
mdx(),
|
||||
sitemap()
|
||||
],
|
||||
i18n: {
|
||||
defaultLocale: 'de',
|
||||
locales: ['de', 'en'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
});
|
||||
25
apps-archived/uload/apps/landing/package.json
Normal file
25
apps-archived/uload/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@uload/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
114
apps-archived/uload/apps/landing/src/components/Footer.astro
Normal file
114
apps-archived/uload/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
produkt: [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/#pricing', label: 'Preise' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
],
|
||||
unternehmen: [{ href: '/about', label: 'Über uns' }],
|
||||
rechtliches: [
|
||||
{ href: '/datenschutz', label: 'Datenschutz' },
|
||||
{ href: '/impressum', label: 'Impressum' },
|
||||
{ href: '/agb', label: 'AGB' },
|
||||
{ href: '/sicherheit', label: 'Sicherheit' },
|
||||
],
|
||||
};
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<footer class="bg-gray-900 text-gray-300">
|
||||
<div class="container-custom py-12 md:py-16">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-2 md:col-span-1">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-white">uLoad</span>
|
||||
</a>
|
||||
<p class="text-sm text-gray-400 mb-4">
|
||||
Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und
|
||||
analysieren Sie Klicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Produkt -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.produkt.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Unternehmen -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Unternehmen</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.unternehmen.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rechtliches -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.rechtliches.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="border-t border-gray-800 mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-sm text-gray-400">
|
||||
© {currentYear} uLoad. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={`${appUrl}/login`}
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
App öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
---
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<section
|
||||
class="relative overflow-hidden bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-primary-500/10 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<!-- Trust badges -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
></path>
|
||||
</svg>
|
||||
DSGVO-konform
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Blitzschnell
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
100% Sicher
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main headline -->
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
More than links.
|
||||
<span class="bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Your digital identity.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="mx-auto mb-8 max-w-2xl text-lg text-gray-600 sm:text-xl">
|
||||
Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
|
||||
beeindruckende Profilkarten und manage alles im Team.
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700 hover:shadow-xl"
|
||||
>
|
||||
Kostenlos starten →
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="rounded-lg border-2 border-gray-200 bg-white px-8 py-3 font-semibold text-gray-900 transition hover:border-primary-500 hover:shadow-lg"
|
||||
>
|
||||
Features entdecken
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Shortener teaser -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white/80 p-4 backdrop-blur sm:flex-row sm:p-2"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Deine lange URL hier einfügen..."
|
||||
disabled
|
||||
class="flex-1 rounded-lg border-0 bg-transparent px-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none sm:py-2"
|
||||
/>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition hover:bg-primary-700 sm:py-2 text-center"
|
||||
>
|
||||
Kürzen →
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual preview -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Link shortening preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Smart Links</h3>
|
||||
<p class="text-sm text-gray-600">Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz</p>
|
||||
<a
|
||||
href="/features"
|
||||
class="mt-4 inline-block text-xs text-primary-600 group-hover:underline"
|
||||
>
|
||||
Mehr erfahren →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Profile cards preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Profile Cards</h3>
|
||||
<p class="text-sm text-gray-600">Beeindruckende Profilseiten mit Drag & Drop Builder</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-purple-600 group-hover:underline">
|
||||
Templates ansehen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Team collaboration preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Team Workspace</h3>
|
||||
<p class="text-sm text-gray-600">Gemeinsam Links verwalten mit granularen Berechtigungen</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-green-600 group-hover:underline">
|
||||
Für Teams →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/about', label: 'Über uns' },
|
||||
];
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
|
||||
<nav class="container-custom">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">uLoad</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href={`${appUrl}/login`} class="text-gray-600 hover:text-gray-900 font-medium">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary"> Kostenlos starten </a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="md:hidden p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label="Menü öffnen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a href={link.href} class="text-gray-600 hover:text-gray-900 font-medium py-2">
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div class="flex flex-col gap-2 pt-4 border-t border-gray-100">
|
||||
<a href={`${appUrl}/login`} class="btn-secondary text-center"> Anmelden </a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary text-center"> Kostenlos starten </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
title: Der ultimative Link-Tracking Guide für 2024
|
||||
description: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
|
||||
pubDate: 2024-01-20
|
||||
author: Till Schneider
|
||||
tags: [tracking, analytics, dsgvo, marketing]
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
### 3. Bounce Rate
|
||||
|
||||
Wie viele Nutzer verlassen Ihre Seite sofort wieder?
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Die 5 UTM-Parameter
|
||||
|
||||
1. **utm_source**: Woher kommt der Traffic?
|
||||
2. **utm_medium**: Welches Medium?
|
||||
3. **utm_campaign**: Welche Kampagne?
|
||||
4. **utm_content**: Welcher spezifische Link?
|
||||
5. **utm_term**: Welches Keyword?
|
||||
|
||||
## DSGVO-konformes Tracking
|
||||
|
||||
### Was ist erlaubt?
|
||||
|
||||
✅ **Anonymisierte Daten**
|
||||
|
||||
- Gerätetyp
|
||||
- Browser
|
||||
- Ungefährer Standort
|
||||
- Referrer
|
||||
|
||||
### Was braucht Zustimmung?
|
||||
|
||||
❌ **Personenbezogene Daten**
|
||||
|
||||
- Vollständige IP-Adressen
|
||||
- Device Fingerprinting
|
||||
- Cross-Site Tracking
|
||||
|
||||
## Best Practices für Link-Tracking
|
||||
|
||||
### 1. Konsistente Namenskonvention
|
||||
|
||||
Entwickeln Sie ein einheitliches Schema für Ihre Kampagnen.
|
||||
|
||||
### 2. Dokumentation führen
|
||||
|
||||
Erstellen Sie eine Tracking-Tabelle für alle Kampagnen.
|
||||
|
||||
### 3. Regelmäßige Bereinigung
|
||||
|
||||
Löschen Sie alte, inaktive Links regelmäßig.
|
||||
|
||||
## 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.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
|
||||
description: 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.
|
||||
pubDate: 2024-01-15
|
||||
author: Till Schneider
|
||||
tags: [urls, psychology, conversion, marketing]
|
||||
---
|
||||
|
||||
**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.
|
||||
|
||||
Vergleichen Sie diese beiden URLs:
|
||||
|
||||
**Lange URL (schlecht):**
|
||||
|
||||
```
|
||||
https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024
|
||||
```
|
||||
|
||||
**Kurze URL (gut):**
|
||||
|
||||
```
|
||||
https://ulo.ad/summer-sale
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## Die Wissenschaft dahinter: Cognitive Load Theory
|
||||
|
||||
Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
|
||||
|
||||
## Die vier Säulen des Link-Vertrauens
|
||||
|
||||
1. **Erkennbare Domain (60% Wichtigkeit)** - Menschen wollen wissen, wo sie landen werden
|
||||
2. **Keine kryptischen Zeichen (25% Wichtigkeit)** - Zufällige Zahlen-Buchstaben-Kombinationen schrecken ab
|
||||
3. **Optimale Länge (10% Wichtigkeit)** - Die magische Grenze liegt bei etwa 50 Zeichen
|
||||
4. **HTTPS-Verschlüsselung (5% Wichtigkeit)** - Ein Hygienefaktor
|
||||
|
||||
## Praktische Optimierungsstrategien
|
||||
|
||||
### 1. Sprechende URLs verwenden
|
||||
|
||||
❌ **Schlecht:** `ulo.ad/p47829`
|
||||
✅ **Gut:** `ulo.ad/sommer-sale`
|
||||
|
||||
### 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
|
||||
|
||||
### 3. A/B-Testing ist Ihr Freund
|
||||
|
||||
Testen Sie verschiedene URL-Varianten und messen Sie die Performance.
|
||||
|
||||
## 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
|
||||
17
apps-archived/uload/apps/landing/src/content/config.ts
Normal file
17
apps-archived/uload/apps/landing/src/content/config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blogCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
author: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
blog: blogCollection,
|
||||
};
|
||||
2
apps-archived/uload/apps/landing/src/env.d.ts
vendored
Normal file
2
apps-archived/uload/apps/landing/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'uLoad - Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.',
|
||||
ogImage = '/og-image.png',
|
||||
} = Astro.props;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<title>{title} | uLoad</title>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<Navigation />
|
||||
<main class="flex-grow">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
const { title, description, lastUpdated } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<header class="mb-12">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
{lastUpdated && <p class="text-gray-500">Zuletzt aktualisiert: {lastUpdated}</p>}
|
||||
</header>
|
||||
|
||||
<div class="prose prose-lg prose-gray max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
130
apps-archived/uload/apps/landing/src/pages/about.astro
Normal file
130
apps-archived/uload/apps/landing/src/pages/about.astro
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const stats = [
|
||||
{ value: '10K+', label: 'Aktive Nutzer' },
|
||||
{ value: '500K+', label: 'Erstellte Links' },
|
||||
{ value: '2M+', label: 'Klicks verfolgt' },
|
||||
{ value: '99.9%', label: 'Uptime' },
|
||||
];
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Einfachheit',
|
||||
description:
|
||||
'Wir glauben, dass professionelle Tools nicht kompliziert sein müssen. uLoad ist intuitiv und sofort einsatzbereit.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Datenschutz',
|
||||
description:
|
||||
'Ihre Daten gehören Ihnen. Wir sind DSGVO-konform und speichern nur was wirklich notwendig ist.',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Performance',
|
||||
description:
|
||||
'Schnelle Links bedeuten bessere Nutzererfahrung. Unsere Infrastruktur ist auf Geschwindigkeit optimiert.',
|
||||
},
|
||||
{
|
||||
icon: '💪',
|
||||
title: 'Zuverlässigkeit',
|
||||
description:
|
||||
'Mit 99.9% Uptime können Sie sich auf uLoad verlassen - für jede Kampagne, jedes Projekt.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Über uns"
|
||||
description="Erfahren Sie mehr über uLoad - den intelligenten URL-Shortener für Profis."
|
||||
>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Links die verbinden
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
uLoad wurde entwickelt um Link-Management einfach, sicher und effektiv zu machen. Für
|
||||
Einzelpersonen, Teams und Unternehmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="bg-primary-600 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-white">{stat.value}</div>
|
||||
<div class="mt-1 text-primary-100">{stat.label}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Story -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Unsere Geschichte</h2>
|
||||
<div class="prose prose-lg mx-auto text-gray-600">
|
||||
<p>
|
||||
uLoad entstand aus einer einfachen Frustration: Bestehende URL-Shortener waren entweder zu
|
||||
kompliziert, zu teuer oder boten nicht die Features die moderne Teams brauchen.
|
||||
</p>
|
||||
<p>
|
||||
Wir wollten einen Service schaffen, der sowohl für Einsteiger als auch für Power-User
|
||||
funktioniert. Ein Tool das mit Ihren Anforderungen wächst - von der ersten verkürzten URL
|
||||
bis zum Enterprise-Einsatz.
|
||||
</p>
|
||||
<p>
|
||||
Heute nutzen tausende Nutzer uLoad täglich für ihre Marketing-Kampagnen,
|
||||
Social-Media-Posts und geschäftliche Kommunikation. Und wir arbeiten jeden Tag daran,
|
||||
uLoad noch besser zu machen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values -->
|
||||
<section class="bg-gray-50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">Unsere Werte</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{
|
||||
values.map((value) => (
|
||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
||||
<div class="mb-4 text-4xl">{value.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{value.title}</h3>
|
||||
<p class="text-sm text-gray-600">{value.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-gray-900">Werden Sie Teil der uLoad Community</h2>
|
||||
<p class="mb-8 text-lg text-gray-600">Schließen Sie sich tausenden zufriedenen Nutzern an.</p>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
76
apps-archived/uload/apps/landing/src/pages/agb.astro
Normal file
76
apps-archived/uload/apps/landing/src/pages/agb.astro
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Allgemeine Geschäftsbedingungen" lastUpdated="Januar 2024">
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
<p>
|
||||
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Verträge zwischen uLoad und dem
|
||||
Nutzer über die Nutzung der auf der Website ulo.ad angebotenen Dienste.
|
||||
</p>
|
||||
|
||||
<h2>§ 2 Leistungsbeschreibung</h2>
|
||||
<p>
|
||||
uLoad bietet einen URL-Verkürzungsdienst sowie ergänzende Dienste wie Analytics,
|
||||
QR-Code-Generierung und Team-Workspaces an. Der genaue Leistungsumfang ergibt sich aus der
|
||||
jeweiligen Produktbeschreibung zum Zeitpunkt der Bestellung.
|
||||
</p>
|
||||
|
||||
<h2>§ 3 Registrierung und Nutzerkonto</h2>
|
||||
<p>
|
||||
Für die Nutzung bestimmter Funktionen ist eine Registrierung erforderlich. Der Nutzer
|
||||
verpflichtet sich, wahrheitsgemäße Angaben zu machen und diese aktuell zu halten. Der Nutzer ist
|
||||
für die Geheimhaltung seiner Zugangsdaten verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>§ 4 Nutzungsregeln</h2>
|
||||
<p>
|
||||
Der Nutzer verpflichtet sich, den Dienst nicht für rechtswidrige Zwecke zu nutzen. Insbesondere
|
||||
ist es untersagt:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Links zu illegalen Inhalten zu erstellen</li>
|
||||
<li>Spam oder Phishing-Links zu verbreiten</li>
|
||||
<li>Die Dienste für automatisierte Massenanfragen zu missbrauchen</li>
|
||||
<li>Andere Nutzer zu belästigen oder zu täuschen</li>
|
||||
</ul>
|
||||
|
||||
<h2>§ 5 Preise und Zahlung</h2>
|
||||
<p>
|
||||
Die Nutzung der Basisfunktionen ist kostenlos. Für erweiterte Funktionen können kostenpflichtige
|
||||
Abonnements abgeschlossen werden. Alle Preise verstehen sich inklusive der gesetzlichen
|
||||
Mehrwertsteuer.
|
||||
</p>
|
||||
|
||||
<h2>§ 6 Kündigung</h2>
|
||||
<p>
|
||||
Kostenlose Konten können jederzeit gelöscht werden. Kostenpflichtige Abonnements können zum Ende
|
||||
der jeweiligen Abrechnungsperiode gekündigt werden.
|
||||
</p>
|
||||
|
||||
<h2>§ 7 Haftung</h2>
|
||||
<p>
|
||||
uLoad haftet nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen.
|
||||
Die Haftung für leichte Fahrlässigkeit ist ausgeschlossen, soweit nicht wesentliche
|
||||
Vertragspflichten verletzt wurden.
|
||||
</p>
|
||||
|
||||
<h2>§ 8 Datenschutz</h2>
|
||||
<p>
|
||||
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung und den
|
||||
geltenden Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>§ 9 Änderungen der AGB</h2>
|
||||
<p>
|
||||
uLoad behält sich vor, diese AGB jederzeit zu ändern. Änderungen werden dem Nutzer rechtzeitig
|
||||
mitgeteilt. Mit der weiteren Nutzung des Dienstes nach Inkrafttreten der Änderungen erklärt sich
|
||||
der Nutzer mit diesen einverstanden.
|
||||
</p>
|
||||
|
||||
<h2>§ 10 Schlussbestimmungen</h2>
|
||||
<p>
|
||||
Es gilt das Recht der Bundesrepublik Deutschland. Sollten einzelne Bestimmungen dieser AGB
|
||||
unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
94
apps-archived/uload/apps/landing/src/pages/blog/[slug].astro
Normal file
94
apps-archived/uload/apps/landing/src/pages/blog/[slug].astro
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = { post: CollectionEntry<'blog'> };
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-12">
|
||||
<a
|
||||
href="/blog"
|
||||
class="inline-flex items-center gap-2 text-sm text-primary-600 hover:underline mb-6"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Zurück zum Blog
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl mb-4">
|
||||
{post.data.title}
|
||||
</h1>
|
||||
<div class="flex items-center gap-4 text-gray-500">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{
|
||||
post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="prose prose-lg prose-gray max-w-none prose-headings:font-bold prose-a:text-primary-600 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-16 pt-8 border-t border-gray-200">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<a href="/blog" class="text-primary-600 hover:underline"> ← Alle Artikel </a>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-6 py-2 font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt uLoad testen
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
69
apps-archived/uload/apps/landing/src/pages/blog/index.astro
Normal file
69
apps-archived/uload/apps/landing/src/pages/blog/index.astro
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const posts = (await getCollection('blog')).sort(
|
||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
|
||||
);
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Blog"
|
||||
description="Tipps, Tricks und Best Practices rund um Link-Management, URL-Verkürzung und digitales Marketing."
|
||||
>
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">Blog</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Tipps, Tricks und Best Practices rund um Link-Management und digitales Marketing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<article class="group rounded-xl border border-gray-200 bg-white overflow-hidden transition hover:shadow-xl">
|
||||
<a href={`/blog/${post.slug}`} class="block">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p class="text-gray-600 line-clamp-3">{post.data.description}</p>
|
||||
{post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
91
apps-archived/uload/apps/landing/src/pages/datenschutz.astro
Normal file
91
apps-archived/uload/apps/landing/src/pages/datenschutz.astro
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Datenschutzerklärung" lastUpdated="Januar 2024">
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
|
||||
<h3>Allgemeine Hinweise</h3>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
|
||||
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit
|
||||
denen Sie persönlich identifiziert werden können.
|
||||
</p>
|
||||
|
||||
<h3>Datenerfassung auf dieser Website</h3>
|
||||
<p>
|
||||
<strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong><br />
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten
|
||||
können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
|
||||
<h3>Wie erfassen wir Ihre Daten?</h3>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich
|
||||
z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
|
||||
</p>
|
||||
<p>
|
||||
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das
|
||||
sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des
|
||||
Seitenaufrufs).
|
||||
</p>
|
||||
|
||||
<h2>2. Hosting</h2>
|
||||
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
|
||||
<p>
|
||||
Die Server befinden sich in Deutschland und unterliegen den strengen deutschen
|
||||
Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
|
||||
|
||||
<h3>Datenschutz</h3>
|
||||
<p>
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln
|
||||
Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
|
||||
Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
</p>
|
||||
|
||||
<h3>Hinweis zur verantwortlichen Stelle</h3>
|
||||
<p>
|
||||
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist im Impressum
|
||||
genannt.
|
||||
</p>
|
||||
|
||||
<h2>4. Datenerfassung auf dieser Website</h2>
|
||||
|
||||
<h3>Cookies</h3>
|
||||
<p>
|
||||
Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Datenpakete und
|
||||
richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer
|
||||
einer Sitzung (Session-Cookies) oder dauerhaft (permanente Cookies) auf Ihrem Endgerät
|
||||
gespeichert.
|
||||
</p>
|
||||
|
||||
<h3>Server-Log-Dateien</h3>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
|
||||
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse (anonymisiert)</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer
|
||||
gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die
|
||||
Berichtigung oder Löschung dieser Daten zu verlangen.
|
||||
</p>
|
||||
|
||||
<h2>6. Kontakt</h2>
|
||||
<p>
|
||||
Bei Fragen zum Datenschutz können Sie sich jederzeit an uns wenden. Die Kontaktdaten finden Sie
|
||||
im Impressum.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
169
apps-archived/uload/apps/landing/src/pages/features.astro
Normal file
169
apps-archived/uload/apps/landing/src/pages/features.astro
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
|
||||
const featureCategories = [
|
||||
{
|
||||
title: 'Link Management',
|
||||
features: [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'URL-Verkürzung',
|
||||
description:
|
||||
'Verwandeln Sie lange URLs in kurze, merkbare Links. Perfekt für Social Media, E-Mails und gedruckte Materialien.',
|
||||
},
|
||||
{
|
||||
icon: '✏️',
|
||||
title: 'Custom Short Codes',
|
||||
description:
|
||||
'Erstellen Sie personalisierte Kurz-URLs wie ulo.ad/mein-link für bessere Wiedererkennung.',
|
||||
},
|
||||
{
|
||||
icon: '📅',
|
||||
title: 'Ablaufdatum',
|
||||
description:
|
||||
'Setzen Sie automatische Ablaufdaten für zeitlich begrenzte Aktionen und Kampagnen.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Passwortschutz',
|
||||
description: 'Schützen Sie sensible Links mit Passwörtern für zusätzliche Sicherheit.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Analytics & Tracking',
|
||||
features: [
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Klick-Tracking',
|
||||
description: 'Verfolgen Sie jeden Klick in Echtzeit mit detaillierten Statistiken.',
|
||||
},
|
||||
{
|
||||
icon: '🌍',
|
||||
title: 'Geografische Daten',
|
||||
description: 'Sehen Sie woher Ihre Besucher kommen mit Länder- und Städte-Aufschlüsselung.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Geräte-Analyse',
|
||||
description:
|
||||
'Erfahren Sie welche Geräte, Browser und Betriebssysteme Ihre Nutzer verwenden.',
|
||||
},
|
||||
{
|
||||
icon: '📈',
|
||||
title: 'Referrer-Tracking',
|
||||
description:
|
||||
'Identifizieren Sie die Quellen Ihres Traffics für bessere Marketing-Entscheidungen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'QR-Codes',
|
||||
features: [
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Anpassbare Designs',
|
||||
description: 'Erstellen Sie QR-Codes in Ihren Markenfarben für konsistentes Branding.',
|
||||
},
|
||||
{
|
||||
icon: '📐',
|
||||
title: 'Multiple Formate',
|
||||
description: 'Download in PNG, SVG oder PDF für verschiedene Anwendungsfälle.',
|
||||
},
|
||||
{
|
||||
icon: '⬇️',
|
||||
title: 'Hochauflösend',
|
||||
description: 'Druckqualität bis zu 4000x4000 Pixel für großformatige Medien.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Team & Kollaboration',
|
||||
features: [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description: 'Erstellen Sie gemeinsame Arbeitsbereiche für Ihr Team oder Ihre Kunden.',
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
title: 'Rollenbasierte Rechte',
|
||||
description: 'Definieren Sie wer Links erstellen, bearbeiten oder nur ansehen darf.',
|
||||
},
|
||||
{
|
||||
icon: '🏷️',
|
||||
title: 'Tag-System',
|
||||
description: 'Organisieren Sie Links mit Tags für bessere Übersicht in großen Teams.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Features"
|
||||
description="Entdecken Sie alle Features von uLoad - URL-Verkürzung, Analytics, QR-Codes und Team-Kollaboration."
|
||||
>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Features die den Unterschied machen
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Von einfacher URL-Verkürzung bis hin zu detaillierten Analytics – uLoad bietet alles was
|
||||
Profis brauchen.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Categories -->
|
||||
{
|
||||
featureCategories.map((category, idx) => (
|
||||
<section
|
||||
class:list={['px-4 py-16 sm:px-6 lg:px-8', idx % 2 === 1 ? 'bg-gray-50' : 'bg-white']}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">{category.title}</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{category.features.map((feature) => (
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-lg">
|
||||
<div class="mb-4 text-4xl">{feature.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{feature.title}</h3>
|
||||
<p class="text-sm text-gray-600">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="bg-primary-600 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-white">Bereit loszulegen?</h2>
|
||||
<p class="mb-8 text-lg text-primary-100">
|
||||
Starten Sie kostenlos und entdecken Sie alle Features selbst.
|
||||
</p>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition hover:bg-gray-100"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
63
apps-archived/uload/apps/landing/src/pages/impressum.astro
Normal file
63
apps-archived/uload/apps/landing/src/pages/impressum.astro
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Impressum">
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
|
||||
<p>
|
||||
<strong>uLoad</strong><br />
|
||||
[Ihr Name / Firmenname]<br />
|
||||
[Straße und Hausnummer]<br />
|
||||
[PLZ Ort]<br />
|
||||
Deutschland
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>E-Mail: kontakt@ulo.ad</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>
|
||||
[Ihr Name]<br />
|
||||
[Adresse wie oben]
|
||||
</p>
|
||||
|
||||
<h2>EU-Streitschlichtung</h2>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener"
|
||||
>https://ec.europa.eu/consumers/odr/</a
|
||||
>
|
||||
</p>
|
||||
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
|
||||
|
||||
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
|
||||
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch
|
||||
nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
|
||||
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss
|
||||
haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die
|
||||
Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
|
||||
verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
|
||||
jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
235
apps-archived/uload/apps/landing/src/pages/index.astro
Normal file
235
apps-archived/uload/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import HeroSection from '../components/HeroSection.astro';
|
||||
|
||||
// Shared components
|
||||
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
|
||||
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
|
||||
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
|
||||
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
|
||||
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'Smart Links',
|
||||
description:
|
||||
'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description:
|
||||
'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'QR-Code Generator',
|
||||
description:
|
||||
'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.',
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Profile Cards',
|
||||
description:
|
||||
'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.',
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description:
|
||||
'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.',
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: 'API & Integrationen',
|
||||
description:
|
||||
'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Link einfügen',
|
||||
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
|
||||
image: '/screenshots/paste.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Anpassen',
|
||||
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
|
||||
image: '/screenshots/customize.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Teilen & Tracken',
|
||||
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
|
||||
image: '/screenshots/share.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Links pro Monat', included: true },
|
||||
{ text: 'Basis Analytics', included: true },
|
||||
{ text: 'QR-Code Generator', included: true },
|
||||
{ text: 'Link Anpassung', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: false },
|
||||
{ text: 'Team Features', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: `${appUrl}/register`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '4,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Freelancer & Creators',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Passwortschutz', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro wählen',
|
||||
href: `${appUrl}/register?plan=pro`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro Jährlich',
|
||||
price: '3,33',
|
||||
period: '/Monat',
|
||||
description: 'Spare 20€ pro Jahr',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Jährlich sparen',
|
||||
href: `${appUrl}/register?plan=pro-yearly`,
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Spare 20€',
|
||||
},
|
||||
{
|
||||
name: 'Lifetime',
|
||||
price: '129,99',
|
||||
period: 'einmalig',
|
||||
description: 'Einmal zahlen, für immer nutzen',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Lebenslanger Zugang', included: true },
|
||||
{ text: 'Alle zukünftigen Features', included: true },
|
||||
{ text: 'Early Access', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Keine Abo-Gebühren', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Lifetime sichern',
|
||||
href: `${appUrl}/register?plan=lifetime`,
|
||||
},
|
||||
badge: 'Einmalig',
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie lange bleiben meine Links aktiv?',
|
||||
answer:
|
||||
'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine eigene Domain verwenden?',
|
||||
answer:
|
||||
'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).',
|
||||
},
|
||||
{
|
||||
question: 'Wie funktionieren die Analytics?',
|
||||
answer:
|
||||
'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.',
|
||||
},
|
||||
{
|
||||
question: 'Was sind Profile Cards?',
|
||||
answer:
|
||||
'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.',
|
||||
},
|
||||
{
|
||||
question: 'Gibt es eine API?',
|
||||
answer:
|
||||
'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer:
|
||||
'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Intelligenter URL-Shortener">
|
||||
<HeroSection />
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du für professionelles Link-Management brauchst"
|
||||
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
/>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum perfekten Link"
|
||||
subtitle="So einfach funktioniert uLoad"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Transparente Preise, keine versteckten Kosten"
|
||||
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
|
||||
plans={pricingPlans}
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über uLoad wissen musst"
|
||||
faqs={faqs}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="cta"
|
||||
title="Bereit für smarte Links?"
|
||||
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
|
||||
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
|
||||
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
|
||||
variant="default"
|
||||
/>
|
||||
</BaseLayout>
|
||||
202
apps-archived/uload/apps/landing/src/pages/sicherheit.astro
Normal file
202
apps-archived/uload/apps/landing/src/pages/sicherheit.astro
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Sicherheit" lastUpdated="November 2024">
|
||||
<div class="rounded-lg bg-green-50 p-4 text-green-800 mb-8">
|
||||
<p class="font-semibold">Ihre Sicherheit ist unsere Priorität</p>
|
||||
<p class="mt-1">
|
||||
Bei uload setzen wir modernste Sicherheitsstandards ein, um Ihre Daten und Links zu schützen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Verschlüsselung</h2>
|
||||
|
||||
<h3>SSL/TLS-Verschlüsselung</h3>
|
||||
<p>
|
||||
Alle Datenübertragungen zwischen Ihrem Browser und unseren Servern sind durch moderne
|
||||
SSL/TLS-Verschlüsselung geschützt. Wir verwenden ausschließlich TLS 1.3 und TLS 1.2 mit starken
|
||||
Cipher-Suites.
|
||||
</p>
|
||||
|
||||
<h3>Verschlüsselte Speicherung</h3>
|
||||
<p>
|
||||
Sensible Daten wie Passwörter werden mit branchenführenden Verschlüsselungsalgorithmen (bcrypt
|
||||
mit Salt) gespeichert. Selbst im unwahrscheinlichen Fall eines Datenlecks bleiben Ihre
|
||||
Passwörter geschützt.
|
||||
</p>
|
||||
|
||||
<h3>Ende-zu-Ende Verschlüsselung für Premium-Nutzer</h3>
|
||||
<p>
|
||||
Premium-Nutzer können optionale Ende-zu-Ende-Verschlüsselung für besonders sensible Links
|
||||
aktivieren. Diese Links können nur mit dem richtigen Schlüssel entschlüsselt werden.
|
||||
</p>
|
||||
|
||||
<h2>Authentifizierung & Zugriffskontrolle</h2>
|
||||
|
||||
<h3>Sichere Authentifizierung</h3>
|
||||
<ul>
|
||||
<li>Starke Passwort-Anforderungen (mindestens 8 Zeichen, Groß-/Kleinbuchstaben, Zahlen)</li>
|
||||
<li>Zwei-Faktor-Authentifizierung (2FA) verfügbar</li>
|
||||
<li>Automatische Sitzungsbeendigung nach Inaktivität</li>
|
||||
<li>Schutz vor Brute-Force-Angriffen durch Rate-Limiting</li>
|
||||
</ul>
|
||||
|
||||
<h3>Passwortgeschützte Links</h3>
|
||||
<p>
|
||||
Erstellen Sie passwortgeschützte Links für zusätzliche Sicherheit. Nur Personen mit dem
|
||||
korrekten Passwort können auf die Ziel-URL zugreifen.
|
||||
</p>
|
||||
|
||||
<h3>IP-Whitelisting für Enterprise</h3>
|
||||
<p>
|
||||
Enterprise-Kunden können IP-Whitelisting aktivieren, um den Zugriff auf ihre Links nur von
|
||||
bestimmten IP-Adressen oder IP-Bereichen zu erlauben.
|
||||
</p>
|
||||
|
||||
<h2>Infrastruktur-Sicherheit</h2>
|
||||
|
||||
<h3>Hosting & Server</h3>
|
||||
<ul>
|
||||
<li>Hosting in ISO 27001 zertifizierten Rechenzentren</li>
|
||||
<li>Redundante Server-Architektur für maximale Verfügbarkeit</li>
|
||||
<li>Regelmäßige Sicherheitsupdates und Patches</li>
|
||||
<li>24/7 Überwachung der Systemintegrität</li>
|
||||
</ul>
|
||||
|
||||
<h3>DDoS-Schutz</h3>
|
||||
<p>
|
||||
Unser Service ist durch einen fortschrittlichen DDoS-Schutz abgesichert, der Angriffe
|
||||
automatisch erkennt und abwehrt, um die Verfügbarkeit unseres Dienstes zu gewährleisten.
|
||||
</p>
|
||||
|
||||
<h3>Web Application Firewall (WAF)</h3>
|
||||
<p>
|
||||
Eine Web Application Firewall schützt vor gängigen Web-Angriffen wie SQL-Injection,
|
||||
Cross-Site-Scripting (XSS) und anderen OWASP Top 10 Bedrohungen.
|
||||
</p>
|
||||
|
||||
<h2>Überwachung & Schutz</h2>
|
||||
|
||||
<h3>Malware & Phishing-Schutz</h3>
|
||||
<p>
|
||||
Alle erstellten Links werden automatisch gegen bekannte Malware- und Phishing-Datenbanken
|
||||
geprüft. Verdächtige Links werden blockiert und zur manuellen Überprüfung markiert.
|
||||
</p>
|
||||
|
||||
<h3>Echtzeit-Überwachung</h3>
|
||||
<ul>
|
||||
<li>Kontinuierliche Überwachung auf verdächtige Aktivitäten</li>
|
||||
<li>Automatische Erkennung von Missbrauchsmustern</li>
|
||||
<li>Sofortige Benachrichtigung bei Sicherheitsvorfällen</li>
|
||||
<li>Detaillierte Audit-Logs für Enterprise-Kunden</li>
|
||||
</ul>
|
||||
|
||||
<h3>Link-Validierung</h3>
|
||||
<p>
|
||||
Regelmäßige Überprüfung aller Ziel-URLs auf Verfügbarkeit und Sicherheit. Gefährliche oder
|
||||
kompromittierte Websites werden automatisch blockiert.
|
||||
</p>
|
||||
|
||||
<h2>Datenschutz & Compliance</h2>
|
||||
|
||||
<h3>DSGVO-Konformität</h3>
|
||||
<p>
|
||||
Vollständige Einhaltung der Datenschutz-Grundverordnung (DSGVO). Sie haben jederzeit die volle
|
||||
Kontrolle über Ihre Daten mit Rechten auf Auskunft, Berichtigung und Löschung.
|
||||
</p>
|
||||
|
||||
<h3>Datensparsamkeit</h3>
|
||||
<p>
|
||||
Wir sammeln nur die minimal notwendigen Daten für den Betrieb unseres Services. Keine unnötige
|
||||
Datensammlung oder -weitergabe an Dritte.
|
||||
</p>
|
||||
|
||||
<h3>Regelmäßige Audits</h3>
|
||||
<p>
|
||||
Unabhängige Sicherheitsaudits und Penetrationstests werden regelmäßig durchgeführt, um höchste
|
||||
Sicherheitsstandards zu gewährleisten.
|
||||
</p>
|
||||
|
||||
<h2>Backup & Wiederherstellung</h2>
|
||||
|
||||
<h3>Automatische Backups</h3>
|
||||
<ul>
|
||||
<li>Tägliche automatische Backups aller Daten</li>
|
||||
<li>Geografisch verteilte Backup-Speicherung</li>
|
||||
<li>Verschlüsselte Backup-Archive</li>
|
||||
<li>Regelmäßige Wiederherstellungstests</li>
|
||||
</ul>
|
||||
|
||||
<h3>Disaster Recovery</h3>
|
||||
<p>
|
||||
Umfassender Disaster-Recovery-Plan mit RPO (Recovery Point Objective) von maximal 24 Stunden und
|
||||
RTO (Recovery Time Objective) von maximal 4 Stunden.
|
||||
</p>
|
||||
|
||||
<h2>Ihre Verantwortung</h2>
|
||||
|
||||
<h3>Best Practices für Nutzer</h3>
|
||||
<ul>
|
||||
<li>Verwenden Sie starke, einzigartige Passwörter</li>
|
||||
<li>Aktivieren Sie die Zwei-Faktor-Authentifizierung</li>
|
||||
<li>Teilen Sie Ihre Zugangsdaten niemals mit anderen</li>
|
||||
<li>Melden Sie verdächtige Aktivitäten sofort</li>
|
||||
<li>Halten Sie Ihre Kontaktinformationen aktuell</li>
|
||||
<li>Überprüfen Sie regelmäßig Ihre Account-Aktivitäten</li>
|
||||
</ul>
|
||||
|
||||
<h2>Sicherheitsvorfälle melden</h2>
|
||||
|
||||
<h3>Verantwortungsvolle Offenlegung</h3>
|
||||
<p>
|
||||
Wir schätzen die Arbeit von Sicherheitsforschern. Wenn Sie eine Sicherheitslücke entdecken,
|
||||
melden Sie diese bitte verantwortungsvoll an:
|
||||
</p>
|
||||
<p class="font-mono bg-gray-100 p-3 rounded-lg mt-2">security@uload.de</p>
|
||||
<p class="mt-2">
|
||||
Bitte geben Sie uns angemessene Zeit zur Behebung, bevor Sie die Schwachstelle öffentlich
|
||||
machen.
|
||||
</p>
|
||||
|
||||
<h3>Bug Bounty Programm</h3>
|
||||
<p>
|
||||
Für kritische Sicherheitslücken bieten wir Belohnungen im Rahmen unseres Bug Bounty Programms.
|
||||
</p>
|
||||
|
||||
<h2>Zertifizierungen & Standards</h2>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 not-prose mt-4">
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">ISO 27001</h3>
|
||||
<p class="text-sm text-gray-600">Informationssicherheits-Management-System zertifiziert</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">SSL Labs A+</h3>
|
||||
<p class="text-sm text-gray-600">Höchste Bewertung für SSL/TLS-Konfiguration</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">OWASP Compliance</h3>
|
||||
<p class="text-sm text-gray-600">Einhaltung der OWASP-Sicherheitsrichtlinien</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">PCI DSS Ready</h3>
|
||||
<p class="text-sm text-gray-600">Bereit für Payment Card Industry Standards</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8">Kontakt</h2>
|
||||
<p>Bei Fragen zur Sicherheit unseres Services kontaktieren Sie uns:</p>
|
||||
<ul>
|
||||
<li><strong>E-Mail:</strong> security@uload.de</li>
|
||||
<li><strong>PGP-Schlüssel:</strong> Verfügbar auf Anfrage</li>
|
||||
</ul>
|
||||
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-blue-800 mt-8 not-prose">
|
||||
<p class="font-semibold">Tipp:</p>
|
||||
<p>
|
||||
Aktivieren Sie die Zwei-Faktor-Authentifizierung in Ihren Account-Einstellungen für maximale
|
||||
Sicherheit!
|
||||
</p>
|
||||
</div>
|
||||
</LegalLayout>
|
||||
92
apps-archived/uload/apps/landing/src/styles/global.css
Normal file
92
apps-archived/uload/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* uLoad Theme CSS Variables - Professional Blue (Light Theme) */
|
||||
:root {
|
||||
/* Primary colors - uLoad Blue */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
--color-primary-glow: rgba(59, 130, 246, 0.2);
|
||||
|
||||
/* Text colors (Light theme) */
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #4b5563;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Background colors (Light theme) */
|
||||
--color-background-page: #ffffff;
|
||||
--color-background-card: #f9fafb;
|
||||
--color-background-card-hover: #f3f4f6;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-hover: #d1d5db;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--color-background-page);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors duration-200 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border-2 border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all duration-200;
|
||||
}
|
||||
|
||||
.container-custom {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply py-16 md:py-24;
|
||||
}
|
||||
}
|
||||
48
apps-archived/uload/apps/landing/tailwind.config.mjs
Normal file
48
apps-archived/uload/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// uLoad Professional Blue Theme (Light)
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
DEFAULT: '#3b82f6',
|
||||
hover: '#2563eb',
|
||||
glow: 'rgba(59, 130, 246, 0.2)'
|
||||
},
|
||||
background: {
|
||||
page: '#ffffff',
|
||||
card: '#f9fafb',
|
||||
'card-hover': '#f3f4f6'
|
||||
},
|
||||
text: {
|
||||
primary: '#111827',
|
||||
secondary: '#4b5563',
|
||||
muted: '#6b7280'
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#e5e7eb',
|
||||
hover: '#d1d5db'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
11
apps-archived/uload/apps/landing/tsconfig.json
Normal file
11
apps-archived/uload/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps-archived/uload/apps/web/.env.example
Normal file
36
apps-archived/uload/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# SvelteKit Configuration
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
NODE_ENV=production
|
||||
PUBLIC_APP_URL=https://ulo.ad
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Development: Use local Docker container
|
||||
DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
|
||||
# Production: Use your Coolify/Hetzner PostgreSQL container
|
||||
# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
|
||||
|
||||
# File Storage (Cloudflare R2)
|
||||
R2_ACCOUNT_ID=your_cloudflare_account_id
|
||||
R2_ACCESS_KEY_ID=your_r2_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_r2_secret_key
|
||||
R2_BUCKET_AVATARS=uload-avatars
|
||||
R2_BUCKET_QR=uload-qr-codes
|
||||
R2_PUBLIC_URL=https://files.ulo.ad
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_your_resend_api_key
|
||||
RESEND_FROM_EMAIL=noreply@ulo.ad
|
||||
|
||||
# Umami Analytics (optional)
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# External Auth (to be implemented)
|
||||
# AUTH_PROVIDER_CLIENT_ID=
|
||||
# AUTH_PROVIDER_CLIENT_SECRET=
|
||||
|
||||
# Coolify specific (if needed)
|
||||
# These will be set automatically by Coolify
|
||||
# COOLIFY_URL=
|
||||
# COOLIFY_TOKEN=
|
||||
20
apps-archived/uload/apps/web/.env.production.example
Normal file
20
apps-archived/uload/apps/web/.env.production.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# SvelteKit Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
PUBLIC_POCKETBASE_URL=https://your-domain.com/api
|
||||
|
||||
# PocketBase Admin Credentials
|
||||
# These will be used to create the admin on first startup
|
||||
POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
|
||||
POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
|
||||
|
||||
# Umami Analytics
|
||||
# Replace with your actual Umami instance and website ID
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# Optional: Additional Configuration
|
||||
# BODY_SIZE_LIMIT=512kb
|
||||
# PROTOCOL_HEADER=x-forwarded-proto
|
||||
# HOST_HEADER=x-forwarded-host
|
||||
17
apps-archived/uload/apps/web/.env.stripe.example
Normal file
17
apps-archived/uload/apps/web/.env.stripe.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Stripe Configuration
|
||||
# Copy this to .env.local or add to your .env file
|
||||
|
||||
# Stripe API Keys (get from https://dashboard.stripe.com/test/apikeys)
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
|
||||
STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
|
||||
|
||||
# Stripe Product & Price IDs (will be created automatically by Claude)
|
||||
STRIPE_PRODUCT_PRO=prod_xxx
|
||||
STRIPE_PRICE_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_YEARLY=price_xxx
|
||||
|
||||
# Stripe Webhook Secret (from webhook endpoint in dashboard)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# App URL for redirects
|
||||
PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
|
||||
1
apps-archived/uload/apps/web/.npmrc
Normal file
1
apps-archived/uload/apps/web/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
9
apps-archived/uload/apps/web/.prettierignore
Normal file
9
apps-archived/uload/apps/web/.prettierignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
16
apps-archived/uload/apps/web/.prettierrc
Normal file
16
apps-archived/uload/apps/web/.prettierrc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
||||
14
apps-archived/uload/apps/web/drizzle.config.ts
Normal file
14
apps-archived/uload/apps/web/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
} satisfies Config;
|
||||
227
apps-archived/uload/apps/web/drizzle/0000_material_puma.sql
Normal file
227
apps-archived/uload/apps/web/drizzle/0000_material_puma.sql
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
CREATE TABLE "accounts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"owner" uuid NOT NULL,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"plan_type" text DEFAULT 'free',
|
||||
"settings" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "clicks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"link_id" uuid NOT NULL,
|
||||
"ip_hash" text,
|
||||
"user_agent" text,
|
||||
"referer" text,
|
||||
"browser" text,
|
||||
"device_type" text,
|
||||
"os" text,
|
||||
"country" text,
|
||||
"city" text,
|
||||
"clicked_at" timestamp DEFAULT now() NOT NULL,
|
||||
"utm_source" text,
|
||||
"utm_medium" text,
|
||||
"utm_campaign" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feature_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"status" text DEFAULT 'pending',
|
||||
"vote_count" integer DEFAULT 0,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feature_votes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"feature_request_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "folders" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "link_tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"link_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"short_code" text NOT NULL,
|
||||
"custom_code" text,
|
||||
"original_url" text NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"user_id" uuid,
|
||||
"is_active" boolean DEFAULT true,
|
||||
"password" text,
|
||||
"max_clicks" integer,
|
||||
"expires_at" timestamp,
|
||||
"click_count" integer DEFAULT 0,
|
||||
"qr_code_url" text,
|
||||
"tags" jsonb,
|
||||
"utm_source" text,
|
||||
"utm_medium" text,
|
||||
"utm_campaign" text,
|
||||
"account_owner" uuid,
|
||||
"workspace_id" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "links_short_code_unique" UNIQUE("short_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"data" jsonb,
|
||||
"read" boolean DEFAULT false,
|
||||
"action_url" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pending_invitations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"owner" uuid NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"accepted_at" timestamp,
|
||||
"accepted_by" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pending_invitations_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "shared_access" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"owner" uuid NOT NULL,
|
||||
"user_id" uuid,
|
||||
"permissions" jsonb,
|
||||
"invitation_status" text DEFAULT 'pending',
|
||||
"accepted_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"is_public" boolean DEFAULT false,
|
||||
"usage_count" integer DEFAULT 0,
|
||||
"user_id" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"external_auth_id" text,
|
||||
"email" text NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"name" text,
|
||||
"avatar_url" text,
|
||||
"bio" text,
|
||||
"location" text,
|
||||
"website" text,
|
||||
"github" text,
|
||||
"twitter" text,
|
||||
"linkedin" text,
|
||||
"instagram" text,
|
||||
"public_profile" boolean DEFAULT false,
|
||||
"show_click_stats" boolean DEFAULT true,
|
||||
"email_notifications" boolean DEFAULT true,
|
||||
"default_expiry" integer,
|
||||
"profile_background" text,
|
||||
"verified" boolean DEFAULT false,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_external_auth_id_unique" UNIQUE("external_auth_id"),
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email"),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"owner" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workspaces_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "clicks" ADD CONSTRAINT "clicks_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feature_requests" ADD CONSTRAINT "feature_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_feature_request_id_feature_requests_id_fk" FOREIGN KEY ("feature_request_id") REFERENCES "public"."feature_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "links" ADD CONSTRAINT "links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "links" ADD CONSTRAINT "links_account_owner_accounts_id_fk" FOREIGN KEY ("account_owner") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "links" ADD CONSTRAINT "links_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_accepted_by_users_id_fk" FOREIGN KEY ("accepted_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "accounts_owner_idx" ON "accounts" USING btree ("owner");--> statement-breakpoint
|
||||
CREATE INDEX "clicks_link_id_idx" ON "clicks" USING btree ("link_id");--> statement-breakpoint
|
||||
CREATE INDEX "clicks_clicked_at_idx" ON "clicks" USING btree ("clicked_at");--> statement-breakpoint
|
||||
CREATE INDEX "clicks_country_idx" ON "clicks" USING btree ("country");--> statement-breakpoint
|
||||
CREATE INDEX "feature_requests_user_id_idx" ON "feature_requests" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "feature_requests_status_idx" ON "feature_requests" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "feature_requests_vote_count_idx" ON "feature_requests" USING btree ("vote_count");--> statement-breakpoint
|
||||
CREATE INDEX "feature_votes_feature_request_id_idx" ON "feature_votes" USING btree ("feature_request_id");--> statement-breakpoint
|
||||
CREATE INDEX "feature_votes_user_id_idx" ON "feature_votes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "feature_votes_unique_idx" ON "feature_votes" USING btree ("feature_request_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "link_tags_link_id_idx" ON "link_tags" USING btree ("link_id");--> statement-breakpoint
|
||||
CREATE INDEX "link_tags_tag_id_idx" ON "link_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "link_tags_unique_idx" ON "link_tags" USING btree ("link_id","tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "links_user_id_idx" ON "links" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "links_short_code_idx" ON "links" USING btree ("short_code");--> statement-breakpoint
|
||||
CREATE INDEX "links_workspace_id_idx" ON "links" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "links_account_owner_idx" ON "links" USING btree ("account_owner");--> statement-breakpoint
|
||||
CREATE INDEX "links_is_active_idx" ON "links" USING btree ("is_active");--> statement-breakpoint
|
||||
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "notifications_read_idx" ON "notifications" USING btree ("read");--> statement-breakpoint
|
||||
CREATE INDEX "pending_invitations_email_idx" ON "pending_invitations" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "pending_invitations_token_idx" ON "pending_invitations" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "pending_invitations_owner_idx" ON "pending_invitations" USING btree ("owner");--> statement-breakpoint
|
||||
CREATE INDEX "shared_access_owner_idx" ON "shared_access" USING btree ("owner");--> statement-breakpoint
|
||||
CREATE INDEX "shared_access_user_id_idx" ON "shared_access" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "shared_access_status_idx" ON "shared_access" USING btree ("invitation_status");--> statement-breakpoint
|
||||
CREATE INDEX "tags_user_id_idx" ON "tags" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
|
||||
CREATE INDEX "users_external_auth_id_idx" ON "users" USING btree ("external_auth_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspaces_slug_idx" ON "workspaces" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner");
|
||||
1762
apps-archived/uload/apps/web/drizzle/meta/0000_snapshot.json
Normal file
1762
apps-archived/uload/apps/web/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
apps-archived/uload/apps/web/drizzle/meta/_journal.json
Normal file
13
apps-archived/uload/apps/web/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1763571183375,
|
||||
"tag": "0000_material_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
6
apps-archived/uload/apps/web/e2e/demo.test.ts
Normal file
6
apps-archived/uload/apps/web/e2e/demo.test.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('home page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
40
apps-archived/uload/apps/web/eslint.config.js
Normal file
40
apps-archived/uload/apps/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import prettier from 'eslint-config-prettier';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
76
apps-archived/uload/apps/web/package.json
Normal file
76
apps-archived/uload/apps/web/package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "@uload/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "pnpm run test:unit && pnpm run test:e2e",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.4",
|
||||
"@tailwindcss/forms": "^0.5.8",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^24.3.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.35.0",
|
||||
"globals": "^15.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"mdsvex": "^0.12.6",
|
||||
"playwright": "^1.51.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^7.0.4",
|
||||
"vitest": "^3.2.3",
|
||||
"vitest-browser-svelte": "^0.1.0",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.934.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.934.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"ioredis": "^5.7.0",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"lucide-svelte": "^0.539.0",
|
||||
"pocketbase": "^0.26.2",
|
||||
"postgres": "^3.4.7",
|
||||
"resend": "^6.5.1",
|
||||
"stripe": "^18.4.0",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-sonner": "^1.0.5"
|
||||
}
|
||||
}
|
||||
9
apps-archived/uload/apps/web/playwright.config.ts
Normal file
9
apps-archived/uload/apps/web/playwright.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173,
|
||||
},
|
||||
testDir: 'e2e',
|
||||
});
|
||||
162
apps-archived/uload/apps/web/src/app.css
Normal file
162
apps-archived/uload/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
/* Dark mode configuration */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Theme color utilities using CSS variables */
|
||||
@theme {
|
||||
--color-theme-primary: var(--theme-primary);
|
||||
--color-theme-primary-hover: var(--theme-primary-hover);
|
||||
--color-theme-background: var(--theme-background);
|
||||
--color-theme-surface: var(--theme-surface);
|
||||
--color-theme-surface-hover: var(--theme-surface-hover);
|
||||
--color-theme-text: var(--theme-text);
|
||||
--color-theme-text-muted: var(--theme-text-muted);
|
||||
--color-theme-border: var(--theme-border);
|
||||
--color-theme-accent: var(--theme-accent);
|
||||
--color-theme-accent-hover: var(--theme-accent-hover);
|
||||
}
|
||||
|
||||
/* Theme CSS Variables - will be overridden by theme presets */
|
||||
:root {
|
||||
--theme-primary: #171717;
|
||||
--theme-primary-hover: #0a0a0a;
|
||||
--theme-background: #fafafa;
|
||||
--theme-surface: #ffffff;
|
||||
--theme-surface-hover: #f5f5f5;
|
||||
--theme-text: #171717;
|
||||
--theme-text-muted: #737373;
|
||||
--theme-border: #e5e5e5;
|
||||
--theme-accent: #525252;
|
||||
--theme-accent-hover: #404040;
|
||||
--theme-font-family: Inter, system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Sonner Toast Styling - Light Mode */
|
||||
--sonner-toast-gap: 8px;
|
||||
--sonner-toast-padding: 16px;
|
||||
--sonner-toast-border-radius: 12px;
|
||||
--sonner-toast-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Apply theme font to body */
|
||||
body {
|
||||
font-family: var(--theme-font-family);
|
||||
}
|
||||
|
||||
/* Theme transition animation */
|
||||
.theme-transitioning,
|
||||
.theme-transitioning * {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
fill 0.3s ease,
|
||||
stroke 0.3s ease,
|
||||
font-family 0.3s ease !important;
|
||||
}
|
||||
|
||||
/* Ensure full viewport coverage */
|
||||
html,
|
||||
body {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--theme-font-family);
|
||||
background-color: var(--theme-background);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Primary button class with proper contrast */
|
||||
.btn-primary {
|
||||
@apply bg-theme-primary text-theme-background hover:bg-theme-primary-hover;
|
||||
}
|
||||
|
||||
/* Sonner Toast Custom Styles */
|
||||
.sonner-toast {
|
||||
font-family: var(--theme-font-family) !important;
|
||||
}
|
||||
|
||||
.sonner-toast[data-type='success'] {
|
||||
background-color: #10b981 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #059669 !important;
|
||||
}
|
||||
|
||||
.sonner-toast[data-type='error'] {
|
||||
background-color: #ef4444 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #dc2626 !important;
|
||||
}
|
||||
|
||||
.sonner-toast[data-type='info'] {
|
||||
background-color: #3b82f6 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #2563eb !important;
|
||||
}
|
||||
|
||||
.sonner-toast[data-type='warning'] {
|
||||
background-color: #f59e0b !important;
|
||||
color: white !important;
|
||||
border: 1px solid #d97706 !important;
|
||||
}
|
||||
|
||||
/* Dark mode toast styles */
|
||||
.dark .sonner-toast {
|
||||
background-color: #374151 !important;
|
||||
color: #f3f4f6 !important;
|
||||
border: 1px solid #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .sonner-toast[data-type='success'] {
|
||||
background-color: #065f46 !important;
|
||||
color: #d1fae5 !important;
|
||||
border: 1px solid #10b981 !important;
|
||||
}
|
||||
|
||||
.dark .sonner-toast[data-type='error'] {
|
||||
background-color: #7f1d1d !important;
|
||||
color: #fee2e2 !important;
|
||||
border: 1px solid #ef4444 !important;
|
||||
}
|
||||
|
||||
.dark .sonner-toast[data-type='info'] {
|
||||
background-color: #1e3a8a !important;
|
||||
color: #dbeafe !important;
|
||||
border: 1px solid #3b82f6 !important;
|
||||
}
|
||||
|
||||
.dark .sonner-toast[data-type='warning'] {
|
||||
background-color: #78350f !important;
|
||||
color: #fef3c7 !important;
|
||||
border: 1px solid #f59e0b !important;
|
||||
}
|
||||
34
apps-archived/uload/apps/web/src/app.d.ts
vendored
Normal file
34
apps-archived/uload/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { DB } from '$lib/db';
|
||||
|
||||
// Supported locales
|
||||
export type SupportedLocale = 'en' | 'de' | 'es' | 'fr' | 'it';
|
||||
|
||||
// User type (will be replaced by external auth later)
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
db: DB;
|
||||
user: User | null;
|
||||
locale: SupportedLocale;
|
||||
}
|
||||
interface PageData {
|
||||
user: User | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
103
apps-archived/uload/apps/web/src/app.html
Normal file
103
apps-archived/uload/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!doctype html>
|
||||
<html lang="%paraglide.lang%" dir="%paraglide.dir%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="uLoad" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192x192.png" />
|
||||
|
||||
<!-- Preconnect für Performance -->
|
||||
<link rel="preconnect" href="https://pb.ulo.ad" />
|
||||
<link rel="dns-prefetch" href="https://pb.ulo.ad" />
|
||||
|
||||
%sveltekit.head%
|
||||
<!-- Umami Analytics -->
|
||||
<script>
|
||||
// Only load Umami if configured
|
||||
if ('%sveltekit.env.PUBLIC_UMAMI_URL%'.startsWith('http')) {
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = '%sveltekit.env.PUBLIC_UMAMI_URL%/script.js';
|
||||
script.dataset.websiteId = '%sveltekit.env.PUBLIC_UMAMI_WEBSITE_ID%';
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Initialize theme early to prevent flash
|
||||
(function () {
|
||||
// Load theme mode (light/dark/system)
|
||||
const themeMode = localStorage.getItem('theme-mode') || 'system';
|
||||
const themePreset = localStorage.getItem('theme-preset') || 'minimal';
|
||||
|
||||
// Determine if dark mode should be active
|
||||
let isDark = false;
|
||||
if (themeMode === 'dark') {
|
||||
isDark = true;
|
||||
} else if (themeMode === 'system') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Apply dark class if needed
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Load theme preset colors
|
||||
const themes = {
|
||||
minimal: {
|
||||
light: {
|
||||
primary: '#171717',
|
||||
primaryHover: '#0a0a0a',
|
||||
background: '#fafafa',
|
||||
surface: '#ffffff',
|
||||
surfaceHover: '#f5f5f5',
|
||||
text: '#171717',
|
||||
textMuted: '#737373',
|
||||
border: '#e5e5e5',
|
||||
accent: '#525252',
|
||||
accentHover: '#404040'
|
||||
},
|
||||
dark: {
|
||||
primary: '#fafafa',
|
||||
primaryHover: '#ffffff',
|
||||
background: '#0a0a0a',
|
||||
surface: '#171717',
|
||||
surfaceHover: '#262626',
|
||||
text: '#fafafa',
|
||||
textMuted: '#a3a3a3',
|
||||
border: '#404040',
|
||||
accent: '#d4d4d4',
|
||||
accentHover: '#e5e5e5'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Apply CSS variables for the theme
|
||||
const preset = themes[themePreset] || themes.minimal;
|
||||
const colors = isDark ? preset.dark : preset.light;
|
||||
const root = document.documentElement;
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
const cssKey = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
||||
root.style.setProperty(`--theme-${cssKey}`, value);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "till-schneider",
|
||||
"name": "Till Schneider",
|
||||
"bio": "Gründer von uload und begeistert von der Psychologie hinter digitalem Marketing.",
|
||||
"avatar": "/images/authors/till.jpg",
|
||||
"social": {
|
||||
"twitter": "https://twitter.com/tillschneider",
|
||||
"linkedin": "https://linkedin.com/in/tillschneider",
|
||||
"website": "https://ulo.ad"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
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!
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
---
|
||||
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.
|
||||
64
apps-archived/uload/apps/web/src/content/config.ts
Normal file
64
apps-archived/uload/apps/web/src/content/config.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Author Schema
|
||||
export const authorSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
bio: z.string().optional(),
|
||||
avatar: z.string().optional(),
|
||||
social: z
|
||||
.object({
|
||||
twitter: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Blog Post Schema
|
||||
export const blogSchema = z.object({
|
||||
title: z.string(),
|
||||
excerpt: z.string(),
|
||||
date: z
|
||||
.string()
|
||||
.or(z.date())
|
||||
.transform((val) => new Date(val)),
|
||||
author: z.string(), // Author ID
|
||||
tags: z.array(z.string()).default([]),
|
||||
category: z.enum(['tutorial', 'psychology', 'feature', 'announcement', 'case-study']),
|
||||
image: z.string().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
featured: z.boolean().default(false),
|
||||
series: z.string().optional(),
|
||||
layout: z.string().default('blog'),
|
||||
seo: z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
canonical: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type BlogPost = z.infer<typeof blogSchema>;
|
||||
export type Author = z.infer<typeof authorSchema>;
|
||||
|
||||
// Extended types with computed fields
|
||||
export interface BlogPostWithMeta extends BlogPost {
|
||||
slug: string;
|
||||
readingTime: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface BlogCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BlogTag {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
7
apps-archived/uload/apps/web/src/demo.spec.ts
Normal file
7
apps-archived/uload/apps/web/src/demo.spec.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
153
apps-archived/uload/apps/web/src/hooks.server.ts
Normal file
153
apps-archived/uload/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
import { RateLimits } from '$lib/server/rate-limiter';
|
||||
import { db } from '$lib/db';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Rate Limiting anwenden (nur in Produktion)
|
||||
if (!dev) {
|
||||
const rateLimitResponse = await applyRateLimit(event);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Make database available in event.locals
|
||||
event.locals.db = db;
|
||||
|
||||
// TODO: Implement external authentication
|
||||
// For now, user is always null until auth is implemented
|
||||
event.locals.user = null;
|
||||
|
||||
console.log('\n[HOOKS] === Request:', event.url.pathname);
|
||||
console.log('[HOOKS] User:', event.locals.user?.id || 'Not authenticated');
|
||||
|
||||
const response = await resolve(event);
|
||||
|
||||
// Rate Limit Headers hinzufügen
|
||||
if (event.locals.rateLimitHeaders) {
|
||||
Object.entries(event.locals.rateLimitHeaders).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Security Headers (nur in Produktion)
|
||||
if (!dev) {
|
||||
addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// Rate Limiting basierend auf Route anwenden
|
||||
async function applyRateLimit(event: any): Promise<Response | null> {
|
||||
const { pathname } = event.url;
|
||||
const method = event.request.method;
|
||||
|
||||
// API Endpoints
|
||||
if (pathname.startsWith('/api/')) {
|
||||
// Spezifische Limits für verschiedene Endpoints
|
||||
if (
|
||||
pathname.includes('/auth') ||
|
||||
pathname.includes('/login') ||
|
||||
pathname.includes('/register')
|
||||
) {
|
||||
return await RateLimits.auth(event);
|
||||
}
|
||||
|
||||
if (pathname.includes('/password-reset')) {
|
||||
return await RateLimits.passwordReset(event);
|
||||
}
|
||||
|
||||
if (pathname.includes('/register')) {
|
||||
return await RateLimits.registration(event);
|
||||
}
|
||||
|
||||
// Link-Operationen (POST für Creation)
|
||||
if (pathname.includes('/links') && method === 'POST') {
|
||||
return await RateLimits.linkCreation(event);
|
||||
}
|
||||
|
||||
// General API Rate Limit
|
||||
return await RateLimits.api(event);
|
||||
}
|
||||
|
||||
// Link Clicks (Redirects)
|
||||
if (
|
||||
pathname.length > 1 &&
|
||||
!pathname.startsWith('/api/') &&
|
||||
!pathname.startsWith('/my/') &&
|
||||
!pathname.startsWith('/admin/')
|
||||
) {
|
||||
// Könnte ein Short Link sein
|
||||
return await RateLimits.clicks(event);
|
||||
}
|
||||
|
||||
// Auth Pages
|
||||
if (pathname === '/login' || pathname === '/register' || pathname === '/forgot-password') {
|
||||
if (method === 'POST') {
|
||||
return await RateLimits.auth(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Kein Rate Limiting für andere Routen
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security Headers hinzufügen
|
||||
function addSecurityHeaders(response: Response) {
|
||||
const headers = response.headers;
|
||||
|
||||
// Content Security Policy (angepasst für uLoad)
|
||||
if (!headers.has('content-security-policy')) {
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.ulo.ad https://analytics.google.com https://www.googletagmanager.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: https: blob:",
|
||||
"media-src 'self' blob:",
|
||||
"connect-src 'self' https://api.stripe.com https://js.stripe.com https://files.ulo.ad",
|
||||
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
dev ? '' : 'upgrade-insecure-requests', // Only in production
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
||||
headers.set('content-security-policy', csp);
|
||||
}
|
||||
|
||||
// HSTS (HTTP Strict Transport Security)
|
||||
if (!headers.has('strict-transport-security')) {
|
||||
headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains; preload');
|
||||
}
|
||||
|
||||
// X-Frame-Options
|
||||
if (!headers.has('x-frame-options')) {
|
||||
headers.set('x-frame-options', 'DENY');
|
||||
}
|
||||
|
||||
// X-Content-Type-Options
|
||||
if (!headers.has('x-content-type-options')) {
|
||||
headers.set('x-content-type-options', 'nosniff');
|
||||
}
|
||||
|
||||
// Referrer Policy
|
||||
if (!headers.has('referrer-policy')) {
|
||||
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
|
||||
// Permissions Policy
|
||||
if (!headers.has('permissions-policy')) {
|
||||
headers.set('permissions-policy', 'camera=(), microphone=(), geolocation=()');
|
||||
}
|
||||
|
||||
// X-XSS-Protection (für ältere Browser)
|
||||
if (!headers.has('x-xss-protection')) {
|
||||
headers.set('x-xss-protection', '1; mode=block');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { hashManager } from '../service/HashManager';
|
||||
import {
|
||||
getVariantContent,
|
||||
getTrustBadges,
|
||||
getFreeText,
|
||||
type VariantContent,
|
||||
} from '../config/variants';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { PageData, ActionData } from '../../../routes/$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
form: ActionData;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
let { data, form, onSubmit }: Props = $props();
|
||||
|
||||
let variant = $state<string>('control');
|
||||
let content = $state<VariantContent>(getVariantContent('control'));
|
||||
let trustBadges = $state(getTrustBadges());
|
||||
let freeText = $state(getFreeText());
|
||||
let showDebug = $state(false);
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
// Get variant assignment
|
||||
variant = hashManager.getVariant();
|
||||
content = getVariantContent(variant);
|
||||
trustBadges = getTrustBadges();
|
||||
freeText = getFreeText();
|
||||
showDebug = hashManager.isDebugMode();
|
||||
isLoading = false;
|
||||
|
||||
// Track page view with variant
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track(`page_view_${variant}`);
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
if (showDebug) {
|
||||
console.log('A/B Test Variant:', variant, content);
|
||||
console.log('Current Locale:', get(locale));
|
||||
}
|
||||
});
|
||||
|
||||
// React to locale changes - use derived state
|
||||
$effect(() => {
|
||||
// This will re-run when locale changes
|
||||
const currentLocale = get(locale);
|
||||
|
||||
// Update content based on current locale
|
||||
content = getVariantContent(variant);
|
||||
trustBadges = getTrustBadges();
|
||||
freeText = getFreeText();
|
||||
|
||||
if (showDebug) {
|
||||
console.log('Locale changed to:', currentLocale);
|
||||
}
|
||||
});
|
||||
|
||||
function handleCtaClick() {
|
||||
// Track CTA click
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track(`cta_click_${variant}`);
|
||||
}
|
||||
onSubmit?.();
|
||||
|
||||
// Smooth scroll to form
|
||||
const form = document.getElementById('url-form');
|
||||
if (form) {
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showDebug}
|
||||
<div class="fixed right-4 top-20 z-50 rounded-lg bg-black/80 p-4 text-white shadow-lg">
|
||||
<div class="font-mono text-xs">
|
||||
<div class="font-bold text-green-400">A/B Test Debug</div>
|
||||
<div>Variant: <span class="text-yellow-400">{variant}</span></div>
|
||||
<div>Name: {content.name}</div>
|
||||
<div>Locale: <span class="text-blue-400">{get(locale)}</span></div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
hashManager.reset();
|
||||
window.location.reload();
|
||||
}}
|
||||
class="rounded bg-red-600 px-2 py-1 text-xs hover:bg-red-700"
|
||||
>
|
||||
Reset & Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section
|
||||
class="relative overflow-hidden bg-gradient-to-br from-purple-50 via-white to-blue-50 dark:from-gray-900 dark:via-gray-900 dark:to-purple-900"
|
||||
>
|
||||
<!-- Background decoration -->
|
||||
<div
|
||||
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-purple-300 opacity-20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-blue-300 opacity-20 blur-3xl"
|
||||
></div>
|
||||
|
||||
<div class="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
|
||||
{#if !isLoading}
|
||||
<div class="text-center">
|
||||
<!-- Headline -->
|
||||
<h1
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl md:text-6xl"
|
||||
>
|
||||
{#if variant === 'b2' && content.headline.includes(',')}
|
||||
<!-- Special formatting for logos variant -->
|
||||
<span class="block">{content.headline.split(',')[0]},</span>
|
||||
<span class="block text-3xl sm:text-4xl md:text-5xl"
|
||||
>{content.headline.split(',').slice(1).join(',')}</span
|
||||
>
|
||||
{:else}
|
||||
{content.headline}
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300 sm:text-xl">
|
||||
{content.subheadline}
|
||||
</p>
|
||||
|
||||
<!-- Social Proof (if present) -->
|
||||
{#if content.socialProof}
|
||||
<div class="mt-8">
|
||||
{#if content.socialProof.type === 'numbers'}
|
||||
<div
|
||||
class="flex flex-wrap justify-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{#each content.socialProof.content.split('•') as stat}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="text-green-500">✓</span>
|
||||
{stat.trim()}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if content.socialProof.type === 'logos'}
|
||||
<div class="mt-4 flex flex-wrap justify-center gap-6 opacity-60 grayscale">
|
||||
{#each content.socialProof.content.split('•') as logo}
|
||||
<span class="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
{logo.trim()}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if content.socialProof.type === 'testimonial'}
|
||||
<div class="mt-4 text-yellow-500">
|
||||
{content.socialProof.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Features List (if present) -->
|
||||
{#if content.features && content.features.length > 0}
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
{#each content.features.slice(0, 3) as feature}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm dark:bg-gray-800/80 dark:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{feature}
|
||||
</div>
|
||||
{/each}
|
||||
{#if content.features.length > 3}
|
||||
{#each content.features.slice(3) as feature}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm dark:bg-gray-800/80 dark:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{feature}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="mx-auto mt-10 max-w-xl">
|
||||
<a
|
||||
href="#url-form"
|
||||
onclick={handleCtaClick}
|
||||
class="inline-block whitespace-nowrap rounded-lg px-8 py-4 font-semibold text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl {content.ctaStyle ||
|
||||
'bg-theme-primary hover:bg-theme-primary-hover'}"
|
||||
>
|
||||
{content.ctaText}
|
||||
</a>
|
||||
|
||||
{#if !data.user}
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{freeText}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Trust Badges -->
|
||||
<div
|
||||
class="mt-12 flex flex-wrap justify-center gap-6 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{#each trustBadges as badge}
|
||||
<span class="flex items-center gap-1">
|
||||
{badge.icon}
|
||||
{badge.text}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Loading state -->
|
||||
<div class="flex min-h-[400px] items-center justify-center">
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* A/B Testing Variant Configurations
|
||||
* Defines content and styling for each variant using multilingual messages
|
||||
*/
|
||||
|
||||
import * as m from '$paraglide/messages';
|
||||
|
||||
export interface VariantContent {
|
||||
id: string;
|
||||
name: string;
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
ctaText: string;
|
||||
ctaStyle?: string;
|
||||
features?: string[];
|
||||
socialProof?: {
|
||||
type: 'numbers' | 'logos' | 'testimonial';
|
||||
content: string;
|
||||
};
|
||||
layout?: 'standard' | 'split' | 'centered';
|
||||
}
|
||||
|
||||
// Get variant content with multilingual support
|
||||
export function getVariantContent(variantId: string): VariantContent {
|
||||
switch (variantId) {
|
||||
case 'control':
|
||||
return {
|
||||
id: 'control',
|
||||
name: 'Control (Baseline)',
|
||||
headline: m.hero_control_headline(),
|
||||
subheadline: m.hero_control_subheadline(),
|
||||
ctaText: m.hero_control_cta(),
|
||||
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
// Variant A - Value Focused
|
||||
case 'a1':
|
||||
return {
|
||||
id: 'a1',
|
||||
name: 'Value Generic',
|
||||
headline: m.hero_a1_headline(),
|
||||
subheadline: m.hero_a1_subheadline(),
|
||||
ctaText: m.hero_a1_cta(),
|
||||
ctaStyle: 'bg-blue-600 hover:bg-blue-700',
|
||||
features: [m.hero_a1_feature_1(), m.hero_a1_feature_2(), m.hero_a1_feature_3()],
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'a2':
|
||||
return {
|
||||
id: 'a2',
|
||||
name: 'Value Specific',
|
||||
headline: 'Save 3 Hours Per Week on Link Management',
|
||||
subheadline: 'Join teams who reduced their link management tasks by 75%',
|
||||
ctaText: 'Calculate Your Savings',
|
||||
ctaStyle:
|
||||
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
|
||||
features: ['3 hours saved weekly', '75% faster workflows', 'ROI in 2 weeks'],
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'a3':
|
||||
return {
|
||||
id: 'a3',
|
||||
name: 'Value Transform',
|
||||
headline: 'Your Links, 10x More Powerful',
|
||||
subheadline: 'Transform every URL into a conversion machine with analytics and automation',
|
||||
ctaText: 'Unlock Link Power →',
|
||||
ctaStyle: 'bg-black hover:bg-gray-800',
|
||||
features: ['10x more clicks', 'Conversion tracking', 'Smart redirects'],
|
||||
layout: 'centered',
|
||||
};
|
||||
|
||||
// Variant B - Social Proof
|
||||
case 'b1':
|
||||
return {
|
||||
id: 'b1',
|
||||
name: 'Social Numbers',
|
||||
headline: m.hero_b1_headline(),
|
||||
subheadline: m.hero_b1_subheadline(),
|
||||
ctaText: m.hero_b1_cta(),
|
||||
ctaStyle: 'bg-purple-600 hover:bg-purple-700',
|
||||
socialProof: {
|
||||
type: 'numbers',
|
||||
content: m.hero_b1_social(),
|
||||
},
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'b2':
|
||||
return {
|
||||
id: 'b2',
|
||||
name: 'Social Logos',
|
||||
headline: 'Trusted by Google, Meta, and Microsoft Teams',
|
||||
subheadline: 'Enterprise-grade URL management for companies of all sizes',
|
||||
ctaText: 'See Why They Chose Us',
|
||||
ctaStyle:
|
||||
'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700',
|
||||
socialProof: {
|
||||
type: 'logos',
|
||||
content: 'Google • Meta • Microsoft • Spotify • Netflix',
|
||||
},
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'b3':
|
||||
return {
|
||||
id: 'b3',
|
||||
name: 'Social Testimonial',
|
||||
headline: 'Rated #1 URL Shortener by Marketing Teams',
|
||||
subheadline: '"uLoad saved us 5 hours per week and increased our CTR by 40%"',
|
||||
ctaText: 'Read Success Stories',
|
||||
ctaStyle: 'bg-green-600 hover:bg-green-700',
|
||||
socialProof: {
|
||||
type: 'testimonial',
|
||||
content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews',
|
||||
},
|
||||
layout: 'centered',
|
||||
};
|
||||
|
||||
// Variant C - Feature Focused
|
||||
case 'c1':
|
||||
return {
|
||||
id: 'c1',
|
||||
name: 'Features All-in-One',
|
||||
headline: m.hero_c1_headline(),
|
||||
subheadline: m.hero_c1_subheadline(),
|
||||
ctaText: m.hero_c1_cta(),
|
||||
ctaStyle: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
features: [
|
||||
m.hero_c1_feature_1(),
|
||||
m.hero_c1_feature_2(),
|
||||
m.hero_c1_feature_3(),
|
||||
m.hero_c1_feature_4(),
|
||||
m.hero_c1_feature_5(),
|
||||
m.hero_c1_feature_6(),
|
||||
],
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'c2':
|
||||
return {
|
||||
id: 'c2',
|
||||
name: 'Features QR Focus',
|
||||
headline: 'QR Codes That Actually Convert',
|
||||
subheadline: 'Create dynamic QR codes with real-time analytics and custom branding',
|
||||
ctaText: 'Create Your First QR Code',
|
||||
ctaStyle: 'bg-orange-600 hover:bg-orange-700',
|
||||
features: ['Dynamic QR codes', 'Custom designs', 'Scan analytics', 'Bulk generation'],
|
||||
layout: 'split',
|
||||
};
|
||||
|
||||
case 'c3':
|
||||
return {
|
||||
id: 'c3',
|
||||
name: 'Features Integration',
|
||||
headline: 'Works With Your Favorite Tools',
|
||||
subheadline: 'Seamless integration with Zapier, Slack, WordPress & 100+ platforms',
|
||||
ctaText: 'Connect Your Tools',
|
||||
ctaStyle: 'bg-teal-600 hover:bg-teal-700',
|
||||
features: [
|
||||
'Zapier automation',
|
||||
'Slack notifications',
|
||||
'WordPress plugin',
|
||||
'API & Webhooks',
|
||||
],
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
// Default to control
|
||||
default:
|
||||
return {
|
||||
id: 'control',
|
||||
name: 'Control (Baseline)',
|
||||
headline: m.hero_control_headline(),
|
||||
subheadline: m.hero_control_subheadline(),
|
||||
ctaText: m.hero_control_cta(),
|
||||
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
|
||||
layout: 'standard',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get all active variant IDs
|
||||
export function getActiveVariantIds(): string[] {
|
||||
return ['control', 'a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
|
||||
}
|
||||
|
||||
// Check if variant exists
|
||||
export function isValidVariant(variantId: string): boolean {
|
||||
return getActiveVariantIds().includes(variantId);
|
||||
}
|
||||
|
||||
// Get trust badges with translations
|
||||
export function getTrustBadges(): Array<{ icon: string; text: string }> {
|
||||
return [
|
||||
{ icon: '🔒', text: m.hero_trust_badge_1() },
|
||||
{ icon: '🇪🇺', text: m.hero_trust_badge_2() },
|
||||
{ icon: '⚡', text: m.hero_trust_badge_3() },
|
||||
{ icon: '🚀', text: m.hero_trust_badge_4() },
|
||||
];
|
||||
}
|
||||
|
||||
// Get free text
|
||||
export function getFreeText(): string {
|
||||
return m.hero_free_text();
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Hash-based A/B Testing Manager
|
||||
* Manages variant assignment and persistence via URL hash
|
||||
*/
|
||||
export class HashManager {
|
||||
// Valid variants with versions
|
||||
private readonly validVariants = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
|
||||
|
||||
// Current traffic distribution (percentages must sum to 100)
|
||||
private readonly distribution: Record<string, number> = {
|
||||
control: 40, // Baseline
|
||||
a1: 20, // Value-focused variant
|
||||
b1: 20, // Social proof variant
|
||||
c1: 20, // Feature-focused variant
|
||||
};
|
||||
|
||||
// Storage key for backup
|
||||
private readonly storageKey = 'uload_ab_variant';
|
||||
|
||||
// Debug mode flag
|
||||
private debugMode = false;
|
||||
|
||||
constructor() {
|
||||
// Check for debug mode
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.debugMode = params.get('debug') === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current variant for the user
|
||||
* Priority: URL hash > localStorage > new assignment
|
||||
*/
|
||||
getVariant(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'control';
|
||||
}
|
||||
|
||||
// Check for forced variant (testing)
|
||||
const forced = this.getForcedVariant();
|
||||
if (forced !== null) {
|
||||
this.log(`Forced variant: ${forced}`);
|
||||
return forced;
|
||||
}
|
||||
|
||||
// Check existing hash
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && this.isValidVariant(hash)) {
|
||||
this.log(`Using hash variant: ${hash}`);
|
||||
this.storeVariant(hash);
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Check localStorage backup
|
||||
const stored = this.getStoredVariant();
|
||||
if (stored && this.isValidVariant(stored)) {
|
||||
this.log(`Using stored variant: ${stored}`);
|
||||
this.setHash(stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
// Assign new variant
|
||||
const newVariant = this.assignRandomVariant();
|
||||
this.log(`Assigned new variant: ${newVariant}`);
|
||||
this.setHash(newVariant);
|
||||
this.storeVariant(newVariant);
|
||||
return newVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a variant is valid
|
||||
*/
|
||||
private isValidVariant(variant: string): boolean {
|
||||
return variant === 'control' || this.validVariants.includes(variant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a random variant based on distribution weights
|
||||
*/
|
||||
private assignRandomVariant(): string {
|
||||
const random = Math.random() * 100;
|
||||
let cumulative = 0;
|
||||
|
||||
for (const [variant, weight] of Object.entries(this.distribution)) {
|
||||
cumulative += weight;
|
||||
if (random <= cumulative) {
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to control
|
||||
return 'control';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the URL hash
|
||||
*/
|
||||
private setHash(variant: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Don't set hash for control to keep URL clean
|
||||
if (variant === 'control') {
|
||||
// Remove hash if it exists
|
||||
if (window.location.hash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
} else {
|
||||
window.location.hash = variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store variant in localStorage
|
||||
*/
|
||||
private storeVariant(variant: string): void {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, variant);
|
||||
// Also store timestamp for analytics
|
||||
localStorage.setItem(`${this.storageKey}_timestamp`, new Date().toISOString());
|
||||
} catch (e) {
|
||||
console.warn('Could not store variant in localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored variant from localStorage
|
||||
*/
|
||||
private getStoredVariant(): string | null {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
try {
|
||||
return localStorage.getItem(this.storageKey);
|
||||
} catch (e) {
|
||||
console.warn('Could not read variant from localStorage:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get forced variant from URL params (for testing)
|
||||
*/
|
||||
private getForcedVariant(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const forced = params.get('force') || params.get('variant');
|
||||
|
||||
if (forced && this.isValidVariant(forced)) {
|
||||
return forced;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset variant assignment (for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Clear hash
|
||||
if (window.location.hash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
// Clear storage
|
||||
if (window.localStorage) {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
localStorage.removeItem(`${this.storageKey}_timestamp`);
|
||||
}
|
||||
|
||||
this.log('Variant assignment reset');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active variants (for debugging)
|
||||
*/
|
||||
getActiveVariants(): string[] {
|
||||
return ['control', ...Object.keys(this.distribution).filter((v) => v !== 'control')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current distribution (for debugging)
|
||||
*/
|
||||
getDistribution(): Record<string, number> {
|
||||
return { ...this.distribution };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug messages
|
||||
*/
|
||||
private log(message: string): void {
|
||||
if (this.debugMode) {
|
||||
console.log(`[A/B Testing] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should show debug info
|
||||
*/
|
||||
isDebugMode(): boolean {
|
||||
return this.debugMode;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const hashManager = new HashManager();
|
||||
16
apps-archived/uload/apps/web/src/lib/actions/clickOutside.ts
Normal file
16
apps-archived/uload/apps/web/src/lib/actions/clickOutside.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Click outside action for Svelte components
|
||||
export function clickOutside(node: HTMLElement, callback: () => void) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
202
apps-archived/uload/apps/web/src/lib/actions/touch.test.ts
Normal file
202
apps-archived/uload/apps/web/src/lib/actions/touch.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { isTouchDevice, isOptimalTouchTarget } from './touch';
|
||||
|
||||
// Mock DOM APIs für Tests
|
||||
const mockEventListener = vi.fn();
|
||||
const mockRemoveEventListener = vi.fn();
|
||||
|
||||
const createMockElement = (width = 44, height = 44) => ({
|
||||
addEventListener: mockEventListener,
|
||||
removeEventListener: mockRemoveEventListener,
|
||||
getBoundingClientRect: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }),
|
||||
style: {},
|
||||
appendChild: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock global objects
|
||||
Object.defineProperty(window, 'navigator', {
|
||||
value: {
|
||||
maxTouchPoints: 0,
|
||||
userAgent: 'Mozilla/5.0 (Test Browser)',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('Touch Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset touch support
|
||||
delete (window as any).ontouchstart;
|
||||
(window.navigator as any).maxTouchPoints = 0;
|
||||
});
|
||||
|
||||
describe('isTouchDevice', () => {
|
||||
test('should detect touch support via ontouchstart', () => {
|
||||
(window as any).ontouchstart = true;
|
||||
expect(isTouchDevice()).toBe(true);
|
||||
});
|
||||
|
||||
test('should detect touch support via maxTouchPoints', () => {
|
||||
(window.navigator as any).maxTouchPoints = 1;
|
||||
expect(isTouchDevice()).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for non-touch devices', () => {
|
||||
expect(isTouchDevice()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOptimalTouchTarget', () => {
|
||||
test('should return true for 44x44 elements', () => {
|
||||
const element = createMockElement(44, 44);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true for larger elements', () => {
|
||||
const element = createMockElement(50, 60);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for small width', () => {
|
||||
const element = createMockElement(30, 44);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for small height', () => {
|
||||
const element = createMockElement(44, 30);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for small elements', () => {
|
||||
const element = createMockElement(20, 20);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Actions (Integration)', () => {
|
||||
let mockElement: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockElement = createMockElement();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Event Registration', () => {
|
||||
test('should register touch and pointer events', () => {
|
||||
// Diese Tests würden die tatsächlichen Touch-Actions testen
|
||||
// Für jetzt testen wir nur die Utility-Funktionen
|
||||
expect(mockEventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gesture Recognition', () => {
|
||||
test('should calculate touch distances correctly', () => {
|
||||
const touch1 = { clientX: 0, clientY: 0 };
|
||||
const touch2 = { clientX: 100, clientY: 100 };
|
||||
|
||||
// Math.sqrt(100^2 + 100^2) = Math.sqrt(20000) ≈ 141.42
|
||||
const expectedDistance = Math.sqrt(20000);
|
||||
const actualDistance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
);
|
||||
|
||||
expect(actualDistance).toBeCloseTo(expectedDistance, 2);
|
||||
});
|
||||
|
||||
test('should detect horizontal swipes', () => {
|
||||
const startTouch = { clientX: 0, clientY: 100 };
|
||||
const endTouch = { clientX: 100, clientY: 100 };
|
||||
|
||||
const deltaX = endTouch.clientX - startTouch.clientX;
|
||||
const deltaY = endTouch.clientY - startTouch.clientY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
// Horizontal swipe: |deltaX| > |deltaY|
|
||||
expect(absDeltaX).toBeGreaterThan(absDeltaY);
|
||||
expect(deltaX).toBeGreaterThan(0); // Right swipe
|
||||
});
|
||||
|
||||
test('should detect vertical swipes', () => {
|
||||
const startTouch = { clientX: 100, clientY: 0 };
|
||||
const endTouch = { clientX: 100, clientY: 100 };
|
||||
|
||||
const deltaX = endTouch.clientX - startTouch.clientX;
|
||||
const deltaY = endTouch.clientY - startTouch.clientY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
// Vertical swipe: |deltaY| > |deltaX|
|
||||
expect(absDeltaY).toBeGreaterThan(absDeltaX);
|
||||
expect(deltaY).toBeGreaterThan(0); // Down swipe
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Target Validation', () => {
|
||||
test('should validate minimum touch target sizes', () => {
|
||||
const sizes = [
|
||||
{ width: 44, height: 44, expected: true },
|
||||
{ width: 48, height: 48, expected: true },
|
||||
{ width: 40, height: 40, expected: false },
|
||||
{ width: 44, height: 40, expected: false },
|
||||
{ width: 40, height: 44, expected: false },
|
||||
];
|
||||
|
||||
sizes.forEach(({ width, height, expected }) => {
|
||||
const element = createMockElement(width, height);
|
||||
expect(isOptimalTouchTarget(element as any)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Considerations', () => {
|
||||
test('should handle rapid touch events', () => {
|
||||
// Simuliere viele schnelle Touch-Events
|
||||
const events = Array.from({ length: 100 }, (_, i) => ({
|
||||
clientX: i,
|
||||
clientY: i,
|
||||
timestamp: Date.now() + i,
|
||||
}));
|
||||
|
||||
// In einer echten Implementation würden wir Throttling/Debouncing testen
|
||||
expect(events).toHaveLength(100);
|
||||
|
||||
// Teste dass Events innerhalb vernünftiger Zeit verarbeitet werden können
|
||||
const startTime = Date.now();
|
||||
events.forEach((event) => {
|
||||
// Simuliere Event-Verarbeitung
|
||||
const deltaX = event.clientX;
|
||||
const deltaY = event.clientY;
|
||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(100); // Sollte sehr schnell sein
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility Considerations', () => {
|
||||
test('should maintain focus accessibility', () => {
|
||||
// Touch-Actions sollten Keyboard-Navigation nicht beeinträchtigen
|
||||
const element = createMockElement();
|
||||
|
||||
// Simuliere dass Element fokussierbar bleibt
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute = vi.fn();
|
||||
|
||||
expect(element.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('should work with screen readers', () => {
|
||||
// Touch-Targets sollten Screen-Reader-kompatibel bleiben
|
||||
const element = createMockElement();
|
||||
element.getAttribute = vi.fn().mockReturnValue('button');
|
||||
element.textContent = 'Touch Button';
|
||||
|
||||
expect(element.getAttribute('role')).toBe('button');
|
||||
expect(element.textContent).toBe('Touch Button');
|
||||
});
|
||||
});
|
||||
});
|
||||
343
apps-archived/uload/apps/web/src/lib/actions/touch.ts
Normal file
343
apps-archived/uload/apps/web/src/lib/actions/touch.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// Touch-optimierte Aktionen für mobile Geräte
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
// Touch-optimierte Ripple-Effekte
|
||||
export const ripple: Action<HTMLElement, { color?: string; duration?: number }> = (
|
||||
node,
|
||||
options = {}
|
||||
) => {
|
||||
const { color = 'rgba(255, 255, 255, 0.3)', duration = 600 } = options;
|
||||
|
||||
let rippleElement: HTMLDivElement | null = null;
|
||||
|
||||
function createRipple(event: PointerEvent | TouchEvent) {
|
||||
// Entferne vorherigen Ripple
|
||||
if (rippleElement) {
|
||||
rippleElement.remove();
|
||||
}
|
||||
|
||||
// Erstelle neuen Ripple
|
||||
rippleElement = document.createElement('div');
|
||||
const rect = node.getBoundingClientRect();
|
||||
|
||||
// Berechne Position des Touches/Clicks
|
||||
let clientX: number, clientY: number;
|
||||
if (event instanceof TouchEvent && event.touches.length > 0) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else if (event instanceof PointerEvent) {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
} else {
|
||||
// Fallback zur Mitte des Elements
|
||||
clientX = rect.left + rect.width / 2;
|
||||
clientY = rect.top + rect.height / 2;
|
||||
}
|
||||
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
const size = Math.max(rect.width, rect.height) * 2;
|
||||
|
||||
// Style des Ripple-Elements
|
||||
Object.assign(rippleElement.style, {
|
||||
position: 'absolute',
|
||||
top: `${y - size / 2}px`,
|
||||
left: `${x - size / 2}px`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: color,
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
transform: 'scale(0)',
|
||||
transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`,
|
||||
zIndex: '1000',
|
||||
});
|
||||
|
||||
// Stelle sicher, dass das Parent-Element relative Position hat
|
||||
const computedStyle = getComputedStyle(node);
|
||||
if (computedStyle.position === 'static') {
|
||||
node.style.position = 'relative';
|
||||
}
|
||||
|
||||
// Stelle sicher, dass overflow hidden ist für Ripple-Effekt
|
||||
const originalOverflow = node.style.overflow;
|
||||
node.style.overflow = 'hidden';
|
||||
|
||||
node.appendChild(rippleElement);
|
||||
|
||||
// Starte Animation
|
||||
requestAnimationFrame(() => {
|
||||
if (rippleElement) {
|
||||
rippleElement.style.transform = 'scale(1)';
|
||||
rippleElement.style.opacity = '0';
|
||||
}
|
||||
});
|
||||
|
||||
// Entferne Element nach Animation
|
||||
setTimeout(() => {
|
||||
if (rippleElement && rippleElement.parentNode) {
|
||||
rippleElement.remove();
|
||||
rippleElement = null;
|
||||
// Stelle ursprünglichen overflow wieder her
|
||||
node.style.overflow = originalOverflow;
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Event Listeners für verschiedene Eingabemethoden
|
||||
node.addEventListener('pointerdown', createRipple);
|
||||
node.addEventListener('touchstart', createRipple, { passive: true });
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', createRipple);
|
||||
node.removeEventListener('touchstart', createRipple);
|
||||
if (rippleElement) {
|
||||
rippleElement.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Swipe-Gesten erkennen
|
||||
interface SwipeOptions {
|
||||
threshold?: number;
|
||||
timeout?: number;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
onSwipeUp?: () => void;
|
||||
onSwipeDown?: () => void;
|
||||
}
|
||||
|
||||
export const swipe: Action<HTMLElement, SwipeOptions> = (node, options = {}) => {
|
||||
const {
|
||||
threshold = 50,
|
||||
timeout = 300,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onSwipeUp,
|
||||
onSwipeDown,
|
||||
} = options;
|
||||
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
let startTime: number;
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
if (event.changedTouches.length !== 1) return;
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
const endX = touch.clientX;
|
||||
const endY = touch.clientY;
|
||||
const endTime = Date.now();
|
||||
|
||||
// Prüfe Timeout
|
||||
if (endTime - startTime > timeout) return;
|
||||
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
// Prüfe ob Schwellenwert erreicht wurde
|
||||
if (Math.max(absDeltaX, absDeltaY) < threshold) return;
|
||||
|
||||
// Bestimme Swipe-Richtung
|
||||
if (absDeltaX > absDeltaY) {
|
||||
// Horizontaler Swipe
|
||||
if (deltaX > 0) {
|
||||
onSwipeRight?.();
|
||||
} else {
|
||||
onSwipeLeft?.();
|
||||
}
|
||||
} else {
|
||||
// Vertikaler Swipe
|
||||
if (deltaY > 0) {
|
||||
onSwipeDown?.();
|
||||
} else {
|
||||
onSwipeUp?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
node.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Long Press für mobile Geräte
|
||||
interface LongPressOptions {
|
||||
duration?: number;
|
||||
onLongPress?: (event: PointerEvent | TouchEvent) => void;
|
||||
}
|
||||
|
||||
export const longPress: Action<HTMLElement, LongPressOptions> = (node, options = {}) => {
|
||||
const { duration = 500, onLongPress } = options;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let startEvent: PointerEvent | TouchEvent;
|
||||
|
||||
function startLongPress(event: PointerEvent | TouchEvent) {
|
||||
startEvent = event;
|
||||
timer = setTimeout(() => {
|
||||
onLongPress?.(startEvent);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Touch Events
|
||||
node.addEventListener('touchstart', startLongPress, { passive: true });
|
||||
node.addEventListener('touchend', cancelLongPress, { passive: true });
|
||||
node.addEventListener('touchcancel', cancelLongPress, { passive: true });
|
||||
node.addEventListener('touchmove', cancelLongPress, { passive: true });
|
||||
|
||||
// Pointer Events (für bessere Unterstützung)
|
||||
node.addEventListener('pointerdown', startLongPress);
|
||||
node.addEventListener('pointerup', cancelLongPress);
|
||||
node.addEventListener('pointercancel', cancelLongPress);
|
||||
node.addEventListener('pointermove', cancelLongPress);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
clearTimeout(timer);
|
||||
node.removeEventListener('touchstart', startLongPress);
|
||||
node.removeEventListener('touchend', cancelLongPress);
|
||||
node.removeEventListener('touchcancel', cancelLongPress);
|
||||
node.removeEventListener('touchmove', cancelLongPress);
|
||||
node.removeEventListener('pointerdown', startLongPress);
|
||||
node.removeEventListener('pointerup', cancelLongPress);
|
||||
node.removeEventListener('pointercancel', cancelLongPress);
|
||||
node.removeEventListener('pointermove', cancelLongPress);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Touch-freundliche Drag & Drop
|
||||
interface TouchDragOptions {
|
||||
onDragStart?: (event: PointerEvent | TouchEvent) => void;
|
||||
onDragMove?: (event: PointerEvent | TouchEvent, deltaX: number, deltaY: number) => void;
|
||||
onDragEnd?: (event: PointerEvent | TouchEvent) => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const touchDrag: Action<HTMLElement, TouchDragOptions> = (node, options = {}) => {
|
||||
const { onDragStart, onDragMove, onDragEnd, threshold = 5 } = options;
|
||||
|
||||
let isDragging = false;
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
let lastX: number;
|
||||
let lastY: number;
|
||||
|
||||
function handleStart(event: PointerEvent | TouchEvent) {
|
||||
let clientX: number, clientY: number;
|
||||
|
||||
if (event instanceof TouchEvent && event.touches.length > 0) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else if (event instanceof PointerEvent) {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
startX = lastX = clientX;
|
||||
startY = lastY = clientY;
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
function handleMove(event: PointerEvent | TouchEvent) {
|
||||
let clientX: number, clientY: number;
|
||||
|
||||
if (event instanceof TouchEvent && event.touches.length > 0) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else if (event instanceof PointerEvent) {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = clientX - lastX;
|
||||
const deltaY = clientY - lastY;
|
||||
const totalDeltaX = clientX - startX;
|
||||
const totalDeltaY = clientY - startY;
|
||||
|
||||
// Prüfe ob Drag-Threshold erreicht wurde
|
||||
if (!isDragging && (Math.abs(totalDeltaX) > threshold || Math.abs(totalDeltaY) > threshold)) {
|
||||
isDragging = true;
|
||||
onDragStart?.(event);
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
onDragMove?.(event, deltaX, deltaY);
|
||||
}
|
||||
|
||||
lastX = clientX;
|
||||
lastY = clientY;
|
||||
}
|
||||
|
||||
function handleEnd(event: PointerEvent | TouchEvent) {
|
||||
if (isDragging) {
|
||||
onDragEnd?.(event);
|
||||
}
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
// Touch Events
|
||||
node.addEventListener('touchstart', handleStart, { passive: true });
|
||||
node.addEventListener('touchmove', handleMove, { passive: false });
|
||||
node.addEventListener('touchend', handleEnd, { passive: true });
|
||||
node.addEventListener('touchcancel', handleEnd, { passive: true });
|
||||
|
||||
// Pointer Events
|
||||
node.addEventListener('pointerdown', handleStart);
|
||||
node.addEventListener('pointermove', handleMove);
|
||||
node.addEventListener('pointerup', handleEnd);
|
||||
node.addEventListener('pointercancel', handleEnd);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', handleStart);
|
||||
node.removeEventListener('touchmove', handleMove);
|
||||
node.removeEventListener('touchend', handleEnd);
|
||||
node.removeEventListener('touchcancel', handleEnd);
|
||||
node.removeEventListener('pointerdown', handleStart);
|
||||
node.removeEventListener('pointermove', handleMove);
|
||||
node.removeEventListener('pointerup', handleEnd);
|
||||
node.removeEventListener('pointercancel', handleEnd);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Utility: Touch-Gerät erkennen
|
||||
export function isTouchDevice(): boolean {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
// Utility: Optimale Touch-Target-Größe prüfen
|
||||
export function isOptimalTouchTarget(element: HTMLElement): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const minSize = 44; // 44px ist die empfohlene Mindestgröße für Touch-Targets
|
||||
return rect.width >= minSize && rect.height >= minSize;
|
||||
}
|
||||
145
apps-archived/uload/apps/web/src/lib/analytics.ts
Normal file
145
apps-archived/uload/apps/web/src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Umami Analytics Event Tracking
|
||||
* Provides type-safe event tracking with Umami Analytics
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName: string, data?: Record<string, string | number | boolean>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names for consistent tracking across the application
|
||||
*/
|
||||
export const EVENTS = {
|
||||
// Link events
|
||||
LINK_CREATED: 'link-created',
|
||||
LINK_EDITED: 'link-edited',
|
||||
LINK_DELETED: 'link-deleted',
|
||||
LINK_CLICKED: 'link-clicked',
|
||||
LINK_COPIED: 'link-copied',
|
||||
LINK_SHARED: 'link-shared',
|
||||
LINK_QR_GENERATED: 'link-qr-generated',
|
||||
LINK_QR_DOWNLOADED: 'link-qr-downloaded',
|
||||
LINK_EXPIRED: 'link-expired',
|
||||
LINK_PASSWORD_SET: 'link-password-set',
|
||||
LINK_PASSWORD_UNLOCKED: 'link-password-unlocked',
|
||||
|
||||
// User events
|
||||
USER_SIGNUP: 'user-signup',
|
||||
USER_LOGIN: 'user-login',
|
||||
USER_LOGOUT: 'user-logout',
|
||||
USER_PROFILE_UPDATED: 'user-profile-updated',
|
||||
USER_PASSWORD_RESET: 'user-password-reset',
|
||||
|
||||
// Dashboard events
|
||||
DASHBOARD_VIEWED: 'dashboard-viewed',
|
||||
ANALYTICS_VIEWED: 'analytics-viewed',
|
||||
PROFILE_VIEWED: 'profile-viewed',
|
||||
|
||||
// Search and filter
|
||||
SEARCH_PERFORMED: 'search-performed',
|
||||
FILTER_APPLIED: 'filter-applied',
|
||||
SORT_CHANGED: 'sort-changed',
|
||||
|
||||
// Error events
|
||||
ERROR_OCCURRED: 'error-occurred',
|
||||
RATE_LIMITED: 'rate-limited',
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
|
||||
|
||||
/**
|
||||
* Track an event with Umami Analytics
|
||||
* @param eventName - The name of the event to track
|
||||
* @param data - Optional data to send with the event (will be converted to strings)
|
||||
*/
|
||||
export function trackEvent(eventName: EventName | string, data?: Record<string, any>): void {
|
||||
if (typeof window === 'undefined' || !window.umami) {
|
||||
console.debug('Umami not available, skipping event:', eventName, data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert all data values to strings (Umami requirement)
|
||||
const stringData = data
|
||||
? Object.entries(data).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = String(value);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
window.umami.track(eventName, stringData);
|
||||
console.debug('Event tracked:', eventName, stringData);
|
||||
} catch (error) {
|
||||
console.error('Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a link click event
|
||||
*/
|
||||
export function trackLinkClick(linkData: {
|
||||
shortCode: string;
|
||||
username: string;
|
||||
hasPassword?: boolean;
|
||||
isExpiring?: boolean;
|
||||
}): void {
|
||||
trackEvent(EVENTS.LINK_CLICKED, {
|
||||
short_code: linkData.shortCode,
|
||||
username: linkData.username,
|
||||
has_password: linkData.hasPassword || false,
|
||||
is_expiring: linkData.isExpiring || false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a link creation event
|
||||
*/
|
||||
export function trackLinkCreated(linkData: {
|
||||
shortCode: string;
|
||||
hasPassword?: boolean;
|
||||
hasExpiry?: boolean;
|
||||
hasClickLimit?: boolean;
|
||||
}): void {
|
||||
trackEvent(EVENTS.LINK_CREATED, {
|
||||
short_code: linkData.shortCode,
|
||||
has_password: linkData.hasPassword || false,
|
||||
has_expiry: linkData.hasExpiry || false,
|
||||
has_click_limit: linkData.hasClickLimit || false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track user authentication events
|
||||
*/
|
||||
export function trackAuth(type: 'signup' | 'login' | 'logout', method?: string): void {
|
||||
const eventMap = {
|
||||
signup: EVENTS.USER_SIGNUP,
|
||||
login: EVENTS.USER_LOGIN,
|
||||
logout: EVENTS.USER_LOGOUT,
|
||||
};
|
||||
|
||||
trackEvent(eventMap[type], method ? { method } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track error events
|
||||
*/
|
||||
export function trackError(error: {
|
||||
type: string;
|
||||
message?: string;
|
||||
code?: string | number;
|
||||
}): void {
|
||||
trackEvent(EVENTS.ERROR_OCCURRED, {
|
||||
error_type: error.type,
|
||||
error_message: error.message || 'Unknown error',
|
||||
error_code: error.code || 'unknown',
|
||||
});
|
||||
}
|
||||
1
apps-archived/uload/apps/web/src/lib/assets/favicon.svg
Normal file
1
apps-archived/uload/apps/web/src/lib/assets/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
146
apps-archived/uload/apps/web/src/lib/auth-helper.ts
Normal file
146
apps-archived/uload/apps/web/src/lib/auth-helper.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { pb } from './pocketbase';
|
||||
import { generateUsernameFromEmail } from './username';
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}
|
||||
|
||||
export interface RegisterResult {
|
||||
success: boolean;
|
||||
user?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function registerUser(data: RegisterData): Promise<RegisterResult> {
|
||||
try {
|
||||
const email = data.email.toLowerCase().trim();
|
||||
|
||||
// Basic validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return { success: false, error: 'Please enter a valid email address' };
|
||||
}
|
||||
|
||||
if (data.password !== data.passwordConfirm) {
|
||||
return { success: false, error: 'Passwords do not match' };
|
||||
}
|
||||
|
||||
if (data.password.length < 8) {
|
||||
return { success: false, error: 'Password must be at least 8 characters' };
|
||||
}
|
||||
|
||||
// Generate unique username
|
||||
let username = generateUsernameFromEmail(email);
|
||||
let attempts = 0;
|
||||
|
||||
// Try to find unique username
|
||||
while (attempts < 10) {
|
||||
try {
|
||||
await pb.collection('users').getFirstListItem(`username="${username}"`);
|
||||
// Username exists, add random suffix
|
||||
username = `${generateUsernameFromEmail(email)}${Math.floor(Math.random() * 9999)}`;
|
||||
attempts++;
|
||||
} catch {
|
||||
// Username is available
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create user with minimal data - DO NOT provide ID
|
||||
const userData = {
|
||||
email,
|
||||
password: data.password,
|
||||
passwordConfirm: data.passwordConfirm,
|
||||
username,
|
||||
emailVisibility: true,
|
||||
};
|
||||
|
||||
console.log('Creating user with minimal data:', { email, username });
|
||||
console.log('PocketBase URL:', pb.baseUrl);
|
||||
|
||||
const newUser = await pb.collection('users').create(userData);
|
||||
|
||||
// Auto-login after registration
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, data.password);
|
||||
} catch (loginErr) {
|
||||
console.error('Auto-login failed:', loginErr);
|
||||
// User created but login failed - still success
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
|
||||
// Parse error details
|
||||
const errorData = error?.response?.data || error?.data?.data || error?.data || {};
|
||||
|
||||
// Log full error for debugging
|
||||
console.error('Full registration error:', JSON.stringify(errorData, null, 2));
|
||||
|
||||
// Handle specific errors
|
||||
if (errorData.email?.message) {
|
||||
if (errorData.email.message.includes('already exists')) {
|
||||
return { success: false, error: 'This email is already registered. Please login instead.' };
|
||||
}
|
||||
return { success: false, error: errorData.email.message };
|
||||
}
|
||||
|
||||
if (errorData.username?.message) {
|
||||
// Try again with different username
|
||||
console.log('Username conflict, this should not happen');
|
||||
return { success: false, error: 'Username generation failed. Please try again.' };
|
||||
}
|
||||
|
||||
if (errorData.password?.message) {
|
||||
return { success: false, error: errorData.password.message };
|
||||
}
|
||||
|
||||
if (errorData.id?.message) {
|
||||
// ID error - this is the main issue we're trying to fix
|
||||
console.error('Critical: ID field error detected');
|
||||
console.error('ID error details:', errorData.id);
|
||||
// Try to understand the error
|
||||
if (errorData.id.message.includes('blank') || errorData.id.message.includes('required')) {
|
||||
console.error('PocketBase is not auto-generating IDs!');
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Registration system error. Please try again later or contact support.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for any field-level errors
|
||||
for (const field in errorData) {
|
||||
if (typeof errorData[field] === 'object' && errorData[field]?.message) {
|
||||
return { success: false, error: `${field}: ${errorData[field].message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || 'Registration failed. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginUser(email: string, password: string) {
|
||||
try {
|
||||
const authData = await pb
|
||||
.collection('users')
|
||||
.authWithPassword(email.toLowerCase().trim(), password);
|
||||
return { success: true, user: authData.record };
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
}
|
||||
219
apps-archived/uload/apps/web/src/lib/cache.test.ts
Normal file
219
apps-archived/uload/apps/web/src/lib/cache.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { cache, cacheKey, CacheKeys } from './cache';
|
||||
|
||||
describe('Cache System', () => {
|
||||
beforeEach(() => {
|
||||
cache.clear();
|
||||
});
|
||||
|
||||
describe('Basic Cache Operations', () => {
|
||||
test('should set and get values', () => {
|
||||
const key = 'test-key';
|
||||
const value = { data: 'test' };
|
||||
|
||||
cache.set(key, value);
|
||||
const result = cache.get(key);
|
||||
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
test('should return null for non-existent keys', () => {
|
||||
const result = cache.get('non-existent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle TTL expiration', async () => {
|
||||
const key = 'ttl-test';
|
||||
const value = 'test-value';
|
||||
const shortTTL = 10; // 10ms
|
||||
|
||||
cache.set(key, value, shortTTL);
|
||||
|
||||
// Should be available immediately
|
||||
expect(cache.get(key)).toBe(value);
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// Should be null after expiration
|
||||
expect(cache.get(key)).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete specific keys', () => {
|
||||
cache.set('key1', 'value1');
|
||||
cache.set('key2', 'value2');
|
||||
|
||||
cache.delete('key1');
|
||||
|
||||
expect(cache.get('key1')).toBeNull();
|
||||
expect(cache.get('key2')).toBe('value2');
|
||||
});
|
||||
|
||||
test('should clear all keys', () => {
|
||||
cache.set('key1', 'value1');
|
||||
cache.set('key2', 'value2');
|
||||
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get('key1')).toBeNull();
|
||||
expect(cache.get('key2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Key Generation', () => {
|
||||
test('should generate cache keys correctly', () => {
|
||||
const key = cacheKey('user', 123, 'profile');
|
||||
expect(key).toBe('user:123:profile');
|
||||
});
|
||||
|
||||
test('should handle different data types in keys', () => {
|
||||
const key = cacheKey('prefix', 42, 'suffix', true);
|
||||
expect(key).toBe('prefix:42:suffix:true');
|
||||
});
|
||||
|
||||
test('should generate predefined cache keys', () => {
|
||||
expect(CacheKeys.userLinks('user123')).toBe('user:user123:links');
|
||||
expect(CacheKeys.linkStats('link456')).toBe('link:link456:stats');
|
||||
expect(CacheKeys.userProfile('john')).toBe('profile:john');
|
||||
expect(CacheKeys.linkRedirect('abc123')).toBe('redirect:abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Cleanup', () => {
|
||||
test('should cleanup expired entries', async () => {
|
||||
const shortTTL = 10; // 10ms
|
||||
|
||||
cache.set('key1', 'value1', shortTTL);
|
||||
cache.set('key2', 'value2', 60000); // 1 minute
|
||||
|
||||
// Wait for first key to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
cache.cleanup();
|
||||
|
||||
expect(cache.get('key1')).toBeNull();
|
||||
expect(cache.get('key2')).toBe('value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
test('should handle typed values correctly', () => {
|
||||
interface TestData {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const key = 'typed-test';
|
||||
const value: TestData = { id: '123', name: 'test', count: 42 };
|
||||
|
||||
cache.set<TestData>(key, value);
|
||||
const result = cache.get<TestData>(key);
|
||||
|
||||
expect(result).toEqual(value);
|
||||
expect(result?.id).toBe('123');
|
||||
expect(result?.count).toBe(42);
|
||||
});
|
||||
|
||||
test('should handle arrays and objects', () => {
|
||||
const arrayKey = 'array-test';
|
||||
const arrayValue = [1, 2, 3, 'test'];
|
||||
|
||||
const objectKey = 'object-test';
|
||||
const objectValue = {
|
||||
nested: { deep: true },
|
||||
array: [1, 2, 3],
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
|
||||
cache.set(arrayKey, arrayValue);
|
||||
cache.set(objectKey, objectValue);
|
||||
|
||||
expect(cache.get(arrayKey)).toEqual(arrayValue);
|
||||
expect(cache.get(objectKey)).toEqual(objectValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('should handle undefined and null values', () => {
|
||||
cache.set('null-test', null);
|
||||
cache.set('undefined-test', undefined);
|
||||
|
||||
expect(cache.get('null-test')).toBeNull();
|
||||
expect(cache.get('undefined-test')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle empty strings and zero values', () => {
|
||||
cache.set('empty-string', '');
|
||||
cache.set('zero', 0);
|
||||
cache.set('false', false);
|
||||
|
||||
expect(cache.get('empty-string')).toBe('');
|
||||
expect(cache.get('zero')).toBe(0);
|
||||
expect(cache.get('false')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle concurrent access', () => {
|
||||
const key = 'concurrent-test';
|
||||
|
||||
// Simulate concurrent writes
|
||||
cache.set(key, 'value1');
|
||||
cache.set(key, 'value2');
|
||||
cache.set(key, 'value3');
|
||||
|
||||
// Last write should win
|
||||
expect(cache.get(key)).toBe('value3');
|
||||
});
|
||||
|
||||
test('should handle very long keys', () => {
|
||||
const longKey = 'a'.repeat(1000);
|
||||
const value = 'test-value';
|
||||
|
||||
cache.set(longKey, value);
|
||||
expect(cache.get(longKey)).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
test('should handle large number of entries efficiently', () => {
|
||||
const startTime = Date.now();
|
||||
const entryCount = 1000;
|
||||
|
||||
// Set many entries
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
cache.set(`key-${i}`, `value-${i}`);
|
||||
}
|
||||
|
||||
// Get many entries
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
expect(cache.get(`key-${i}`)).toBe(`value-${i}`);
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time (1 second for 1000 entries)
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('should handle large values efficiently', () => {
|
||||
const largeValue = {
|
||||
data: 'x'.repeat(10000),
|
||||
array: Array(1000).fill('test'),
|
||||
nested: {
|
||||
deep: {
|
||||
very: {
|
||||
deep: 'value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const key = 'large-value-test';
|
||||
cache.set(key, largeValue);
|
||||
|
||||
const result = cache.get(key);
|
||||
expect(result).toEqual(largeValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps-archived/uload/apps/web/src/lib/cache.ts
Normal file
93
apps-archived/uload/apps/web/src/lib/cache.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Simple in-memory cache with TTL for server-side caching
|
||||
// In Produktion kann das durch Redis/Valkey ersetzt werden
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class SimpleCache {
|
||||
private cache = new Map<string, CacheEntry<any>>();
|
||||
private readonly defaultTTL = 5 * 60 * 1000; // 5 Minuten default
|
||||
|
||||
set<T>(key: string, data: T, ttlMs: number = this.defaultTTL): void {
|
||||
const expiresAt = Date.now() + ttlMs;
|
||||
this.cache.set(key, { data, expiresAt });
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
// Periodisches Cleanup abgelaufener Einträge
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Globale Cache-Instanz
|
||||
export const cache = new SimpleCache();
|
||||
|
||||
// Cleanup alle 10 Minuten
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(() => cache.cleanup(), 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Helper Funktionen für häufige Cache-Pattern
|
||||
export function cacheKey(...parts: (string | number)[]): string {
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
// Cache-Decorator für async Funktionen
|
||||
export function cached<T>(keyGenerator: (...args: any[]) => string, ttlMs: number = 5 * 60 * 1000) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]): Promise<T> {
|
||||
const key = keyGenerator(...args);
|
||||
const cached = cache.get<T>(key);
|
||||
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = await originalMethod.apply(this, args);
|
||||
cache.set(key, result, ttlMs);
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
// Spezielle Cache-Keys für uLoad
|
||||
export const CacheKeys = {
|
||||
userLinks: (userId: string) => cacheKey('user', userId, 'links'),
|
||||
linkStats: (linkId: string) => cacheKey('link', linkId, 'stats'),
|
||||
userProfile: (username: string) => cacheKey('profile', username),
|
||||
linkRedirect: (shortCode: string) => cacheKey('redirect', shortCode),
|
||||
analyticsDaily: (linkId: string, date: string) => cacheKey('analytics', linkId, date),
|
||||
userCards: (userId: string) => cacheKey('user', userId, 'cards'),
|
||||
publicCard: (username: string, cardId: string) => cacheKey('public', username, cardId),
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, User, Check, Users, UserPlus } from 'lucide-svelte';
|
||||
import { accountsStore, currentViewingAccount } from '$lib/stores/accounts';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as m from '$paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
position?: 'right' | 'left-outside';
|
||||
}
|
||||
|
||||
let { position = 'right' }: Props = $props();
|
||||
let showDropdown = $state(false);
|
||||
let accounts = $derived($accountsStore);
|
||||
let currentAccount = $derived($currentViewingAccount);
|
||||
|
||||
function toggleDropdown() {
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
async function switchToAccount(accountId: string) {
|
||||
await accountsStore.switchViewingContext(accountId);
|
||||
showDropdown = false;
|
||||
// Force page reload to update data
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function getAccountDisplayName(account: any): string {
|
||||
if (!account) return 'Unknown';
|
||||
return account.name || account.username || account.email;
|
||||
}
|
||||
|
||||
function addAccount() {
|
||||
showDropdown = false;
|
||||
// Navigate to login page for adding existing account
|
||||
goto('/login?additional=true');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside={handleClickOutside}>
|
||||
<button
|
||||
onclick={toggleDropdown}
|
||||
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{#if currentAccount}
|
||||
{#if accounts.viewingAs !== accounts.currentUser?.id}
|
||||
<Users class="h-4 w-4 text-purple-500" />
|
||||
{:else}
|
||||
<User class="h-4 w-4 text-theme-text-muted" />
|
||||
{/if}
|
||||
<span class="max-w-[150px] truncate">
|
||||
{getAccountDisplayName(currentAccount)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{:else if accounts.currentUser}
|
||||
<User class="h-4 w-4 text-theme-text-muted" />
|
||||
<span class="max-w-[150px] truncate">
|
||||
{getAccountDisplayName(accounts.currentUser)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
class="absolute z-50 {position === 'left-outside'
|
||||
? 'left-0 top-full mt-2'
|
||||
: 'right-0 mt-2'} w-72 {position === 'left-outside'
|
||||
? 'origin-top-left'
|
||||
: 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
>
|
||||
<!-- Personal Account Section -->
|
||||
{#if accounts.currentUser}
|
||||
<div class="border-b border-theme-border p-2">
|
||||
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
|
||||
{m.account_my_account()}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => switchToAccount(accounts.currentUser.id)}
|
||||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<User class="h-5 w-5 text-theme-text-muted" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getAccountDisplayName(accounts.currentUser)}
|
||||
</div>
|
||||
<div class="text-xs text-theme-text-muted">
|
||||
@{accounts.currentUser.username}
|
||||
</div>
|
||||
</div>
|
||||
{#if accounts.viewingAs === accounts.currentUser.id}
|
||||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Team Accounts Section -->
|
||||
{#if accounts.sharedAccounts && accounts.sharedAccounts.length > 0}
|
||||
<div class="border-b border-theme-border p-2">
|
||||
<div class="px-2 py-1 text-xs font-medium uppercase text-theme-text-muted">
|
||||
{m.account_team_accounts()}
|
||||
</div>
|
||||
{#each accounts.sharedAccounts as shared}
|
||||
{#if shared.expand?.owner}
|
||||
<button
|
||||
onclick={() => switchToAccount(shared.owner)}
|
||||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<Users class="h-5 w-5 text-purple-500" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getAccountDisplayName(shared.expand.owner)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="text-theme-text-muted">
|
||||
@{shared.expand.owner.username}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-900/20 dark:text-purple-400"
|
||||
>
|
||||
{m.account_team_member()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if accounts.viewingAs === shared.owner}
|
||||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty State for Team Accounts -->
|
||||
<div class="border-b border-theme-border p-4">
|
||||
<p class="text-center text-xs text-theme-text-muted">
|
||||
{m.account_no_team_accounts()}
|
||||
</p>
|
||||
<p class="mt-1 text-center text-xs text-theme-text-muted">
|
||||
{m.account_team_invite_info()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Account Button -->
|
||||
<div class="border-t border-theme-border p-2">
|
||||
<button
|
||||
onclick={addAccount}
|
||||
class="hover:bg-theme-primary/10 flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<div class="bg-theme-primary/10 flex h-5 w-5 items-center justify-center rounded-full">
|
||||
<UserPlus class="h-3.5 w-3.5 text-theme-primary" />
|
||||
</div>
|
||||
<span class="text-theme-text">{m.account_add_account()}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
class: className = '',
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
secondary: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover',
|
||||
ghost: 'bg-transparent text-theme-text hover:bg-theme-surface',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-2 text-base',
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
rounded-lg transition-colors
|
||||
${className}
|
||||
`.trim()
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class={classes} {...restProps}>
|
||||
{@render children()}
|
||||
</button>
|
||||
186
apps-archived/uload/apps/web/src/lib/components/DataTable.svelte
Normal file
186
apps-archived/uload/apps/web/src/lib/components/DataTable.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: TableColumn[];
|
||||
items: any[];
|
||||
title?: string;
|
||||
mobileBreakpoint?: number;
|
||||
tabletBreakpoint?: number;
|
||||
emptyMessage?: string;
|
||||
children: Snippet<[any, TableColumn[]]>;
|
||||
mobileCard?: Snippet<[any]>;
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
items,
|
||||
title,
|
||||
mobileBreakpoint = 768,
|
||||
tabletBreakpoint = 1024,
|
||||
emptyMessage = 'No items found',
|
||||
children,
|
||||
mobileCard,
|
||||
}: Props = $props();
|
||||
|
||||
let windowWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1200);
|
||||
let isMobile = $derived(windowWidth < mobileBreakpoint);
|
||||
let isTablet = $derived(windowWidth >= mobileBreakpoint && windowWidth < tabletBreakpoint);
|
||||
let isDesktop = $derived(windowWidth >= tabletBreakpoint);
|
||||
|
||||
// Filter columns based on screen size
|
||||
let visibleColumns = $derived(
|
||||
columns.filter((col) => {
|
||||
if (isMobile && col.hideOnMobile) return false;
|
||||
if (isTablet && col.hideOnTablet) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Generate grid template columns
|
||||
let gridTemplate = $derived(() => {
|
||||
if (isMobile) return 'grid-cols-1';
|
||||
|
||||
const widths = visibleColumns.map((col) => {
|
||||
if (col.width === 'flex') return '1fr';
|
||||
if (col.width) return col.width;
|
||||
return 'auto';
|
||||
});
|
||||
|
||||
// For Tailwind, we need to use predefined classes or inline styles
|
||||
return widths.join(' ');
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handleResize = () => {
|
||||
windowWidth = window.innerWidth;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
function getAlignment(align?: string) {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
return 'text-center justify-center';
|
||||
case 'right':
|
||||
return 'text-right justify-end';
|
||||
default:
|
||||
return 'text-left justify-start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items && items.length > 0}
|
||||
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
|
||||
{#if title}
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-6 py-4">
|
||||
<h2 class="text-xl font-semibold text-theme-text">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isMobile && renderMobileCard}
|
||||
<!-- Mobile Card View -->
|
||||
<div class="divide-y divide-theme-border">
|
||||
{#each items as item}
|
||||
{@html renderMobileCard(item)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Desktop/Tablet Table View -->
|
||||
<!-- Table Header -->
|
||||
<div
|
||||
class="hidden items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text md:grid"
|
||||
style="grid-template-columns: {visibleColumns
|
||||
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
|
||||
.join(' ')}"
|
||||
>
|
||||
{#each visibleColumns as column}
|
||||
<div class={getAlignment(column.align)}>
|
||||
{column.label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="divide-y divide-theme-border">
|
||||
{#each items as item}
|
||||
<!-- Desktop Row -->
|
||||
<div
|
||||
class="hidden items-center gap-4 px-6 py-4 transition-colors hover:bg-theme-surface-hover md:grid"
|
||||
style="grid-template-columns: {visibleColumns
|
||||
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
|
||||
.join(' ')}"
|
||||
>
|
||||
{#each visibleColumns as column}
|
||||
<div class={getAlignment(column.align)}>
|
||||
{#if column.render}
|
||||
{@html column.render(item)}
|
||||
{:else if column.key.includes('.')}
|
||||
<!-- Handle nested properties -->
|
||||
{@const keys = column.key.split('.')}
|
||||
{@const value = keys.reduce((obj, key) => obj?.[key], item)}
|
||||
{value || '-'}
|
||||
{:else}
|
||||
{item[column.key] || '-'}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card -->
|
||||
<div
|
||||
class="space-y-3 bg-theme-surface p-4 transition-colors hover:bg-theme-surface-hover md:hidden"
|
||||
>
|
||||
{#if renderMobileCard}
|
||||
{@html renderMobileCard(item)}
|
||||
{:else}
|
||||
<!-- Default mobile layout -->
|
||||
<div class="space-y-2">
|
||||
{#each columns.filter((col) => !col.hideOnMobile) as column}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-theme-text-muted">{column.label}:</span>
|
||||
<span class="text-theme-text">
|
||||
{#if column.render}
|
||||
{@html column.render(item)}
|
||||
{:else if column.key.includes('.')}
|
||||
{@const keys = column.key.split('.')}
|
||||
{@const value = keys.reduce((obj, key) => obj?.[key], item)}
|
||||
{value || '-'}
|
||||
{:else}
|
||||
{item[column.key] || '-'}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
|
||||
<p class="text-theme-text-muted">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Add any custom styles if needed */
|
||||
</style>
|
||||
197
apps-archived/uload/apps/web/src/lib/components/Dropdown.svelte
Normal file
197
apps-archived/uload/apps/web/src/lib/components/Dropdown.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface DropdownItem {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
action?: () => void;
|
||||
href?: string;
|
||||
type?: 'button' | 'submit' | 'link' | 'form';
|
||||
formAction?: string;
|
||||
formMethod?: 'POST' | 'GET';
|
||||
formData?: Record<string, string>;
|
||||
divider?: boolean;
|
||||
enhanceOptions?: (options: any) => any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[];
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
position?: 'left' | 'right';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
buttonText = 'Actions',
|
||||
buttonIcon,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
position = 'right',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let dropdownRef: HTMLDivElement;
|
||||
let buttonRef: HTMLButtonElement;
|
||||
let menuRef: HTMLDivElement;
|
||||
let dropdownPosition = $state({ top: 0, left: 0 });
|
||||
|
||||
function toggleDropdown() {
|
||||
if (!isOpen && buttonRef) {
|
||||
const rect = buttonRef.getBoundingClientRect();
|
||||
dropdownPosition = {
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: position === 'left' ? rect.left + window.scrollX : rect.right + window.scrollX - 192,
|
||||
};
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
// Check if click is outside both the dropdown container and the menu
|
||||
if (dropdownRef && !dropdownRef.contains(target) && menuRef && !menuRef.contains(target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: DropdownItem) {
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function getItemClasses(color?: string) {
|
||||
const baseClasses =
|
||||
'flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors';
|
||||
if (!color) return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
|
||||
|
||||
switch (color) {
|
||||
case '#dc2626':
|
||||
return `${baseClasses} text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20`;
|
||||
case '#ea580c':
|
||||
return `${baseClasses} text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20`;
|
||||
case '#16a34a':
|
||||
return `${baseClasses} text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20`;
|
||||
case '#2563eb':
|
||||
return `${baseClasses} text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20`;
|
||||
case '#9333ea':
|
||||
return `${baseClasses} text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20`;
|
||||
case '#4f46e5':
|
||||
return `${baseClasses} text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20`;
|
||||
default:
|
||||
return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-3 py-2 text-base',
|
||||
lg: 'px-4 py-3 text-lg',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-theme-primary text-white hover:bg-theme-primary-hover',
|
||||
secondary:
|
||||
'bg-theme-surface border border-theme-border text-theme-text hover:bg-theme-surface-hover',
|
||||
ghost: 'text-theme-text hover:bg-theme-surface-hover',
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
left: 'left-0',
|
||||
right: 'right-0',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative {className}" bind:this={dropdownRef}>
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
onclick={toggleDropdown}
|
||||
class="inline-flex items-center gap-2 rounded-lg font-medium transition-colors {sizeClasses[
|
||||
size
|
||||
]} {variantClasses[variant]}"
|
||||
type="button"
|
||||
>
|
||||
{#if buttonIcon}
|
||||
{@html buttonIcon}
|
||||
{/if}
|
||||
<span>{buttonText}</span>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="fixed z-[9999] min-w-[12rem] rounded-lg border border-theme-border bg-theme-surface shadow-lg"
|
||||
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px"
|
||||
>
|
||||
{#each items as item, index}
|
||||
{#if item.divider}
|
||||
<div class="border-t border-theme-border"></div>
|
||||
{:else if item.type === 'form'}
|
||||
<form
|
||||
method={item.formMethod || 'POST'}
|
||||
action={item.formAction}
|
||||
use:enhance={item.enhanceOptions ||
|
||||
(() => {
|
||||
return async ({ update }) => {
|
||||
closeDropdown();
|
||||
await update();
|
||||
};
|
||||
})}
|
||||
>
|
||||
{#if item.formData}
|
||||
{#each Object.entries(item.formData) as [name, value]}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/each}
|
||||
{/if}
|
||||
<button type="submit" class={getItemClasses(item.color)}>
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{/if}
|
||||
{item.label}
|
||||
</button>
|
||||
</form>
|
||||
{:else if item.href}
|
||||
<a href={item.href} onclick={() => closeDropdown()} class={getItemClasses(item.color)}>
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{/if}
|
||||
{item.label}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => handleItemClick(item)}
|
||||
type={item.type || 'button'}
|
||||
class={getItemClasses(item.color)}
|
||||
>
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{/if}
|
||||
{item.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,644 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import * as m from '$paraglide/messages';
|
||||
import { onMount } from 'svelte';
|
||||
import { themeStore } from '$lib/themes/theme-store';
|
||||
import { themes } from '$lib/themes/presets';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import { activeWorkspace } from '$lib/stores/activeWorkspace';
|
||||
|
||||
interface Props {
|
||||
user?: {
|
||||
email: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let collapsed = $state(false);
|
||||
let mounted = $state(false);
|
||||
let showThemeDropdown = $state(false);
|
||||
|
||||
// Subscribe to workspace stores for reactive URL updates
|
||||
let currentWorkspaceId = $state(activeWorkspace.getId());
|
||||
let currentWorkspaceData = $state(activeWorkspace.getData());
|
||||
|
||||
// Subscribe to changes
|
||||
$effect(() => {
|
||||
const unsubId = activeWorkspace.id.subscribe((id) => {
|
||||
currentWorkspaceId = id;
|
||||
});
|
||||
const unsubData = activeWorkspace.data.subscribe((data) => {
|
||||
currentWorkspaceData = data;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubId();
|
||||
unsubData();
|
||||
};
|
||||
});
|
||||
|
||||
// Reactive URL builder
|
||||
function buildUrl(path: string): string {
|
||||
if (currentWorkspaceId && !path.includes('workspace=')) {
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
return `${path}${separator}workspace=${currentWorkspaceId}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
let themeDropdownElement: HTMLDivElement;
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const currentPath = $page.url.pathname;
|
||||
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
|
||||
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
return cleanPath === cleanHref;
|
||||
}
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed = !collapsed;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('sidebar-collapsed', collapsed.toString());
|
||||
// Dispatch storage event for other components
|
||||
window.dispatchEvent(new Event('storage'));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThemeDropdown(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
showThemeDropdown = !showThemeDropdown;
|
||||
}
|
||||
|
||||
function selectTheme(themeId: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
themeStore.setPreset(themeId);
|
||||
showThemeDropdown = false;
|
||||
}
|
||||
|
||||
function toggleDarkMode(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
themeStore.toggle();
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (themeDropdownElement && !themeDropdownElement.contains(event.target as Node)) {
|
||||
showThemeDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showThemeDropdown) {
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('sidebar-collapsed');
|
||||
if (stored !== null) {
|
||||
collapsed = stored === 'true';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if user && mounted}
|
||||
<aside
|
||||
class="sidebar-transition animate-slide-in fixed bottom-4 left-4 top-4 z-40 hidden flex-col lg:flex"
|
||||
class:w-64={!collapsed}
|
||||
class:w-20={collapsed}
|
||||
>
|
||||
<!-- Glassmorphism Background -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl border transition-all duration-300 {collapsed
|
||||
? 'border-transparent bg-transparent shadow-none backdrop-blur-none'
|
||||
: 'border-theme-border/30 bg-theme-surface/80 shadow-2xl backdrop-blur-xl'}"
|
||||
></div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative flex h-full flex-col p-4">
|
||||
<!-- Logo Section -->
|
||||
<div
|
||||
class="mb-8"
|
||||
class:flex={!collapsed}
|
||||
class:flex-col={collapsed}
|
||||
class:items-center={collapsed}
|
||||
class:justify-between={!collapsed}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 transition-opacity hover:opacity-80"
|
||||
class:justify-center={collapsed}
|
||||
class:mb-4={collapsed}
|
||||
title="uload"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 flex-shrink-0 text-theme-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition text-xl font-bold text-theme-text">uload</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Collapse Toggle -->
|
||||
<button
|
||||
onclick={toggleCollapse}
|
||||
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
|
||||
class:mx-auto={collapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<svg
|
||||
class="icon-transition h-5 w-5"
|
||||
class:rotate-180={collapsed}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notifications & Account Switcher -->
|
||||
{#if !collapsed}
|
||||
<div class="mb-6 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-theme-text-muted"
|
||||
>Benachrichtigungen</span
|
||||
>
|
||||
<NotificationBell position="left-outside" />
|
||||
</div>
|
||||
<WorkspaceSwitcher position="left-outside" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6 flex justify-center">
|
||||
<NotificationBell position="left-outside" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation Items -->
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a
|
||||
href={buildUrl('/my/links')}
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/links'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Links' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Links</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={buildUrl('/my/cards')}
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/cards'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Cards' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Cards</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={buildUrl('/my/tags')}
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/tags'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Tags' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Tags</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/template-store"
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/template-store'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Templates' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Templates</span>
|
||||
{/if}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
|
||||
<!-- Theme Toggle -->
|
||||
<div class="relative" bind:this={themeDropdownElement}>
|
||||
<button
|
||||
onclick={(e) => toggleThemeDropdown(e)}
|
||||
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
|
||||
title={collapsed ? 'Theme' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if themeStore.isDark}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Theme</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showThemeDropdown}
|
||||
<div
|
||||
class="absolute bottom-0 left-full z-50 ml-2 w-64 rounded-lg border border-theme-border bg-theme-surface shadow-lg"
|
||||
>
|
||||
<!-- Dark Mode Toggle -->
|
||||
<div class="border-b border-theme-border p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-theme-text">Dark Mode</span>
|
||||
<button
|
||||
onclick={(e) => toggleDarkMode(e)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {themeStore.isDark
|
||||
? 'bg-theme-accent'
|
||||
: 'bg-theme-border'}"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {themeStore.isDark
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Selection -->
|
||||
<div class="p-2">
|
||||
<p class="mb-2 px-2 text-xs font-medium text-theme-text-muted">Choose Theme</p>
|
||||
<div class="space-y-1">
|
||||
{#each Object.values(themes) as theme}
|
||||
<button
|
||||
onclick={(e) => selectTheme(theme.id, e)}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover {themeStore.preset ===
|
||||
theme.id
|
||||
? 'bg-theme-surface-hover'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Theme Preview Colors -->
|
||||
<div class="flex gap-1">
|
||||
<div
|
||||
class="h-4 w-4 rounded-full border border-theme-border"
|
||||
style="background-color: {themeStore.isDark
|
||||
? theme.colors.dark.primary
|
||||
: theme.colors.light.primary}"
|
||||
/>
|
||||
<div
|
||||
class="h-4 w-4 rounded-full border border-theme-border"
|
||||
style="background-color: {themeStore.isDark
|
||||
? theme.colors.dark.accent
|
||||
: theme.colors.light.accent}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-theme-text">{theme.name}</span
|
||||
>
|
||||
<span class="block text-xs text-theme-text-muted"
|
||||
>{theme.description}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if themeStore.preset === theme.id}
|
||||
<svg
|
||||
class="h-4 w-4 text-theme-accent"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<a
|
||||
href="/settings"
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/settings'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Settings' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Settings</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Team -->
|
||||
<a
|
||||
href="/settings/team"
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/settings/team'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? 'Team' : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">Team</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Pricing -->
|
||||
<a
|
||||
href="/pricing"
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/pricing'
|
||||
)
|
||||
? 'active bg-theme-surface-hover'
|
||||
: ''}"
|
||||
title={collapsed ? m.nav_pricing() : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text"
|
||||
>{m.nav_pricing() || 'Pricing'}</span
|
||||
>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Profile -->
|
||||
{#if user.username}
|
||||
<a
|
||||
href="/p/{user.username}"
|
||||
target="_blank"
|
||||
class="group relative flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover"
|
||||
title={collapsed ? m.nav_profile() : undefined}
|
||||
>
|
||||
<span class="active-indicator"></span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-theme-text-muted group-hover:text-theme-text"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium text-theme-text">{m.nav_profile()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- User Info -->
|
||||
{#if !collapsed}
|
||||
<div class="truncate px-3 py-2 text-xs text-theme-text-muted">
|
||||
{user.email}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Logout -->
|
||||
<form method="POST" action="/login?/logout" class="w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="group relative flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10 dark:text-red-500"
|
||||
title={collapsed ? m.nav_logout() : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}
|
||||
<span class="text-transition font-medium">{m.nav_logout()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition:
|
||||
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.icon-transition {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.text-transition {
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background: var(--theme-primary);
|
||||
border-radius: 0 4px 4px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.active .active-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s ease-out;
|
||||
}
|
||||
</style>
|
||||
201
apps-archived/uload/apps/web/src/lib/components/Footer.svelte
Normal file
201
apps-archived/uload/apps/web/src/lib/components/Footer.svelte
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<script lang="ts">
|
||||
import * as m from '$paraglide/messages';
|
||||
|
||||
let currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer class="border-t border-theme-border bg-theme-surface">
|
||||
<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<!-- Company Info -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<div class="mb-4 flex items-center space-x-2">
|
||||
<svg
|
||||
class="h-8 w-8 text-theme-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-theme-text">uload</span>
|
||||
</div>
|
||||
<p class="max-w-md text-theme-text-muted">
|
||||
Verkürzen Sie Ihre URLs schnell und einfach. Mit erweiterten Funktionen für professionelle
|
||||
Nutzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
|
||||
Navigation
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a href="/" class="text-theme-text-muted transition-colors hover:text-theme-text">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
{m.nav_dashboard()}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/dashboard/tags"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Tags
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/dashboard/cards"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Cards
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/template-store"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Templates
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/features"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/blog" class="text-theme-text-muted transition-colors hover:text-theme-text">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about" class="text-theme-text-muted transition-colors hover:text-theme-text">
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
|
||||
Rechtliches
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
<li>
|
||||
<a
|
||||
href="/impressum"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Impressum
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/datenschutz"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Datenschutz
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/agb" class="text-theme-text-muted transition-colors hover:text-theme-text">
|
||||
AGB
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/sicherheit"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
>
|
||||
Sicherheit
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="mt-8 border-t border-theme-border pt-8">
|
||||
<div class="flex flex-col items-center justify-between space-y-4 sm:flex-row sm:space-y-0">
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
© {currentYear} uload. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
|
||||
<!-- Social Links (optional) -->
|
||||
<div class="flex space-x-6">
|
||||
<a
|
||||
href="https://twitter.com"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://linkedin.com"
|
||||
class="text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import '$lib/i18n';
|
||||
|
||||
let showDropdown = $state(false);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
];
|
||||
|
||||
let currentLanguage = $state(languages[0]);
|
||||
|
||||
// Get current language on mount
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const currentCode = get(locale) || 'en';
|
||||
currentLanguage = languages.find((lang) => lang.code === currentCode) || languages[0];
|
||||
}
|
||||
});
|
||||
|
||||
function changeLanguage(langCode: string) {
|
||||
if (browser) {
|
||||
// Save preference
|
||||
localStorage.setItem('preferred-language', langCode);
|
||||
// Update svelte-i18n locale
|
||||
locale.set(langCode);
|
||||
// Update current language display
|
||||
currentLanguage = languages.find((lang) => lang.code === langCode) || languages[0];
|
||||
// Close dropdown
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span class="text-lg">{currentLanguage.flag}</span>
|
||||
<span class="hidden sm:inline">{currentLanguage.name}</span>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {showDropdown ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="absolute right-0 z-50 mt-2 w-48 rounded-lg border border-theme-border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
{#each languages as lang}
|
||||
<button
|
||||
onclick={() => changeLanguage(lang.code)}
|
||||
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 {lang.code ===
|
||||
currentLanguage.code
|
||||
? 'bg-gray-50 dark:bg-gray-700/50'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-lg">{lang.flag}</span>
|
||||
<span class="text-theme-text">{lang.name}</span>
|
||||
{#if lang.code === currentLanguage.code}
|
||||
<svg class="ml-auto h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:window
|
||||
onclick={(e) => {
|
||||
// Close dropdown when clicking outside
|
||||
if (showDropdown && !(e.target as HTMLElement)?.closest('.relative')) {
|
||||
showDropdown = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { getLimitDisplayInfo } from '$lib/services/link-limits';
|
||||
import { Check, AlertTriangle, X } from 'lucide-svelte';
|
||||
|
||||
let { user } = $props();
|
||||
|
||||
let usageInfo = $derived(getLimitDisplayInfo(user));
|
||||
|
||||
let barColor = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
});
|
||||
|
||||
let textColor = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger':
|
||||
return 'text-red-700';
|
||||
case 'warning':
|
||||
return 'text-yellow-700';
|
||||
default:
|
||||
return 'text-blue-700';
|
||||
}
|
||||
});
|
||||
|
||||
let icon = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger':
|
||||
return X;
|
||||
case 'warning':
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return Check;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svelte:component this={icon} class="h-4 w-4 {textColor}" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{#if usageInfo.unlimited}
|
||||
Unbegrenzte Links
|
||||
{:else}
|
||||
Link-Nutzung diesen Monat
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if !usageInfo.unlimited}
|
||||
<span class="text-sm {textColor}">
|
||||
{usageInfo.current} / {usageInfo.limit}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !usageInfo.unlimited}
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300 {barColor}"
|
||||
style="width: {Math.min(usageInfo.percentage, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{#if usageInfo.status === 'danger'}
|
||||
<span class="font-medium text-red-600 dark:text-red-400">
|
||||
Monatslimit erreicht! Upgrade für mehr Links.
|
||||
</span>
|
||||
{:else if usageInfo.status === 'warning'}
|
||||
<span class="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
{usageInfo.limit - usageInfo.current} Links verbleibend
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-green-600 dark:text-green-400">
|
||||
{usageInfo.limit - usageInfo.current} Links verbleibend
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs font-medium text-green-600 dark:text-green-400">
|
||||
🎉 Du hast unbegrenzten Zugang zu allen Features!
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import * as m from '$paraglide/messages';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||
|
||||
interface Props {
|
||||
user?: {
|
||||
email: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { user, open = false, onClose }: Props = $props();
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const currentPath = $page.url.pathname;
|
||||
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
|
||||
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
return cleanPath === cleanHref;
|
||||
}
|
||||
|
||||
function handleLinkClick() {
|
||||
if (onClose) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if user && open}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 bg-black/50 lg:hidden" onclick={onClose}></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="slide-in fixed bottom-0 left-0 top-0 z-50 w-72 bg-theme-surface shadow-2xl lg:hidden"
|
||||
>
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="h-8 w-8 text-theme-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-theme-text">uload</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Switcher -->
|
||||
<div class="mb-6">
|
||||
<WorkspaceSwitcher />
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a
|
||||
href="/my"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">{m.nav_dashboard()}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/my/links"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/links'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Links</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/my/cards"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/cards'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Cards</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/my/tags"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/my/tags'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Tags</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/template-store"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/template-store'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Templates</span>
|
||||
</a>
|
||||
|
||||
<div class="border-theme-border/30 my-2 border-t"></div>
|
||||
|
||||
<a
|
||||
href="/pricing"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/pricing'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">{m.nav_pricing() || 'Pricing'}</span>
|
||||
</a>
|
||||
|
||||
{#if user.username}
|
||||
<a
|
||||
href="/p/{user.username}"
|
||||
onclick={handleLinkClick}
|
||||
target="_blank"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-text transition-all hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">{m.nav_profile()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/settings'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Settings</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/settings/team"
|
||||
onclick={handleLinkClick}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 transition-all hover:bg-theme-surface-hover {isActive(
|
||||
'/settings/team'
|
||||
)
|
||||
? 'bg-theme-surface-hover text-theme-primary'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">Team</span>
|
||||
</a>
|
||||
|
||||
<div class="px-3 py-2 text-sm text-theme-text-muted">
|
||||
{user.email}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/login?/logout" class="w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-red-600 transition-all hover:bg-red-600/10"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">{m.nav_logout()}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,840 @@
|
|||
<script lang="ts">
|
||||
import { trackAuth } from '$lib/analytics';
|
||||
import ThemeDropdown from '$lib/components/ThemeDropdown.svelte';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import WorkspaceSwitcher from '$lib/components/WorkspaceSwitcher.svelte';
|
||||
import * as m from '$paraglide/messages';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { themeStore } from '$lib/themes/theme-store';
|
||||
import { themes } from '$lib/themes/presets';
|
||||
import { toastMessages } from '$lib/services/toast';
|
||||
|
||||
interface Props {
|
||||
user?: {
|
||||
email: string;
|
||||
username?: string;
|
||||
} | null;
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
let { user, currentPath = '' }: Props = $props();
|
||||
let mobileMenuOpen = $state(false);
|
||||
let scrollProgress = $state(0);
|
||||
let isInFooter = $state(false);
|
||||
let showThemeMenu = $state(false);
|
||||
|
||||
function handleLogout() {
|
||||
trackAuth('logout');
|
||||
toastMessages.logoutSuccess();
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
showThemeMenu = !showThemeMenu;
|
||||
}
|
||||
|
||||
function selectTheme(themeId: string) {
|
||||
themeStore.setPreset(themeId);
|
||||
showThemeMenu = false;
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
themeStore.toggle();
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
|
||||
const cleanHref = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
return cleanPath === cleanHref;
|
||||
}
|
||||
|
||||
function updateScrollProgress() {
|
||||
const footer = document.querySelector('footer');
|
||||
const footerHeight = footer ? footer.offsetHeight : 0;
|
||||
const totalHeight = document.documentElement.scrollHeight;
|
||||
const scrollableHeight = totalHeight - window.innerHeight - footerHeight;
|
||||
const scrollPosition = window.scrollY;
|
||||
scrollProgress = scrollableHeight > 0 ? Math.min(scrollPosition / scrollableHeight, 1) : 0;
|
||||
|
||||
const footerTop = footer ? footer.getBoundingClientRect().top : Infinity;
|
||||
isInFooter = footerTop <= window.innerHeight;
|
||||
}
|
||||
|
||||
function getProgressColor(): string {
|
||||
if (isInFooter) {
|
||||
return 'rgba(148, 163, 184, 0.3)';
|
||||
} else if (scrollProgress < 0.25) {
|
||||
const t = scrollProgress / 0.25;
|
||||
return `rgba(${Math.round(255)}, ${Math.round(0 + 165 * t)}, 0, 0.4)`;
|
||||
} else if (scrollProgress < 0.5) {
|
||||
const t = (scrollProgress - 0.25) / 0.25;
|
||||
return `rgba(255, ${Math.round(165 + 90 * t)}, 0, 0.4)`;
|
||||
} else if (scrollProgress < 0.75) {
|
||||
const t = (scrollProgress - 0.5) / 0.25;
|
||||
return `rgba(${Math.round(255 - 82 * t)}, 255, ${Math.round(47 * t)}, 0.4)`;
|
||||
} else {
|
||||
const t = (scrollProgress - 0.75) / 0.25;
|
||||
return `rgba(${Math.round(173 - 173 * t)}, 255, ${Math.round(47 - 47 * t)}, 0.4)`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateScrollProgress();
|
||||
window.addEventListener('scroll', updateScrollProgress);
|
||||
return () => window.removeEventListener('scroll', updateScrollProgress);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = 'hidden';
|
||||
const main = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (main) main.classList.add('brightness-[0.3]', 'transition-all', 'duration-200');
|
||||
if (footer) footer.classList.add('brightness-[0.3]', 'transition-all', 'duration-200');
|
||||
}
|
||||
} else {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = '';
|
||||
const main = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (main) main.classList.remove('brightness-[0.3]');
|
||||
if (footer) footer.classList.remove('brightness-[0.3]');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav
|
||||
class="bg-theme-surface/80 sticky top-0 z-50 hidden border-b border-theme-border shadow-sm backdrop-blur-xl md:block"
|
||||
>
|
||||
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="z-10 flex-shrink-0">
|
||||
<a href="/" class="flex items-center space-x-2 transition-opacity hover:opacity-80">
|
||||
<svg
|
||||
class="h-8 w-8 text-theme-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xl font-bold text-theme-text">uload</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation - Absolutely Centered -->
|
||||
<div class="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 xl:flex">
|
||||
<div class="flex items-center gap-6">
|
||||
{#if user}
|
||||
<a
|
||||
href="/my/links"
|
||||
class="transition-all {isActive('/my/links')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Links
|
||||
</a>
|
||||
<a
|
||||
href="/my/cards"
|
||||
class="transition-all {isActive('/my/cards')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Cards
|
||||
</a>
|
||||
<a
|
||||
href="/my/tags"
|
||||
class="transition-all {isActive('/my/tags')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Tags
|
||||
</a>
|
||||
<a
|
||||
href="/template-store"
|
||||
class="transition-all {isActive('/template-store')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Templates
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="transition-all {isActive('/pricing')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
|
||||
</a>
|
||||
{#if user.username}
|
||||
<a
|
||||
href="/p/{user.username}"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted transition-all hover:text-theme-text hover:underline"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/features"
|
||||
class="transition-all {isActive('/features')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="transition-all {isActive('/pricing')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
class="transition-all {isActive('/blog')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
Blog
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
class="transition-all {isActive('/about')
|
||||
? 'text-theme-text underline'
|
||||
: 'text-theme-text-muted hover:text-theme-text hover:underline'}"
|
||||
>
|
||||
About
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Actions -->
|
||||
<div class="z-10 flex flex-shrink-0 items-center gap-2">
|
||||
{#if !user}
|
||||
<a
|
||||
href="/login"
|
||||
class="rounded-lg px-4 py-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
|
||||
>
|
||||
{m.nav_login()}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
class="rounded-lg bg-theme-primary px-6 py-2 font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
|
||||
>
|
||||
{m.nav_register()}
|
||||
</a>
|
||||
{:else}
|
||||
<!-- Account Switcher for logged-in users -->
|
||||
<WorkspaceSwitcher />
|
||||
{/if}
|
||||
|
||||
<LanguageSwitcher />
|
||||
<ThemeDropdown />
|
||||
|
||||
<!-- Menu Button -->
|
||||
<button
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text xl:hidden"
|
||||
aria-label="Menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if mobileMenuOpen}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Navigation - Bottom Pill -->
|
||||
<nav
|
||||
class="fixed bottom-4 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 md:hidden"
|
||||
style="--scroll-progress: {scrollProgress}; --progress-color: {getProgressColor()}"
|
||||
>
|
||||
<!-- Progress border layer -->
|
||||
<div class="absolute -inset-[5px] z-[-1] overflow-hidden rounded-full p-[5px]">
|
||||
<div
|
||||
class="scroll-progress-indicator absolute inset-0 rounded-full"
|
||||
style="--scroll-progress: {scrollProgress}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Main navigation content -->
|
||||
<div
|
||||
class="border-theme-border/20 bg-theme-surface/95 relative z-20 flex overflow-hidden rounded-full border-2 shadow-2xl backdrop-blur-xl transition-all duration-300 before:pointer-events-none before:absolute before:inset-0 before:rounded-full before:bg-gradient-to-t before:from-black/20 before:to-transparent"
|
||||
>
|
||||
<!-- Left Half: Logo -->
|
||||
<a
|
||||
href="/"
|
||||
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
|
||||
>
|
||||
<svg class="h-6 w-6 text-theme-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-lg font-semibold text-theme-text">uload</span>
|
||||
</a>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="bg-theme-border/30 relative z-10 w-px"></div>
|
||||
|
||||
<!-- Right Half: Menu -->
|
||||
<button
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
|
||||
aria-label="Menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<span class="text-lg font-medium text-theme-text">Menu</span>
|
||||
<svg class="h-6 w-6 text-theme-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if mobileMenuOpen}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Menu Backdrop -->
|
||||
{#if mobileMenuOpen}
|
||||
<button
|
||||
class="z-35 fixed inset-0 bg-black/40 md:hidden"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (mobileMenuOpen = false)}
|
||||
aria-label="Close mobile menu"
|
||||
style="top: 0;"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile Menu - Dropdown from bottom on mobile, from top on tablet/desktop -->
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="animate-slide-up md:animate-slide-down fixed bottom-[80px] left-1/2 z-40 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 px-4 md:bottom-auto md:top-[65px] md:max-w-md"
|
||||
>
|
||||
<div
|
||||
class="border-theme-border/30 bg-theme-surface/95 flex max-h-[60vh] w-full flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if user}
|
||||
<!-- Main Navigation -->
|
||||
<div class="pb-1">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
|
||||
<a
|
||||
href="/my/links"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/my/links'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Links
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/my/cards"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/my/cards'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Profile Cards
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/my/tags"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/my/tags'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Tags
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/template-store"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/template-store'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Templates
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/pricing'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
|
||||
</span>
|
||||
</a>
|
||||
{#if user.username}
|
||||
<a
|
||||
href="/p/{user.username}"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
target="_blank"
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 group-hover:underline"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Account</h3>
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/settings'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/settings/team"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/settings/team'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Team
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="border-theme-border/30 border-t pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5H9m12 0v6m0 6v4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="flex-1 text-lg font-medium text-theme-text">Theme</span>
|
||||
<ThemeDropdown />
|
||||
</div>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||
/>
|
||||
</svg>
|
||||
<span class="flex-1 text-lg font-medium text-theme-text">Language</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Guest Navigation -->
|
||||
<div class="pb-1">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
|
||||
<a
|
||||
href="/features"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/features'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
Features
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/pricing'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
{m.nav_pricing ? m.nav_pricing() : 'Pricing'}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-lg font-medium text-theme-text transition-all duration-300 {isActive(
|
||||
'/about'
|
||||
)
|
||||
? 'underline'
|
||||
: 'group-hover:underline'}"
|
||||
>
|
||||
About
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="border-theme-border/30 border-t pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5H9m12 0v6m0 6v4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="flex-1 text-lg font-medium text-theme-text">Theme</span>
|
||||
<ThemeDropdown />
|
||||
</div>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||
/>
|
||||
</svg>
|
||||
<span class="flex-1 text-lg font-medium text-theme-text">Language</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sticky Buttons at bottom -->
|
||||
<div class="border-theme-border/30 space-y-2 border-t p-3">
|
||||
{#if !user}
|
||||
<a
|
||||
href="/login"
|
||||
class="block w-full rounded-lg bg-theme-surface-hover px-4 py-2 text-center font-medium text-theme-text transition-colors hover:bg-theme-border"
|
||||
>
|
||||
{m.nav_login()}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
class="block w-full rounded-lg bg-theme-primary px-4 py-2 text-center font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
|
||||
>
|
||||
{m.nav_register()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scroll-progress-indicator {
|
||||
background: conic-gradient(
|
||||
from -90deg at 50% 50%,
|
||||
var(--progress-color, #ef4444) calc(var(--scroll-progress, 0) * 360deg),
|
||||
transparent calc(var(--scroll-progress, 0) * 360deg)
|
||||
);
|
||||
transition: --progress-color 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slide-down 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
<script lang="ts">
|
||||
import { Bell, Check, Trash2, ExternalLink, X } from 'lucide-svelte';
|
||||
import { notifications, unreadCount } from '$lib/stores/notifications';
|
||||
import { pb } from '$lib/pocketbase';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Props {
|
||||
position?: 'right' | 'left-outside';
|
||||
}
|
||||
|
||||
let { position = 'right' }: Props = $props();
|
||||
let showDropdown = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Load notifications on mount
|
||||
notifications.load(pb);
|
||||
|
||||
// Set up real-time subscription
|
||||
pb.collection('notifications').subscribe('*', (e) => {
|
||||
if (e.action === 'create') {
|
||||
notifications.add(e.record);
|
||||
} else if (e.action === 'update') {
|
||||
// Reload notifications to get updated data
|
||||
notifications.load(pb);
|
||||
} else if (e.action === 'delete') {
|
||||
// Remove deleted notification
|
||||
notifications.load(pb);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
pb.collection('notifications').unsubscribe('*');
|
||||
};
|
||||
});
|
||||
|
||||
function handleClickOutside() {
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
async function handleMarkAsRead(notificationId: string) {
|
||||
await notifications.markAsRead(pb, notificationId);
|
||||
}
|
||||
|
||||
async function handleMarkAllAsRead() {
|
||||
await notifications.markAllAsRead(pb);
|
||||
}
|
||||
|
||||
async function handleDelete(notificationId: string) {
|
||||
await notifications.delete(pb, notificationId);
|
||||
}
|
||||
|
||||
async function handleAction(notification: any) {
|
||||
// Mark as read first
|
||||
await handleMarkAsRead(notification.id);
|
||||
|
||||
// Navigate to action URL if available
|
||||
if (notification.action_url) {
|
||||
if (notification.action_url.startsWith('http')) {
|
||||
window.location.href = notification.action_url;
|
||||
} else {
|
||||
goto(notification.action_url);
|
||||
}
|
||||
}
|
||||
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
function getNotificationIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'team_invite':
|
||||
return '👥';
|
||||
case 'team_accepted':
|
||||
return '✅';
|
||||
case 'team_declined':
|
||||
return '⛔';
|
||||
case 'link_shared':
|
||||
return '🔗';
|
||||
case 'system':
|
||||
return '💡';
|
||||
default:
|
||||
return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'team_invite':
|
||||
return 'text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/20';
|
||||
case 'team_accepted':
|
||||
return 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/20';
|
||||
case 'team_declined':
|
||||
return 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/20';
|
||||
case 'link_shared':
|
||||
return 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/20';
|
||||
case 'system':
|
||||
return 'text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/20';
|
||||
default:
|
||||
return 'text-theme-text-muted bg-theme-primary/10';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Gerade eben';
|
||||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||
if (hours < 24) return `vor ${hours} Std.`;
|
||||
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside={handleClickOutside}>
|
||||
<!-- Bell Button -->
|
||||
<button
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="relative p-2 text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
aria-label="Benachrichtigungen"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Bell class="h-5 w-5" />
|
||||
{#if $unreadCount > 0}
|
||||
<span
|
||||
class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary text-xs font-bold text-white"
|
||||
>
|
||||
{$unreadCount > 9 ? '9+' : $unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Panel -->
|
||||
{#if showDropdown}
|
||||
<div
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
class="absolute {position === 'left-outside'
|
||||
? 'left-0 top-full mt-2 origin-top-left'
|
||||
: 'right-0 mt-2 origin-top-right'} z-50 max-h-[600px] w-96 overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-theme-border p-2">
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<h3 class="text-sm font-medium text-theme-text">Benachrichtigungen</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $unreadCount > 0}
|
||||
<button
|
||||
onclick={handleMarkAllAsRead}
|
||||
class="text-xs text-theme-primary transition-colors hover:text-theme-primary-hover"
|
||||
>
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showDropdown = false)}
|
||||
class="rounded-md p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div class="max-h-[500px] overflow-y-auto">
|
||||
{#if $notifications.loading}
|
||||
<div class="p-8 text-center text-theme-text-muted">
|
||||
<div
|
||||
class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-theme-primary"
|
||||
></div>
|
||||
<p class="mt-2 text-sm">Lade Benachrichtigungen...</p>
|
||||
</div>
|
||||
{:else if $notifications.notifications.length === 0}
|
||||
<div class="p-8 text-center text-theme-text-muted">
|
||||
<Bell class="mx-auto mb-3 h-12 w-12 opacity-20" />
|
||||
<p class="text-sm">Keine Benachrichtigungen</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-2">
|
||||
{#each $notifications.notifications as notification, i}
|
||||
<div
|
||||
class="group mb-1 rounded-md px-3 py-3 transition-colors hover:bg-theme-surface-hover {!notification.read
|
||||
? 'bg-theme-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full {getNotificationIconColor(
|
||||
notification.type
|
||||
)}"
|
||||
>
|
||||
<span class="text-base">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<button onclick={() => handleAction(notification)} class="flex-1 text-left">
|
||||
<p class="text-sm font-medium text-theme-text">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-theme-text-muted">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-theme-text-muted">
|
||||
{formatTime(notification.created)}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Actions -->
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{#if !notification.read}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-primary"
|
||||
title="Als gelesen markieren"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(notification.id);
|
||||
}}
|
||||
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if notification.type === 'team_invite' && notification.action_url}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(notification);
|
||||
}}
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-2 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium text-theme-primary transition-colors"
|
||||
>
|
||||
Einladung annehmen
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<script lang="ts">
|
||||
import { ChevronDown, User, Users, Check } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { User as UserType, SharedAccess } from '$lib/types/accounts';
|
||||
|
||||
interface Props {
|
||||
user: UserType | null;
|
||||
sharedAccounts?: SharedAccess[];
|
||||
}
|
||||
|
||||
let { user, sharedAccounts = [] }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let currentAccount = $state<string>(user?.id || '');
|
||||
|
||||
// Get current viewing context from URL params or session
|
||||
$effect(() => {
|
||||
const viewingAs = $page.url.searchParams.get('viewing_as');
|
||||
if (viewingAs) {
|
||||
currentAccount = viewingAs;
|
||||
} else {
|
||||
currentAccount = user?.id || '';
|
||||
}
|
||||
});
|
||||
|
||||
async function switchAccount(accountId: string) {
|
||||
if (accountId === currentAccount) {
|
||||
isOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update URL with viewing context
|
||||
const url = new URL($page.url);
|
||||
if (accountId === user?.id) {
|
||||
url.searchParams.delete('viewing_as');
|
||||
} else {
|
||||
url.searchParams.set('viewing_as', accountId);
|
||||
}
|
||||
|
||||
await goto(url.toString());
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
// Get display name for current account
|
||||
const currentAccountName = $derived(() => {
|
||||
if (currentAccount === user?.id) {
|
||||
return user?.name || user?.username || 'My Account';
|
||||
}
|
||||
|
||||
const shared = sharedAccounts.find((s) => s.owner === currentAccount);
|
||||
if (shared?.expand?.owner) {
|
||||
return shared.expand.owner.name || shared.expand.owner.username || shared.expand.owner.email;
|
||||
}
|
||||
|
||||
return 'Unknown Account';
|
||||
});
|
||||
|
||||
// Check if viewing a shared account
|
||||
const isViewingShared = $derived(currentAccount !== user?.id);
|
||||
</script>
|
||||
|
||||
{#if user && sharedAccounts.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isViewingShared}
|
||||
<Users class="h-4 w-4 text-theme-primary" />
|
||||
{:else}
|
||||
<User class="h-4 w-4" />
|
||||
{/if}
|
||||
<span>{currentAccountName()}</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 {isOpen ? 'rotate-180' : ''} transition-transform" />
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<button onclick={() => (isOpen = false)} class="fixed inset-0 z-40" aria-label="Close menu"
|
||||
></button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-2 w-64 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<!-- My Account -->
|
||||
<button
|
||||
onclick={() => switchAccount(user.id)}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<User class="h-4 w-4 text-theme-text-muted" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-text">My Account</p>
|
||||
<p class="text-xs text-theme-text-muted">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if currentAccount === user.id}
|
||||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if sharedAccounts.length > 0}
|
||||
<div class="my-2 border-t border-theme-border"></div>
|
||||
|
||||
<!-- Shared Accounts -->
|
||||
<div class="mb-1 px-3 py-1">
|
||||
<p class="text-xs font-medium text-theme-text-muted">Team Accounts</p>
|
||||
</div>
|
||||
|
||||
{#each sharedAccounts as shared}
|
||||
{#if shared.invitation_status === 'accepted'}
|
||||
<button
|
||||
onclick={() => switchAccount(shared.owner)}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="h-4 w-4 text-theme-text-muted" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-text">
|
||||
{shared.expand?.owner?.name ||
|
||||
shared.expand?.owner?.username ||
|
||||
'Team Account'}
|
||||
</p>
|
||||
<p class="text-xs text-theme-text-muted">
|
||||
{shared.expand?.owner?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if currentAccount === shared.owner}
|
||||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
159
apps-archived/uload/apps/web/src/lib/components/StatsBar.svelte
Normal file
159
apps-archived/uload/apps/web/src/lib/components/StatsBar.svelte
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
stats?: {
|
||||
totalUsers: number;
|
||||
totalLinks: number;
|
||||
totalFolders: number;
|
||||
totalClicks: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { stats = { totalUsers: 0, totalLinks: 0, totalFolders: 0, totalClicks: 0 } }: Props =
|
||||
$props();
|
||||
|
||||
let displayStats = $state({
|
||||
totalUsers: 0,
|
||||
totalLinks: 0,
|
||||
totalFolders: 0,
|
||||
totalClicks: 0,
|
||||
});
|
||||
|
||||
let isVisible = $state(false);
|
||||
|
||||
// Animate numbers counting up
|
||||
function animateValue(
|
||||
start: number,
|
||||
end: number,
|
||||
duration: number,
|
||||
key: keyof typeof displayStats
|
||||
) {
|
||||
const range = end - start;
|
||||
const startTime = Date.now();
|
||||
|
||||
function update() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const current = Math.floor(start + range * easeOutQuart);
|
||||
|
||||
displayStats[key] = current;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
// Format large numbers with commas
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Trigger visibility animation
|
||||
setTimeout(() => {
|
||||
isVisible = true;
|
||||
}, 100);
|
||||
|
||||
// Start counter animations after a short delay
|
||||
if (stats) {
|
||||
setTimeout(() => {
|
||||
animateValue(0, stats.totalUsers || 0, 1500, 'totalUsers');
|
||||
animateValue(0, stats.totalLinks || 0, 1500, 'totalLinks');
|
||||
animateValue(0, stats.totalFolders || 0, 1500, 'totalFolders');
|
||||
animateValue(0, stats.totalClicks || 0, 1500, 'totalClicks');
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const statItems = [
|
||||
{
|
||||
icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
|
||||
label: 'Users',
|
||||
key: 'totalUsers' as const,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||
label: 'Links',
|
||||
key: 'totalLinks' as const,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z',
|
||||
label: 'Folders',
|
||||
key: 'totalFolders' as const,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: 'M22 12h-4l-3 9L9 3l-3 9H2',
|
||||
label: 'Clicks',
|
||||
key: 'totalClicks' as const,
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="transition-all duration-500 {isVisible ? 'opacity-100' : 'opacity-0'}">
|
||||
<!-- Stats bar -->
|
||||
<div class="rounded-lg border border-theme-border bg-theme-surface shadow-sm">
|
||||
<div class="px-4 py-2 sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
{#each statItems as stat}
|
||||
<div class="group flex items-center gap-2">
|
||||
<!-- Icon -->
|
||||
<svg
|
||||
class="h-5 w-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={stat.icon} />
|
||||
</svg>
|
||||
|
||||
<!-- Stats text -->
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-lg font-bold text-theme-text">
|
||||
{formatNumber(displayStats[stat.key] || 0)}
|
||||
</span>
|
||||
<span class="text-xs text-theme-text-muted">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Additional styles for smooth animations */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import type { Tag } from '$lib/pocketbase';
|
||||
|
||||
interface Props {
|
||||
tag: Tag;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
clickable?: boolean;
|
||||
removable?: boolean;
|
||||
onclick?: () => void;
|
||||
onremove?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tag,
|
||||
size = 'sm',
|
||||
clickable = false,
|
||||
removable = false,
|
||||
onclick,
|
||||
onremove,
|
||||
}: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-1.5 py-0 text-[10px]',
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-1.5 text-base',
|
||||
};
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (clickable && onclick) {
|
||||
e.stopPropagation();
|
||||
onclick();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (onremove) {
|
||||
onremove();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tag && tag.name}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full font-medium transition-all {sizeClasses[
|
||||
size
|
||||
]} {clickable ? 'cursor-pointer hover:scale-105' : ''}"
|
||||
style="background-color: {tag.color || '#3B82F6'}20; color: {tag.color || '#3B82F6'}"
|
||||
onclick={handleClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabindex={clickable ? 0 : -1}
|
||||
>
|
||||
{#if tag.icon && tag.icon.trim()}
|
||||
<span>{tag.icon}</span>
|
||||
{/if}
|
||||
<span>{tag.name}</span>
|
||||
{#if removable}
|
||||
<button
|
||||
onclick={handleRemove}
|
||||
class="ml-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
|
||||
aria-label="Remove tag"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
175
apps-archived/uload/apps/web/src/lib/components/TagCard.svelte
Normal file
175
apps-archived/uload/apps/web/src/lib/components/TagCard.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
import type { Tag } from '$lib/pocketbase';
|
||||
import { enhance } from '$app/forms';
|
||||
import TagBadge from './TagBadge.svelte';
|
||||
import Dropdown from './Dropdown.svelte';
|
||||
import { DEFAULT_TAG_COLORS } from '$lib/pocketbase';
|
||||
import { MousePointer, Link, Hash } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
tag: Tag & { linkCount?: number; totalClicks?: number };
|
||||
}
|
||||
|
||||
let { tag }: Props = $props();
|
||||
let editingTag = $state(false);
|
||||
|
||||
function startEdit() {
|
||||
editingTag = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingTag = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative z-0 rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
{#if editingTag}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
cancelEdit();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={tag.name}
|
||||
required
|
||||
class="w-full rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
class="rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text"
|
||||
>
|
||||
{#each DEFAULT_TAG_COLORS as color}
|
||||
<option value={color} selected={color === tag.color}>{color}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_public"
|
||||
checked={tag.is_public}
|
||||
class="h-3 w-3 rounded border-theme-border"
|
||||
/>
|
||||
Public
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-green-600 px-3 py-1 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelEdit}
|
||||
class="rounded bg-theme-surface-hover px-3 py-1 text-sm font-medium text-theme-text hover:bg-theme-border"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="mb-4">
|
||||
<TagBadge {tag} size="lg" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-theme-text-muted">
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<Link class="h-3.5 w-3.5" />
|
||||
<span>{tag.linkCount || 0} links</span>
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Used in {tag.linkCount || 0} links
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-theme-border">•</span>
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<MousePointer class="h-3.5 w-3.5" />
|
||||
<span>{tag.totalClicks || 0} clicks</span>
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Total clicks: {tag.totalClicks || 0}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-theme-border">•</span>
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<Hash class="h-3.5 w-3.5" />
|
||||
<span>{tag.usage_count || 0} uses</span>
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Usage count: {tag.usage_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
{#if tag.is_public}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span class="font-medium text-green-600 dark:text-green-400">Public</span>
|
||||
{:else}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span class="font-medium">Private</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
|
||||
color: '#9333ea',
|
||||
action: startEdit,
|
||||
},
|
||||
{
|
||||
label: 'View Links',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>',
|
||||
color: '#2563eb',
|
||||
href: `/my/links?tag=${tag.name}`,
|
||||
},
|
||||
{
|
||||
label: tag.is_public ? 'Make Private' : 'Make Public',
|
||||
icon: tag.is_public
|
||||
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
|
||||
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
|
||||
color: '#ea580c',
|
||||
type: 'form',
|
||||
formAction: '?/togglePublic',
|
||||
formData: { id: tag.id, is_public: String(!tag.is_public) },
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>',
|
||||
color: '#dc2626',
|
||||
type: 'form',
|
||||
formAction: '?/delete',
|
||||
formData: { id: tag.id },
|
||||
enhanceOptions: () => {
|
||||
return async ({ update }) => {
|
||||
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
]}
|
||||
buttonText="Actions"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
100
apps-archived/uload/apps/web/src/lib/components/TagList.svelte
Normal file
100
apps-archived/uload/apps/web/src/lib/components/TagList.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import type { Tag } from '$lib/pocketbase';
|
||||
import TagCard from './TagCard.svelte';
|
||||
import TagListItem from './TagListItem.svelte';
|
||||
import TagStats from './TagStats.svelte';
|
||||
import type { ViewMode } from '$lib/stores/viewModes';
|
||||
|
||||
interface Props {
|
||||
tags: (Tag & { linkCount?: number; totalClicks?: number })[];
|
||||
viewMode: ViewMode;
|
||||
isSelectMode?: boolean;
|
||||
selectedTags?: Set<string>;
|
||||
onToggleSelect?: (tagId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tags,
|
||||
viewMode,
|
||||
isSelectMode = false,
|
||||
selectedTags = new Set<string>(),
|
||||
onToggleSelect = () => {},
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if tags && tags.length > 0}
|
||||
{#if viewMode === 'stats'}
|
||||
<TagStats {tags} />
|
||||
{:else if viewMode === 'cards'}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each tags as tag}
|
||||
<div
|
||||
class="relative {isSelectMode && selectedTags.has(tag.id)
|
||||
? 'rounded-xl ring-2 ring-theme-primary'
|
||||
: ''}"
|
||||
>
|
||||
{#if isSelectMode}
|
||||
<div class="absolute left-3 top-3 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTags.has(tag.id)}
|
||||
onchange={() => onToggleSelect(tag.id)}
|
||||
class="h-5 w-5 cursor-pointer rounded border-theme-border bg-white text-theme-primary focus:ring-theme-primary"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<TagCard {tag} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-4 py-4 sm:px-6">
|
||||
<h2 class="text-lg font-semibold text-theme-text sm:text-xl">
|
||||
Your Tags ({tags.length} total)
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Desktop Table Header -->
|
||||
<div
|
||||
class="hidden lg:grid {isSelectMode
|
||||
? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]'
|
||||
: 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
|
||||
>
|
||||
{#if isSelectMode}<div></div>{/if}
|
||||
<div>Tag Name</div>
|
||||
<div>Links</div>
|
||||
<div>Clicks</div>
|
||||
<div>Uses</div>
|
||||
<div>Status</div>
|
||||
<div class="text-right">Actions</div>
|
||||
</div>
|
||||
<!-- Tablet Table Header -->
|
||||
<div
|
||||
class="hidden md:grid lg:hidden {isSelectMode
|
||||
? 'grid-cols-[40px_1fr_100px_120px_140px]'
|
||||
: 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text"
|
||||
>
|
||||
{#if isSelectMode}<div></div>{/if}
|
||||
<div>Tag Name</div>
|
||||
<div>Links</div>
|
||||
<div>Clicks</div>
|
||||
<div class="text-right">Actions</div>
|
||||
</div>
|
||||
<!-- Table Body -->
|
||||
<div>
|
||||
{#each tags as tag}
|
||||
<TagListItem
|
||||
{tag}
|
||||
{isSelectMode}
|
||||
isSelected={selectedTags.has(tag.id)}
|
||||
onToggleSelect={() => onToggleSelect(tag.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
|
||||
<p class="text-theme-text-muted">No tags yet. Create your first tag to organize your links!</p>
|
||||
</div>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue