Feat: New project chat, uload refactor (postgress), hosting plans, uload landingpage

This commit is contained in:
Till-JS 2025-11-25 13:01:41 +01:00
parent 559eb08d8c
commit fcf3a344b1
123 changed files with 7106 additions and 3715 deletions

View file

@ -1,47 +1,71 @@
# Build Stage
# =============================================================================
# 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 package files from apps/web
COPY apps/web/package*.json ./
# Copy workspace configuration
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
# Install dependencies
RUN npm ci --legacy-peer-deps
# Copy the uload web app
COPY uload/apps/web/ ./uload/apps/web/
# Copy web app source
COPY apps/web/ .
# Copy required shared packages
COPY packages/shared-auth-ui/ ./packages/shared-auth-ui/
COPY packages/shared-branding/ ./packages/shared-branding/
# Generate .svelte-kit directory first by running vite in prepare mode
RUN npx vite build --mode prepare || true
# Sync SvelteKit files
RUN npx svelte-kit sync
# Compile paraglide messages before build
RUN npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/paraglide
# Install dependencies with flat structure for Docker compatibility
RUN pnpm install --filter @uload/web... --shamefully-hoist
# Build the app
RUN npm run build
WORKDIR /app/uload/apps/web
# Production Stage
FROM node:20-alpine
# 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 and dependencies
COPY --from=builder /app/build build/
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules node_modules/
COPY --from=builder /app/drizzle drizzle/
# 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 node -e "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Switch to non-root user
USER sveltekit
EXPOSE 3000

View 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

View 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"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,71 @@
{
"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": {
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
"@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"
}
}

View file

@ -0,0 +1,80 @@
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
}
}

View file

@ -0,0 +1,28 @@
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'),
});

View file

@ -0,0 +1,98 @@
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
},
};
}
}

View file

@ -0,0 +1,35 @@
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',
};
}
}

View file

@ -0,0 +1,131 @@
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',
};
}
}

View file

@ -0,0 +1,113 @@
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,
});
}
}

View file

@ -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 };

View file

@ -0,0 +1,162 @@
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;
}
}

View file

@ -0,0 +1,2 @@
export { LinkRepository, type ListLinksOptions } from './link.repository';
export { ClickRepository, type ClickStats } from './click.repository';

View file

@ -0,0 +1,148 @@
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;
}
}

View 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();

View file

@ -0,0 +1,102 @@
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);
}
}

View file

@ -0,0 +1,144 @@
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}`;
}
}

View file

@ -0,0 +1,77 @@
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);
}
}

View 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
}
}

View 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
}
}
});

View file

@ -0,0 +1,24 @@
{
"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",
"astro": "^5.1.1",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,76 @@
---
const features = [
{
icon: '🔗',
title: 'URL-Verkürzung',
description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick. Perfekt für Social Media und Marketing.'
},
{
icon: '📊',
title: 'Detaillierte Analytics',
description: 'Verfolgen Sie Klicks, geografische Herkunft, Geräte und Engagement Ihrer Links in Echtzeit.'
},
{
icon: '🎨',
title: 'QR-Code Generator',
description: 'Erstellen Sie anpassbare QR-Codes in verschiedenen Farben und Formaten für jeden Link.'
},
{
icon: '💳',
title: 'Digitale Visitenkarten',
description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes und Kontaktinformationen.'
},
{
icon: '🔒',
title: 'Passwortschutz',
description: 'Schützen Sie Ihre Links mit Passwörtern und setzen Sie Ablaufdaten für zeitlich begrenzte Aktionen.'
},
{
icon: '🏷️',
title: 'Tag-System',
description: 'Organisieren Sie Ihre Links mit Tags und Kategorien für eine bessere Übersicht und Filterung.'
},
{
icon: '👥',
title: 'Team Workspaces',
description: 'Arbeiten Sie im Team zusammen mit gemeinsamen Workspaces und granularen Berechtigungen.'
},
{
icon: '⚡',
title: 'Blitzschnell',
description: 'Unsere Links sind weltweit über ein CDN verteilt für minimale Ladezeiten und maximale Verfügbarkeit.'
},
{
icon: '🔌',
title: 'API Zugang',
description: 'Integrieren Sie uLoad in Ihre Anwendungen mit unserer RESTful API für automatisierte Workflows.'
}
];
---
<section id="features" 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">
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
Alles was du für professionelles Link-Management brauchst
</h2>
<p class="mx-auto max-w-2xl text-lg text-gray-600">
Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration uLoad bietet alle Features die du brauchst.
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{features.map(feature => (
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl hover:border-primary-200">
<div class="mb-4 text-4xl">{feature.icon}</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
{feature.title}
</h3>
<p class="text-gray-600">
{feature.description}
</p>
</div>
))}
</div>
</div>
</section>

View file

@ -0,0 +1,95 @@
---
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>

View file

@ -0,0 +1,138 @@
---
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" />
</svg>
DSGVO-konform
</span>
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Blitzschnell
</span>
<span class="flex items-center gap-1">
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
100% Sicher
</span>
</div>
<!-- Main headline -->
<h1 class="mb-4 text-4xl font-bold tracking-tight text-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" />
</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" />
</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" />
</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>

View file

@ -0,0 +1,87 @@
---
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>

View file

@ -0,0 +1,185 @@
---
const appUrl = 'https://app.ulo.ad';
const plans = [
{
id: 'free',
name: 'Free',
price: 0,
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
'10 Links pro Monat',
'Basis Analytics',
'QR-Code Generator',
'Link Anpassung',
'Standard Support'
],
cta: 'Kostenlos starten',
highlighted: false,
href: `${appUrl}/register`
},
{
id: 'pro-monthly',
name: 'Pro',
price: 4.99,
period: '/Monat',
description: 'Für Freelancer & Creators',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'API Zugang'
],
cta: 'Pro wählen',
highlighted: false,
href: `${appUrl}/register?plan=pro`
},
{
id: 'pro-yearly',
name: 'Pro Jährlich',
price: 3.33,
period: '/Monat',
description: 'Beste Wahl für Power User',
features: [
'Unbegrenzte Links',
'Erweiterte Analytics',
'Custom QR Codes',
'Link Anpassung',
'Priority Support',
'API Zugang'
],
cta: 'Jährlich sparen',
highlighted: true,
badge: 'Spare 20€/Jahr',
href: `${appUrl}/register?plan=pro-yearly`
},
{
id: 'lifetime',
name: 'Pro Lifetime',
price: 129.99,
period: 'einmalig',
description: 'Einmalig zahlen, für immer nutzen',
features: [
'Alle Pro Features',
'Lebenslanger Zugang',
'Alle zukünftigen Features',
'Early Access',
'Priority Support'
],
cta: 'Lifetime sichern',
highlighted: false,
badge: 'Einmalig',
href: `${appUrl}/register?plan=lifetime`
}
];
function formatPrice(price: number): string {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: price % 1 === 0 ? 0 : 2
}).format(price);
}
---
<section id="pricing" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24 bg-gray-50">
<div class="mx-auto max-w-7xl">
<div class="text-center">
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
Transparente Preise, keine versteckten Kosten
</h2>
<p class="mx-auto mb-12 max-w-2xl text-lg text-gray-600">
Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
</p>
</div>
<!-- Pricing Cards -->
<div class="grid gap-8 lg:grid-cols-4">
{plans.map(plan => (
<div
class:list={[
"relative rounded-xl border-2 bg-white transition-all duration-300",
plan.highlighted
? "border-primary-500 shadow-2xl scale-105"
: "border-gray-200 hover:border-primary-300 hover:shadow-xl"
]}
>
{plan.badge && (
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
<span class="rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white">
{plan.badge}
</span>
</div>
)}
<div class="p-6">
<h3 class="mb-2 text-xl font-bold text-gray-900">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-500">{plan.description}</p>
<div class="mb-6">
<div class="flex items-baseline">
<span class="text-4xl font-bold text-gray-900">
{formatPrice(plan.price)}
</span>
<span class="ml-2 text-gray-500">{plan.period}</span>
</div>
</div>
<a
href={plan.href}
class:list={[
"mb-6 block w-full rounded-lg py-3 font-semibold text-center transition",
plan.highlighted
? "bg-primary-600 text-white hover:bg-primary-700"
: "border-2 border-gray-200 text-gray-900 hover:border-primary-500 hover:bg-primary-50"
]}
>
{plan.cta}
</a>
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Inklusive:
</p>
{plan.features.map(feature => (
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
<!-- Benefits -->
<div class="mt-16 rounded-xl border border-gray-200 bg-white p-8">
<div class="grid gap-8 lg:grid-cols-3">
<div>
<h4 class="mb-2 font-semibold text-gray-900">💳 Keine Kreditkarte erforderlich</h4>
<p class="text-sm text-gray-600">
Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-gray-900">🔄 Jederzeit kündbar</h4>
<p class="text-sm text-gray-600">
Keine Vertragsbindung. Kündige monatlich ohne Probleme.
</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-gray-900">🚀 Sofort startklar</h4>
<p class="text-sm text-gray-600">
Nach der Anmeldung kannst du sofort alle Features nutzen.
</p>
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,85 @@
---
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.

View file

@ -0,0 +1,73 @@
---
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

View 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
uload/apps/landing/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -0,0 +1,52 @@
---
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>

View file

@ -0,0 +1,32 @@
---
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>

View file

@ -0,0 +1,124 @@
---
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>

View file

@ -0,0 +1,59 @@
---
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>

View file

@ -0,0 +1,83 @@
---
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" />
</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>

View file

@ -0,0 +1,68 @@
---
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>

View file

@ -0,0 +1,76 @@
---
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>

View file

@ -0,0 +1,158 @@
---
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>

View file

@ -0,0 +1,55 @@
---
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>

View file

@ -0,0 +1,12 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HeroSection from '../components/HeroSection.astro';
import FeaturesSection from '../components/FeaturesSection.astro';
import PricingSection from '../components/PricingSection.astro';
---
<BaseLayout title="Intelligenter URL-Shortener">
<HeroSection />
<FeaturesSection />
<PricingSection />
</BaseLayout>

View file

@ -0,0 +1,196 @@
---
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>

View file

@ -0,0 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
--color-background: #ffffff;
--color-background-secondary: #f9fafb;
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
}
.dark {
--color-background: #111827;
--color-background-secondary: #1f2937;
--color-text: #f9fafb;
--color-text-secondary: #9ca3af;
--color-border: #374151;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-gray-900 antialiased;
}
}
@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;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors 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;
}
}

View file

@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
}
}
},
plugins: []
};

View file

@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -58,6 +58,8 @@
"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",

View file

@ -1 +0,0 @@
cache

View file

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

View file

@ -1,28 +0,0 @@
{
"nav_login": "Login",
"nav_register": "Register",
"nav_dashboard": "Dashboard",
"nav_folders": "Folders",
"nav_profile": "Profile",
"nav_logout": "Logout",
"home_title": "Share Links Smarter",
"home_subtitle": "Create shortened links with QR codes, custom names, and analytics",
"home_url_label_qr": "URL to encode",
"home_url_label": "URL to shorten",
"home_title_label": "Title",
"home_title_placeholder": "Give your link a name",
"home_description_label": "Description",
"home_description_placeholder": "Add a description (optional)",
"home_expires_label": "Expiration",
"home_expires_placeholder": "e.g., 7 days, 1 month",
"home_max_clicks_label": "Max clicks",
"home_max_clicks_placeholder": "Limit number of clicks",
"home_password_label": "Password",
"home_password_placeholder": "Protect with password",
"home_guest_info": "You're using uload as a guest",
"auth_modal_signin": "Sign in",
"home_guest_signin_hint": "to access advanced features",
"home_processing": "Processing...",
"home_submit_button_qr": "Generate QR Code",
"home_submit_button": "Create Link"
}

View file

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

View file

@ -1,28 +0,0 @@
{
"nav_login": "Connexion",
"nav_register": "S'inscrire",
"nav_dashboard": "Tableau de bord",
"nav_folders": "Dossiers",
"nav_profile": "Profil",
"nav_logout": "Déconnexion",
"home_title": "Partagez des Liens Intelligemment",
"home_subtitle": "Créez des liens raccourcis avec codes QR, noms personnalisés et analyses",
"home_url_label_qr": "URL à encoder",
"home_url_label": "URL à raccourcir",
"home_title_label": "Titre",
"home_title_placeholder": "Donnez un nom à votre lien",
"home_description_label": "Description",
"home_description_placeholder": "Ajouter une description (optionnel)",
"home_expires_label": "Expiration",
"home_expires_placeholder": "ex., 7 jours, 1 mois",
"home_max_clicks_label": "Clics maximum",
"home_max_clicks_placeholder": "Limiter le nombre de clics",
"home_password_label": "Mot de passe",
"home_password_placeholder": "Protéger avec mot de passe",
"home_guest_info": "Vous utilisez uload en tant qu'invité",
"auth_modal_signin": "Se connecter",
"home_guest_signin_hint": "pour accéder aux fonctionnalités avancées",
"home_processing": "Traitement...",
"home_submit_button_qr": "Générer Code QR",
"home_submit_button": "Créer Lien"
}

View file

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

View file

@ -1 +0,0 @@
vBR0K1t5zNgjHxICus

View file

@ -1,12 +0,0 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "en",
"languageTags": ["en", "de", "es", "fr", "it"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
],
"plugin.inlang.json": {
"pathPattern": "./messages/{languageTag}.json"
}
}

View file

@ -1,8 +1,9 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { DB } from '$lib/db';
import type { AvailableLanguageTag } from '$paraglide/runtime';
import type { ParaglideLocals } from '@inlang/paraglide-sveltekit';
// Supported locales
export type SupportedLocale = 'en' | 'de' | 'es' | 'fr' | 'it';
// User type (will be replaced by external auth later)
export interface User {
@ -20,7 +21,7 @@ declare global {
interface Locals {
db: DB;
user: User | null;
paraglide: ParaglideLocals<AvailableLanguageTag>;
locale: SupportedLocale;
}
interface PageData {
user: User | null;

View file

@ -7,7 +7,8 @@
getFreeText,
type VariantContent
} from '../config/variants';
import { getLocale } from '$paraglide/runtime.js';
import { locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PageData, ActionData } from '../../../routes/$types';
interface Props {
@ -42,14 +43,14 @@
// Log for debugging
if (showDebug) {
console.log('A/B Test Variant:', variant, content);
console.log('Current Locale:', getLocale());
console.log('Current Locale:', get(locale));
}
});
// React to locale changes - use derived state
$effect(() => {
// This will re-run when locale changes
const currentLocale = getLocale();
const currentLocale = get(locale);
// Update content based on current locale
content = getVariantContent(variant);
@ -82,7 +83,7 @@
<div class="font-bold text-green-400">A/B Test Debug</div>
<div>Variant: <span class="text-yellow-400">{variant}</span></div>
<div>Name: {content.name}</div>
<div>Locale: <span class="text-blue-400">{getLocale()}</span></div>
<div>Locale: <span class="text-blue-400">{get(locale)}</span></div>
<div class="mt-2">
<button
onclick={() => {

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { setLocale, getLocale } from '$paraglide/runtime.js';
import { locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import '$lib/i18n';
let showDropdown = $state(false);
@ -14,7 +16,7 @@
// Get current language on mount
$effect(() => {
if (browser) {
const currentCode = getLocale();
const currentCode = get(locale) || 'en';
currentLanguage = languages.find((lang) => lang.code === currentCode) || languages[0];
}
});
@ -23,8 +25,8 @@
if (browser) {
// Save preference
localStorage.setItem('preferred-language', langCode);
// Update Paraglide locale
setLocale(langCode as any);
// Update svelte-i18n locale
locale.set(langCode);
// Update current language display
currentLanguage = languages.find((lang) => lang.code === langCode) || languages[0];
// Close dropdown

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
let { data, children }: { data: any; children: Snippet } = $props();
$effect(() => {
// Redirect to dashboard if already logged in
if (data.user) {
goto('/my');
}
});
</script>
{@render children()}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
async function handleForgotPassword(email: string) {
try {
await pb.collection('users').requestPasswordReset(email);
return { success: true };
} catch (err: any) {
// PocketBase doesn't reveal if email exists for security
// So we always show success message
return { success: true };
}
}
</script>
<ForgotPasswordPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onForgotPassword={handleForgotPassword}
goto={goto}
loginPath="/login"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
titleForm: 'Passwort zurücksetzen',
titleSuccess: 'E-Mail gesendet',
description: 'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.',
emailPlaceholder: 'E-Mail',
sendResetLinkButton: 'Link senden',
sending: 'Wird gesendet...',
backToLogin: 'Zurück zum Login',
resendEmail: 'E-Mail erneut senden',
successMessage: 'Wir haben einen Link zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang.',
emailRequired: 'E-Mail ist erforderlich',
sendFailed: 'Senden der E-Mail fehlgeschlagen'
}}
/>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
async function handleSignIn(email: string, password: string) {
try {
await pb.collection('users').authWithPassword(email, password);
// Invalidate all data to refresh server-side auth state
await invalidateAll();
return { success: true };
} catch (err: any) {
return {
success: false,
error: err?.message || 'Ungültige E-Mail oder Passwort'
};
}
}
</script>
<LoginPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
goto={goto}
enableGoogle={false}
enableApple={false}
successRedirect="/my"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
title: 'Anmelden',
subtitle: 'Melde dich mit deinem uLoad Account an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolg!',
orDivider: 'oder',
noAccount: 'Noch kein Account?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...'
}}
/>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { UloadLogo } from '@manacore/shared-branding';
import { pb } from '$lib/pocketbase';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
async function handleSignUp(email: string, password: string) {
try {
// Create user
await pb.collection('users').create({
email: email.toLowerCase().trim(),
password,
passwordConfirm: password,
emailVisibility: true
});
// Request verification email
try {
await pb.collection('users').requestVerification(email);
} catch (emailErr) {
console.error('Failed to send verification email:', emailErr);
}
return {
success: true,
needsVerification: true
};
} catch (err: any) {
const errorData = err?.response?.data || err?.data || {};
if (errorData.email?.message?.includes('unique')) {
return {
success: false,
error: 'Diese E-Mail ist bereits registriert. Bitte melde dich an.'
};
}
if (errorData.email?.message) {
return { success: false, error: errorData.email.message };
}
if (errorData.password?.message) {
return { success: false, error: errorData.password.message };
}
return {
success: false,
error: err?.message || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.'
};
}
}
</script>
<RegisterPage
appName="uLoad"
logo={UloadLogo}
primaryColor="#3b82f6"
onSignUp={handleSignUp}
goto={goto}
successRedirect="/login?registered=true"
loginPath="/login"
lightBackground="#f8fafc"
darkBackground="#0f172a"
translations={{
title: 'Account erstellen',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
confirmPasswordPlaceholder: 'Passwort bestätigen',
passwordRequirements: 'Passwort muss mindestens 8 Zeichen mit Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten.',
createAccountButton: 'Account erstellen',
creatingAccount: 'Wird erstellt...',
backToLogin: 'Zurück zum Login',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
passwordRequired: 'Passwort ist erforderlich',
confirmPasswordRequired: 'Bitte bestätige dein Passwort',
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
passwordTooShort: 'Passwort muss mindestens 8 Zeichen haben',
passwordStrengthError: 'Passwort muss Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten',
registrationFailed: 'Registrierung fehlgeschlagen',
accountCreated: 'Account erstellt! Bitte überprüfe deine E-Mail zur Verifizierung.'
}}
/>

View file

@ -1,288 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const features = [
{
icon: '🔗',
title: 'URL-Verkürzung',
description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick.'
},
{
icon: '📊',
title: 'Detaillierte Analytics',
description: 'Verfolgen Sie Klicks, Herkunft und Engagement Ihrer Links in Echtzeit.'
},
{
icon: '💳',
title: 'Digitale Visitenkarten',
description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes.'
},
{
icon: '🎨',
title: 'Anpassbare Templates',
description: 'Nutzen Sie vorgefertigte Templates oder erstellen Sie eigene Designs.'
},
{
icon: '🔒',
title: 'Passwortschutz',
description: 'Schützen Sie Ihre Links mit Passwörtern und Ablaufdaten.'
},
{
icon: '🏷️',
title: 'Tag-System',
description: 'Organisieren Sie Ihre Links mit Tags für bessere Übersicht.'
}
];
const stats = [
{ value: '10K+', label: 'Aktive Nutzer' },
{ value: '500K+', label: 'Erstellte Links' },
{ value: '2M+', label: 'Klicks verfolgt' },
{ value: '99.9%', label: 'Uptime' }
];
const team = [
{
name: 'Till Schneider',
role: 'Gründer & Entwickler',
description: 'Full-Stack Entwickler mit Leidenschaft für saubere, effiziente Lösungen.',
avatar: '👨‍💻'
}
];
</script>
<svelte:head>
<title>{data.title || 'Über Uload'}</title>
<meta name="description" content={data.description || 'Erfahren Sie mehr über Uload'} />
</svelte:head>
<div class="min-h-screen bg-theme-background">
<Navigation user={data.user} />
<!-- Hero Section -->
<section class="relative overflow-hidden border-b border-theme-border bg-theme-surface">
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div class="relative mx-auto max-w-7xl px-4 py-24 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl font-bold tracking-tight text-theme-text sm:text-5xl md:text-6xl">
Über <span class="text-theme-primary">Uload</span>
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-theme-text-muted">
Ihre moderne Plattform für professionelles Link-Management und digitale Präsenz
</p>
</div>
</div>
</section>
<!-- Mission Section -->
<section class="bg-theme-background py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold text-theme-text">Unsere Mission</h2>
<p class="mt-6 text-lg leading-relaxed text-theme-text-muted">
Bei Uload glauben wir daran, dass Link-Management einfach, effizient und zugänglich sein sollte.
Unsere Plattform wurde entwickelt, um Unternehmen und Einzelpersonen dabei zu helfen,
ihre Online-Präsenz zu optimieren und wertvolle Einblicke in ihr Publikum zu gewinnen.
</p>
<p class="mt-4 text-lg leading-relaxed text-theme-text-muted">
Von der einfachen URL-Verkürzung bis hin zu erweiterten Analytics und digitalen Visitenkarten -
wir bieten alle Tools, die Sie für erfolgreiches digitales Marketing benötigen.
</p>
</div>
</div>
</section>
<!-- Features Grid -->
<section class="bg-theme-surface py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-theme-text">Was macht uns besonders?</h2>
<p class="mt-4 text-lg text-theme-text-muted">
Entdecken Sie die Features, die Uload zur ersten Wahl für Link-Management machen
</p>
</div>
<div class="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{#each features as feature}
<div class="group rounded-lg border border-theme-border bg-theme-surface-hover p-6 transition-all hover:shadow-md hover:scale-105">
<div class="mb-4 text-4xl">{feature.icon}</div>
<h3 class="mb-2 text-xl font-semibold text-theme-text">
{feature.title}
</h3>
<p class="text-theme-text-muted">
{feature.description}
</p>
</div>
{/each}
</div>
</div>
</section>
<!-- Stats Section -->
<section class="bg-theme-primary py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-white">Uload in Zahlen</h2>
<p class="mt-4 text-lg text-gray-200">
Vertrauen Sie auf eine bewährte Plattform
</p>
</div>
<div class="mt-12 grid grid-cols-2 gap-8 md:grid-cols-4">
{#each stats as stat}
<div class="text-center">
<div class="text-4xl font-bold text-white">{stat.value}</div>
<div class="mt-2 text-sm text-gray-200">{stat.label}</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Team Section -->
<section class="bg-theme-background py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-theme-text">Das Team</h2>
<p class="mt-4 text-lg text-theme-text-muted">
Die Menschen hinter Uload
</p>
</div>
<div class="mt-12 flex justify-center">
{#each team as member}
<div class="max-w-sm rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-lg">
<div class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-theme-surface-hover text-5xl">
{member.avatar}
</div>
<h3 class="text-xl font-semibold text-theme-text">{member.name}</h3>
<p class="mt-1 text-sm text-theme-accent">{member.role}</p>
<p class="mt-4 text-theme-text-muted">{member.description}</p>
</div>
{/each}
</div>
</div>
</section>
<!-- Values Section -->
<section class="bg-theme-surface py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h2 class="text-3xl font-bold text-theme-text">Unsere Werte</h2>
</div>
<div class="mt-12 grid gap-8 md:grid-cols-3">
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
🚀
</div>
<h3 class="mb-2 text-xl font-semibold text-theme-text">Innovation</h3>
<p class="text-theme-text-muted">
Wir entwickeln ständig neue Features und verbessern bestehende Funktionen.
</p>
</div>
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
🛡️
</div>
<h3 class="mb-2 text-xl font-semibold text-theme-text">Sicherheit</h3>
<p class="text-theme-text-muted">
Ihre Daten sind bei uns sicher. Datenschutz und Sicherheit haben höchste Priorität.
</p>
</div>
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
💡
</div>
<h3 class="mb-2 text-xl font-semibold text-theme-text">Einfachheit</h3>
<p class="text-theme-text-muted">
Komplexe Funktionen, einfach zu bedienen. Das ist unser Versprechen.
</p>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="bg-theme-background py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="rounded-2xl bg-theme-primary px-8 py-12 text-center shadow-xl">
<h2 class="text-3xl font-bold text-white">Bereit loszulegen?</h2>
<p class="mt-4 text-lg text-gray-200">
Erstellen Sie noch heute Ihr kostenloses Konto und entdecken Sie die Möglichkeiten
</p>
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="/register"
class="rounded-lg bg-white px-8 py-3 font-semibold text-theme-primary transition hover:bg-gray-50 hover:scale-105"
>
Kostenlos starten
</a>
<a
href="/features"
class="rounded-lg border-2 border-white px-8 py-3 font-semibold text-white transition hover:bg-white/10 hover:scale-105"
>
Features entdecken
</a>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section class="bg-theme-surface py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold text-theme-text">Kontakt</h2>
<p class="mt-4 text-lg text-theme-text-muted">
Haben Sie Fragen oder Feedback? Wir freuen uns von Ihnen zu hören!
</p>
<div class="mt-8 flex flex-col items-center gap-4">
<a
href="mailto:support@ulo.ad"
class="flex items-center gap-2 text-theme-accent hover:text-theme-accent-hover transition-colors"
>
<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 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
support@ulo.ad
</a>
<a
href="/features"
class="flex items-center gap-2 text-theme-accent hover:text-theme-accent-hover transition-colors"
>
<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>
Feature-Wünsche einreichen
</a>
</div>
</div>
</div>
</section>
<Footer />
</div>
<style>
.bg-grid-pattern {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
</style>

View file

@ -1,8 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
return {
title: 'Über Uload - Moderne URL-Verkürzung & Link-Management',
description: 'Erfahren Sie mehr über Uload - Ihre Plattform für professionelles Link-Management, URL-Verkürzung und digitale Visitenkarten.'
};
};

View file

@ -1,251 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<h1 class="mb-8 text-3xl font-bold text-theme-text">Allgemeine Geschäftsbedingungen (AGB)</h1>
<div class="space-y-6 text-theme-text">
<section>
<h2 class="mb-3 text-xl font-semibold">§ 1 Geltungsbereich</h2>
<p class="text-theme-text-muted">
(1) Diese Allgemeinen Geschäftsbedingungen (nachfolgend "AGB") gelten für alle über
unsere Website uload geschlossenen Verträge zwischen uns und unseren Nutzern
(nachfolgend "Nutzer" oder "Sie").
</p>
<p class="mt-2 text-theme-text-muted">
(2) Maßgeblich ist die jeweils zum Zeitpunkt des Vertragsschlusses gültige Fassung der
AGB.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Abweichende Bedingungen des Nutzers werden nicht anerkannt, es sei denn, wir stimmen
ihrer Geltung ausdrücklich schriftlich zu.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 2 Vertragsgegenstand</h2>
<p class="text-theme-text-muted">
(1) Gegenstand des Vertrages ist die Bereitstellung eines URL-Verkürzungsdienstes
(uload) sowie damit verbundene Zusatzfunktionen.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Der Nutzer kann über unseren Dienst lange URLs in kurze, leicht zu teilende Links
umwandeln.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Je nach gewähltem Tarif stehen dem Nutzer verschiedene Funktionen zur Verfügung, wie
z.B. Statistiken, benutzerdefinierte Links, QR-Codes und erweiterte
Verwaltungsfunktionen.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 3 Registrierung und Nutzerkonto</h2>
<p class="text-theme-text-muted">
(1) Für die vollständige Nutzung unseres Dienstes ist eine Registrierung erforderlich.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Bei der Registrierung müssen alle Pflichtfelder wahrheitsgemäß und vollständig
ausgefüllt werden.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Der Nutzer ist verpflichtet, seine Zugangsdaten geheim zu halten und vor dem Zugriff
Dritter zu schützen.
</p>
<p class="mt-2 text-theme-text-muted">
(4) Der Nutzer haftet für alle unter seinem Nutzerkonto vorgenommenen Handlungen.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 4 Leistungen und Verfügbarkeit</h2>
<p class="text-theme-text-muted">
(1) Wir stellen unseren Dienst mit einer Verfügbarkeit von 99% im Jahresmittel zur
Verfügung.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Hiervon ausgenommen sind Zeiten, in denen der Server aufgrund von technischen oder
sonstigen Problemen, die nicht in unserem Einflussbereich liegen (höhere Gewalt,
Verschulden Dritter etc.), nicht erreichbar ist.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Wir behalten uns vor, den Zugang zum Dienst bei Wartungsarbeiten vorübergehend zu
beschränken.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 5 Preise und Zahlung</h2>
<p class="text-theme-text-muted">(1) Die Nutzung des Basisdienstes ist kostenlos.</p>
<p class="mt-2 text-theme-text-muted">
(2) Für erweiterte Funktionen (Premium-Tarife) fallen die auf unserer Website
angegebenen Gebühren an.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Die Abrechnung erfolgt wahlweise monatlich oder jährlich im Voraus.
</p>
<p class="mt-2 text-theme-text-muted">
(4) Die Zahlung erfolgt über die auf der Website angebotenen Zahlungsmethoden (z.B.
Kreditkarte, PayPal, Stripe).
</p>
<p class="mt-2 text-theme-text-muted">
(5) Bei Zahlungsverzug behalten wir uns vor, den Zugang zu Premium-Funktionen zu
sperren.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 6 Pflichten des Nutzers</h2>
<p class="mb-2 text-theme-text-muted">Der Nutzer verpflichtet sich:</p>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<li>
keine rechtswidrigen, beleidigenden, verleumderischen oder anderweitig unzulässigen
Inhalte zu verlinken
</li>
<li>keine Links zu erstellen, die gegen geltendes Recht verstoßen</li>
<li>keine automatisierten Anfragen ohne unsere ausdrückliche Genehmigung zu senden</li>
<li>den Dienst nicht für Spam oder Phishing zu verwenden</li>
<li>keine schädliche Software oder Malware zu verbreiten</li>
<li>
die Rechte Dritter, insbesondere Marken-, Urheber- und Persönlichkeitsrechte zu
beachten
</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 7 Haftung</h2>
<p class="text-theme-text-muted">
(1) Wir haften unbeschränkt für Vorsatz und grobe Fahrlässigkeit.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Bei leichter Fahrlässigkeit haften wir nur bei Verletzung einer wesentlichen
Vertragspflicht (Kardinalpflicht) und nur in Höhe des vorhersehbaren, typischerweise
eintretenden Schadens.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Die Haftung für Datenverlust wird auf den typischen Wiederherstellungsaufwand
beschränkt, der bei regelmäßiger und gefahrentsprechender Anfertigung von
Sicherungskopien eingetreten wäre.
</p>
<p class="mt-2 text-theme-text-muted">
(4) Die vorstehenden Haftungsbeschränkungen gelten nicht bei Verletzung von Leben,
Körper und Gesundheit sowie bei Ansprüchen nach dem Produkthaftungsgesetz.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 8 Laufzeit und Kündigung</h2>
<p class="text-theme-text-muted">
(1) Der Vertrag über die kostenlose Nutzung wird auf unbestimmte Zeit geschlossen und
kann von beiden Parteien jederzeit ohne Einhaltung einer Frist gekündigt werden.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Premium-Tarife haben eine Mindestlaufzeit entsprechend des gewählten
Abrechnungszeitraums (monatlich oder jährlich).
</p>
<p class="mt-2 text-theme-text-muted">
(3) Premium-Tarife verlängern sich automatisch um den gewählten Abrechnungszeitraum,
wenn sie nicht mit einer Frist von 14 Tagen zum Ende der Laufzeit gekündigt werden.
</p>
<p class="mt-2 text-theme-text-muted">
(4) Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.
</p>
<p class="mt-2 text-theme-text-muted">
(5) Kündigungen bedürfen der Textform (z.B. E-Mail).
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 9 Datenschutz</h2>
<p class="text-theme-text-muted">
(1) Wir erheben, verarbeiten und nutzen personenbezogene Daten nur im Rahmen der
gesetzlichen Bestimmungen.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Einzelheiten zur Datenverarbeitung sind unserer Datenschutzerklärung zu entnehmen.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 10 Änderungen der AGB</h2>
<p class="text-theme-text-muted">
(1) Wir behalten uns vor, diese AGB jederzeit zu ändern.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Änderungen werden dem Nutzer per E-Mail mitgeteilt.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Die Änderungen gelten als genehmigt, wenn der Nutzer nicht innerhalb von vier Wochen
nach Mitteilung der Änderungen widerspricht.
</p>
<p class="mt-2 text-theme-text-muted">
(4) Bei Widerspruch steht beiden Parteien ein Sonderkündigungsrecht zu.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 11 Streitbeilegung</h2>
<p class="text-theme-text-muted">
(1) Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
bereit, die Sie unter
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noopener noreferrer"
class="text-theme-primary hover:underline">https://ec.europa.eu/consumers/odr/</a
> finden.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">§ 12 Schlussbestimmungen</h2>
<p class="text-theme-text-muted">
(1) Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts.
</p>
<p class="mt-2 text-theme-text-muted">
(2) Ist der Nutzer Kaufmann, juristische Person des öffentlichen Rechts oder
öffentlich-rechtliches Sondervermögen, ist ausschließlicher Gerichtsstand für alle
Streitigkeiten aus diesem Vertrag unser Geschäftssitz.
</p>
<p class="mt-2 text-theme-text-muted">
(3) Sollten einzelne Bestimmungen dieser AGB unwirksam sein oder werden, bleibt die
Wirksamkeit der übrigen Bestimmungen unberührt.
</p>
</section>
<section class="pt-4">
<p class="text-sm text-theme-text-muted">
Stand: {new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long' })}
</p>
</section>
</div>
<div
class="mt-8 rounded-lg bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
>
<p class="font-semibold">⚠️ Wichtiger Hinweis:</p>
<p>
Diese AGB sind ein Muster und sollten von einem Rechtsanwalt auf Ihre spezifischen
Bedürfnisse angepasst werden.
</p>
</div>
</div>
</div>
</div>
<Footer />

View file

@ -1,8 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { user } = await parent();
return {
user
};
};

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
};

View file

@ -1,16 +0,0 @@
import { getCollection, getFeaturedPosts, getAllCategories } from '$lib/content';
import type { BlogPostWithMeta, BlogCategory } from '../../content/config';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const posts = await getCollection<BlogPostWithMeta>('blog');
const featuredPosts = await getFeaturedPosts();
const categories = await getAllCategories();
return {
posts,
featuredPosts,
categories,
user: locals.user
};
};

View file

@ -1,307 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import type { BlogPostWithMeta } from '../../content/config';
import BlogCard from '$lib/components/blog/BlogCard.svelte';
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import ViewToggle from '$lib/components/ViewToggle.svelte';
import { Search, ArrowUpDown } from 'lucide-svelte';
// Svelte 5: Props mit $props()
let { data }: { data: PageData } = $props();
// Svelte 5: $state für alle reaktiven Variablen
let selectedCategory = $state<string>('all');
let selectedTag = $state<string | null>(null);
let searchQuery = $state('');
let sortBy = $state<'date' | 'readingTime' | 'category'>('date');
let sortOrder = $state<'asc' | 'desc'>('desc');
let viewMode = $state<'cards' | 'list' | 'stats'>('cards');
// Svelte 5: $derived für gefilterte/sortierte Posts
let filteredAndSortedPosts = $derived.by(() => {
let posts = [...data.posts];
// Kategorie-Filter
if (selectedCategory !== 'all') {
posts = posts.filter(p => p.category === selectedCategory);
}
// Tag-Filter
if (selectedTag) {
posts = posts.filter(p => p.tags.includes(selectedTag));
}
// Suche
if (searchQuery) {
const query = searchQuery.toLowerCase();
posts = posts.filter(p =>
p.title.toLowerCase().includes(query) ||
p.excerpt.toLowerCase().includes(query) ||
p.tags.some(t => t.toLowerCase().includes(query))
);
}
// Sortierung
posts.sort((a, b) => {
let compareValue = 0;
switch (sortBy) {
case 'date':
compareValue = new Date(b.date).getTime() - new Date(a.date).getTime();
break;
case 'readingTime':
compareValue = b.readingTime - a.readingTime;
break;
case 'category':
compareValue = a.category.localeCompare(b.category);
break;
}
return sortOrder === 'asc' ? -compareValue : compareValue;
});
return posts;
});
// Svelte 5: $derived für Tag-Cloud mit Counts
let tagCloud = $derived(() => {
const tags = new Map<string, number>();
data.posts.forEach(post => {
post.tags.forEach(tag => {
tags.set(tag, (tags.get(tag) || 0) + 1);
});
});
return Array.from(tags.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 20);
});
// Svelte 5: $derived für Statistiken
let stats = $derived({
totalPosts: filteredAndSortedPosts.length,
totalCategories: data.categories.length,
totalTags: tagCloud.length
});
// Event Handler
function handleCategorySelect(category: string) {
selectedCategory = category;
selectedTag = null;
}
function handleTagSelect(tag: string) {
selectedTag = selectedTag === tag ? null : tag;
selectedCategory = 'all';
}
function clearFilters() {
selectedCategory = 'all';
selectedTag = null;
searchQuery = '';
sortBy = 'date';
sortOrder = 'desc';
}
function toggleSortOrder() {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
}
</script>
<svelte:head>
<title>Blog | uload - Insights über URLs, Marketing und Psychologie</title>
<meta name="description" content="Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für Link-Management." />
</svelte:head>
<Navigation user={data.user} currentPath="/blog" />
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Header mit Titel und View Toggle -->
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-theme-text">Blog</h1>
<div class="flex items-center gap-4">
<ViewToggle
currentView={viewMode}
onViewChange={(view) => viewMode = view}
showStats={false}
/>
</div>
</div>
<!-- Search and Sort Controls -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="relative flex-1 max-w-md">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-theme-text-muted" />
<input
type="text"
bind:value={searchQuery}
placeholder="Artikel durchsuchen..."
class="w-full rounded-lg border border-theme-border bg-theme-surface pl-10 pr-4 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div class="flex items-center gap-2">
<label for="sort-select" class="text-sm font-medium text-theme-text">Sortieren nach:</label>
<select
id="sort-select"
bind:value={sortBy}
class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:ring-2 focus:ring-theme-accent focus:outline-none"
>
<option value="date">Datum</option>
<option value="readingTime">Lesezeit</option>
<option value="category">Kategorie</option>
</select>
<button
onclick={toggleSortOrder}
class="rounded-lg border border-theme-border bg-theme-surface p-2 text-theme-text transition-all hover:bg-theme-surface-hover"
aria-label="Toggle sort order"
>
<ArrowUpDown class="h-4 w-4" />
</button>
</div>
</div>
<!-- Filter Section -->
<div class="mb-6 rounded-xl border border-theme-border bg-theme-surface p-6">
<!-- Kategorien -->
<div class="mb-4">
<h3 class="mb-3 text-sm font-semibold text-theme-text">Kategorien</h3>
<div class="flex gap-2 flex-wrap">
<button
onclick={() => handleCategorySelect('all')}
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-all {
selectedCategory === 'all'
? 'border-theme-primary bg-theme-primary text-white'
: 'border-theme-border bg-theme-surface text-theme-text hover:bg-theme-surface-hover'
}"
>
Alle ({data.posts.length})
</button>
{#each data.categories as category}
<button
onclick={() => handleCategorySelect(category.slug)}
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-all {
selectedCategory === category.slug
? 'border-theme-primary bg-theme-primary text-white'
: 'border-theme-border bg-theme-surface text-theme-text hover:bg-theme-surface-hover'
}"
>
{category.name} ({category.count})
</button>
{/each}
</div>
</div>
<!-- Tag-Cloud -->
{#if tagCloud.length > 0}
<div>
<h3 class="mb-3 text-sm font-semibold text-theme-text">Beliebte Tags</h3>
<div class="flex gap-2 flex-wrap">
{#each tagCloud as [tag, count]}
<button
onclick={() => handleTagSelect(tag)}
class="rounded-full border px-3 py-1 text-sm transition-all {
selectedTag === tag
? 'border-theme-primary bg-theme-primary/10 text-theme-primary'
: 'border-theme-border bg-theme-surface text-theme-text-muted hover:bg-theme-surface-hover'
}"
>
#{tag} ({count})
</button>
{/each}
</div>
</div>
{/if}
<!-- Active Filters -->
{#if selectedCategory !== 'all' || selectedTag || searchQuery}
<div class="mt-4 flex items-center gap-2 border-t border-theme-border pt-4">
<span class="text-sm font-medium text-theme-text-muted">Aktive Filter:</span>
{#if selectedCategory !== 'all'}
<span class="inline-flex items-center gap-1 rounded-full bg-theme-primary/10 px-2 py-1 text-xs font-medium text-theme-primary">
{data.categories.find(c => c.slug === selectedCategory)?.name || selectedCategory}
<button
onclick={() => selectedCategory = 'all'}
class="ml-1 hover:text-theme-primary-hover"
>
×
</button>
</span>
{/if}
{#if selectedTag}
<span class="inline-flex items-center gap-1 rounded-full bg-theme-primary/10 px-2 py-1 text-xs font-medium text-theme-primary">
#{selectedTag}
<button
onclick={() => selectedTag = null}
class="ml-1 hover:text-theme-primary-hover"
>
×
</button>
</span>
{/if}
{#if searchQuery}
<span class="inline-flex items-center gap-1 rounded-full bg-theme-primary/10 px-2 py-1 text-xs font-medium text-theme-primary">
"{searchQuery}"
<button
onclick={() => searchQuery = ''}
class="ml-1 hover:text-theme-primary-hover"
>
×
</button>
</span>
{/if}
<button
onclick={clearFilters}
class="ml-auto text-sm text-theme-text-muted hover:text-theme-text"
>
Alle löschen
</button>
</div>
{/if}
</div>
<!-- Featured Posts -->
{#if data.featuredPosts.length > 0 && selectedCategory === 'all' && !selectedTag && !searchQuery}
<section class="mb-8">
<h2 class="mb-4 text-xl font-semibold text-theme-text">Featured Artikel</h2>
<div class="grid gap-6 md:grid-cols-2">
{#each data.featuredPosts as post}
<BlogCard {post} featured={true} {viewMode} />
{/each}
</div>
</section>
{/if}
<!-- Posts Grid/List -->
{#if filteredAndSortedPosts.length > 0}
<div class={viewMode === 'cards'
? 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'
: 'space-y-4'
}>
{#each filteredAndSortedPosts as post (post.slug)}
<BlogCard {post} {viewMode} />
{/each}
</div>
{:else}
<div class="rounded-xl border border-theme-border bg-theme-surface p-12 text-center">
<svg class="mx-auto h-12 w-12 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.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 21a9 9 0 110-18 9 9 0 010 18z" />
</svg>
<p class="mt-4 text-theme-text-muted">
Keine Artikel gefunden.
</p>
{#if selectedCategory !== 'all' || selectedTag || searchQuery}
<button
onclick={clearFilters}
class="mt-4 text-theme-primary hover:text-theme-primary-hover"
>
Filter zurücksetzen
</button>
{/if}
</div>
{/if}
</div>
</div>
<Footer />

View file

@ -1,7 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:component this={data.content} {...data.metadata} />

View file

@ -1,10 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const post = await import(`../../../content/blog/${params.slug}.md`);
return {
content: post.default,
metadata: post.metadata
};
};

View file

@ -1,38 +0,0 @@
import { getCollection } from '$lib/content';
import type { BlogPostWithMeta } from '../../../content/config';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const posts = await getCollection<BlogPostWithMeta>('blog');
const site = 'https://ulo.ad';
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>uload Blog</title>
<link>${site}/blog</link>
<description>Insights über URLs, Marketing und Psychologie</description>
<language>de-DE</language>
<atom:link href="${site}/blog/rss.xml" rel="self" type="application/rss+xml" />
${posts.slice(0, 20).map(post => `
<item>
<title><![CDATA[${post.title}]]></title>
<link>${site}/blog/${post.slug}</link>
<guid isPermaLink="true">${site}/blog/${post.slug}</guid>
<description><![CDATA[${post.excerpt}]]></description>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<category>${post.category}</category>
${post.tags.map(tag => `<category>${tag}</category>`).join('\n\t\t\t')}
</item>`).join('')}
</channel>
</rss>`;
return new Response(xml.trim(), {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=0, s-maxage=3600'
}
});
};
export const prerender = false;

View file

@ -1,271 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<h1 class="mb-8 text-3xl font-bold text-theme-text">Datenschutzerklärung</h1>
<div class="space-y-6 text-theme-text">
<section>
<h2 class="mb-3 text-xl font-semibold">1. Datenschutz auf einen Blick</h2>
<h3 class="mt-4 mb-2 font-semibold">Allgemeine Hinweise</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Datenerfassung auf dieser Website</h3>
<p class="mb-2 text-theme-text-muted">
<strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong>
</p>
<p class="mb-4 text-theme-text-muted">
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen
Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
</p>
<p class="mb-2 text-theme-text-muted">
<strong>Wie erfassen wir Ihre Daten?</strong>
</p>
<p class="mb-4 text-theme-text-muted">
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. Andere Daten
werden automatisch oder nach Ihrer Einwilligung beim Besuch der Website durch unsere
IT-Systeme erfasst.
</p>
<p class="mb-2 text-theme-text-muted">
<strong>Wofür nutzen wir Ihre Daten?</strong>
</p>
<p class="mb-4 text-theme-text-muted">
Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu
gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.
</p>
<p class="mb-2 text-theme-text-muted">
<strong>Welche Rechte haben Sie bezüglich Ihrer Daten?</strong>
</p>
<p class="text-theme-text-muted">
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>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">2. Hosting</h2>
<p class="text-theme-text-muted">
Wir hosten die Inhalte unserer Website bei folgendem Anbieter: [Hosting-Anbieter
einfügen]
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">
3. Allgemeine Hinweise und Pflichtinformationen
</h2>
<h3 class="mt-4 mb-2 font-semibold">Datenschutz</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Hinweis zur verantwortlichen Stelle</h3>
<p class="mb-2 text-theme-text-muted">
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist:
</p>
<div class="mb-4 space-y-1 text-theme-text-muted">
<p>[Ihr Name oder Firmenname]</p>
<p>[Ihre Adresse]</p>
<p>Telefon: [Ihre Telefonnummer]</p>
<p>E-Mail: [Ihre E-Mail-Adresse]</p>
</div>
<h3 class="mt-4 mb-2 font-semibold">Speicherdauer</h3>
<p class="text-theme-text-muted">
Soweit innerhalb dieser Datenschutzerklärung keine speziellere Speicherdauer genannt
wurde, verbleiben Ihre personenbezogenen Daten bei uns, bis der Zweck für die
Datenverarbeitung entfällt.
</p>
<h3 class="mt-4 mb-2 font-semibold">
Gesetzlich vorgeschriebener Datenschutzbeauftragter
</h3>
<p class="text-theme-text-muted">
Wir haben für unser Unternehmen einen Datenschutzbeauftragten bestellt.
</p>
<div class="mt-2 space-y-1 text-theme-text-muted">
<p>[Name des Datenschutzbeauftragten]</p>
<p>[Adresse]</p>
<p>Telefon: [Telefonnummer]</p>
<p>E-Mail: [E-Mail-Adresse]</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">4. Datenerfassung auf dieser Website</h2>
<h3 class="mt-4 mb-2 font-semibold">Cookies</h3>
<p class="mb-4 text-theme-text-muted">
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>
<p class="text-theme-text-muted">
Sie können Ihren Browser so einstellen, dass Sie über das Setzen von Cookies informiert
werden und Cookies nur im Einzelfall erlauben, die Annahme von Cookies für bestimmte
Fälle oder generell ausschließen sowie das automatische Löschen der Cookies beim
Schließen des Browsers aktivieren.
</p>
<h3 class="mt-4 mb-2 font-semibold">Server-Log-Dateien</h3>
<p class="mb-2 text-theme-text-muted">
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 class="mb-4 list-inside list-disc space-y-1 text-theme-text-muted">
<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</li>
</ul>
<p class="text-theme-text-muted">
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen. Die
Erfassung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO.
</p>
<h3 class="mt-4 mb-2 font-semibold">Registrierung auf dieser Website</h3>
<p class="mb-4 text-theme-text-muted">
Sie können sich auf dieser Website registrieren, um zusätzliche Funktionen auf der Seite
zu nutzen. Die dazu eingegebenen Daten verwenden wir nur zum Zwecke der Nutzung des
jeweiligen Angebotes oder Dienstes, für den Sie sich registriert haben.
</p>
<p class="text-theme-text-muted">
Die bei der Registrierung abgefragten Pflichtangaben müssen vollständig angegeben
werden. Anderenfalls werden wir die Registrierung ablehnen.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">5. Soziale Medien</h2>
<p class="text-theme-text-muted">
Auf unseren Seiten sind Plugins der sozialen Netzwerke eingebunden. Wenn Sie eine
unserer Seiten besuchen, die ein solches Plugin enthält, baut Ihr Browser eine direkte
Verbindung mit den Servern der Netzwerke auf.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">6. Analyse-Tools und Werbung</h2>
<h3 class="mt-4 mb-2 font-semibold">Google Analytics</h3>
<p class="text-theme-text-muted">
Diese Website nutzt Funktionen des Webanalysedienstes Google Analytics. Anbieter ist die
Google Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">7. Newsletter</h2>
<p class="text-theme-text-muted">
Wenn Sie den auf der Website angebotenen Newsletter beziehen möchten, benötigen wir von
Ihnen eine E-Mail-Adresse sowie Informationen, welche uns die Überprüfung gestatten,
dass Sie der Inhaber der angegebenen E-Mail-Adresse sind und mit dem Empfang des
Newsletters einverstanden sind.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">8. Plugins und Tools</h2>
<h3 class="mt-4 mb-2 font-semibold">YouTube mit erweitertem Datenschutz</h3>
<p class="text-theme-text-muted">
Diese Website bindet Videos der Website YouTube ein. Betreiber der Seiten ist die Google
Ireland Limited („Google"), Gordon House, Barrow Street, Dublin 4, Irland.
</p>
<h3 class="mt-4 mb-2 font-semibold">Google Fonts</h3>
<p class="text-theme-text-muted">
Diese Seite nutzt zur einheitlichen Darstellung von Schriftarten so genannte Web Fonts,
die von Google bereitgestellt werden.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">9. Online-Zahlungsanbieter</h2>
<h3 class="mt-4 mb-2 font-semibold">Stripe</h3>
<p class="text-theme-text-muted">
Anbieter dieses Zahlungsdienstes ist die Stripe Payments Europe, Ltd., 1 Grand Canal
Street Lower, Grand Canal Dock, Dublin, Irland (im Folgenden „Stripe").
</p>
<p class="mt-2 text-theme-text-muted">
Bei der Zahlung via Stripe werden die von Ihnen im Rahmen des Kaufvorgangs eingegebenen
Zahlungsdaten (z.B. Name, E-Mail-Adresse, Kreditkartennummer, Bankleitzahl, evtl.
Rechnungsadresse) von Stripe zur Zahlungsabwicklung gespeichert.
</p>
<p class="mt-2 text-theme-text-muted">
Die Übermittlung Ihrer Daten an Stripe erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b
DSGVO (Vertragsabwicklung) sowie im Interesse einer möglichst effizienten
Zahlungsabwicklung (Art. 6 Abs. 1 lit. f DSGVO).
</p>
<p class="mt-2 text-theme-text-muted">
Weitere Informationen entnehmen Sie der Datenschutzerklärung von Stripe unter folgendem
Link:
<a
href="https://stripe.com/de/privacy"
target="_blank"
rel="noopener noreferrer"
class="text-theme-primary hover:underline">https://stripe.com/de/privacy</a
>
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">10. Ihre Rechte</h2>
<p class="mb-4 text-theme-text-muted">
Sie haben im Rahmen der geltenden gesetzlichen Bestimmungen jederzeit das Recht auf:
</p>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<li>
Auskunft über Ihre bei uns gespeicherten Daten und deren Verarbeitung (Art. 15 DSGVO)
</li>
<li>Berichtigung unrichtiger personenbezogener Daten (Art. 16 DSGVO)</li>
<li>Löschung Ihrer bei uns gespeicherten Daten (Art. 17 DSGVO)</li>
<li>Einschränkung der Datenverarbeitung (Art. 18 DSGVO)</li>
<li>Widerspruch gegen die Verarbeitung Ihrer Daten (Art. 21 DSGVO)</li>
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
</ul>
</section>
</div>
<div
class="mt-8 rounded-lg bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
>
<p class="font-semibold">⚠️ Wichtiger Hinweis:</p>
<p>
Bitte ersetzen Sie alle Platzhalter in eckigen Klammern [ ] mit Ihren tatsächlichen Daten
und passen Sie die Datenschutzerklärung an Ihre spezifischen Dienste und
Datenverarbeitungen an.
</p>
</div>
</div>
</div>
</div>
<Footer />

View file

@ -1,8 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { user } = await parent();
return {
user
};
};

View file

@ -1,123 +0,0 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
// Load published feature requests
let featureRequestsList = [];
let userVotes = [];
try {
// Get all published feature requests, sorted by vote count
console.log('Loading feature requests...');
const response = await locals.pb.collection('featurerequests').getList(1, 50, {
filter: 'published = true',
sort: '-vote_count'
});
featureRequestsList = response.items || [];
console.log('Found feature requests:', response.totalItems, 'total');
console.log('Feature requests loaded:', featureRequestsList.length);
if (featureRequestsList.length > 0) {
console.log('First feature request:', featureRequestsList[0]);
}
// If user is logged in, get their votes
if (locals.user) {
userVotes = await locals.pb.collection('featurevotes').getFullList({
filter: `user_id = "${locals.user.id}"`,
fields: 'feature_request_id'
});
console.log('User votes:', userVotes.length);
}
} catch (error) {
console.error('Error loading feature requests:', error);
}
return {
user: locals.user,
featureRequests: featureRequestsList,
userVotedIds: userVotes.map((v) => v.feature_request_id)
};
};
export const actions: Actions = {
requestFeature: async ({ request, locals }) => {
const formData = await request.formData();
const message = formData.get('message')?.toString();
const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || '';
if (!message) {
return fail(400, { error: 'Bitte gib eine Nachricht ein.' });
}
try {
// Save feature request to PocketBase
const featureRequest = await locals.pb.collection('featurerequests').create({
message,
name: name || null,
email: email || null,
status: 'new'
});
console.log('Feature request saved:', featureRequest.id);
if (name || email) {
console.log(`From: ${name || 'Anonymous'} ${email ? `(${email})` : ''}`);
}
console.log(`Message: ${message}`);
return { success: true };
} catch (error) {
console.error('Error saving feature request:', error);
return fail(500, {
error: 'Es gab ein Problem beim Speichern deiner Anfrage. Bitte versuche es später erneut.'
});
}
},
vote: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Du musst angemeldet sein, um abzustimmen.' });
}
const formData = await request.formData();
const featureRequestId = formData.get('featureRequestId')?.toString();
const action = formData.get('action')?.toString(); // 'add' or 'remove'
if (!featureRequestId || !action) {
return fail(400, { error: 'Ungültige Anfrage.' });
}
try {
if (action === 'add') {
// Try to create vote - PocketBase Hook will handle vote_count update
// The hook also prevents duplicate votes
await locals.pb.collection('featurevotes').create({
user_id: locals.user.id,
feature_request_id: featureRequestId
});
console.log('Vote created successfully');
} else if (action === 'remove') {
// Find and delete vote - PocketBase Hook will handle vote_count update
const existingVotes = await locals.pb.collection('featurevotes').getList(1, 1, {
filter: `user_id = "${locals.user.id}" && feature_request_id = "${featureRequestId}"`
});
if (existingVotes.items.length > 0) {
await locals.pb.collection('featurevotes').delete(existingVotes.items[0].id);
console.log('Vote removed successfully');
}
}
return { voteSuccess: true };
} catch (error) {
console.error('Error processing vote:', error);
// Check if it's a duplicate vote error
if (error.message?.includes('already voted')) {
return fail(400, { error: 'Du hast bereits für dieses Feature abgestimmt.' });
}
return fail(500, { error: 'Es gab ein Problem beim Verarbeiten deiner Stimme.' });
}
}
};

View file

@ -1,723 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
let { data, form }: { data: PageData; form: ActionData } = $props();
let isSubmitting = $state(false);
let showSuccess = $state(false);
let votingInProgress = $state<string | null>(null);
let statusFilter = $state('all');
let sortBy = $state('votes');
// Reactive filtering and sorting
let filteredRequests = $derived(
(data.featureRequests || [])
.filter((req) => statusFilter === 'all' || req.status === statusFilter)
.sort((a, b) => {
if (sortBy === 'votes') return (b.vote_count || 0) - (a.vote_count || 0);
if (sortBy === 'newest' && a.created && b.created) {
return new Date(b.created).getTime() - new Date(a.created).getTime();
}
return 0;
})
);
// Debug output
$effect(() => {
console.log('Feature requests from data:', data.featureRequests);
console.log('Filtered requests:', filteredRequests);
});
function getStatusBadge(status: string) {
const badges = {
new: { text: 'Neu', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
reviewed: {
text: 'In Prüfung',
class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
},
planned: {
text: 'Geplant',
class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'
},
in_progress: {
text: 'In Entwicklung',
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 animate-pulse'
},
completed: {
text: 'Fertig',
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
},
rejected: {
text: 'Abgelehnt',
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}
};
return badges[status] || badges.new;
}
function getCategoryIcon(category: string) {
const icons = {
ui: '🎨',
performance: '⚡',
integration: '🔌',
security: '🔐',
features: '✨',
other: '💡'
};
return icons[category] || '💡';
}
const features = [
{
category: 'Link Management',
icon: '🔗',
items: [
{
title: 'Smart URL Shortening',
description: 'Verwandle lange URLs in kurze, einprägsame Links mit einem Klick',
icon: '✂️'
},
{
title: 'Custom Short Codes',
description: 'Erstelle eigene, personalisierte Kurz-URLs für deine Marke',
icon: '✏️'
},
{
title: 'Ordner Organisation',
description: 'Organisiere Links in farbcodierten Ordnern mit Icons',
icon: '📁'
},
{
title: 'Tagging System',
description: 'Verwende Tags mit Farben und Icons für bessere Organisation',
icon: '🏷️'
},
{
title: 'QR Code Generator',
description: 'Erstelle anpassbare QR Codes in verschiedenen Formaten und Farben',
icon: '📱'
},
{
title: 'Bulk Operations',
description: 'Verwalte mehrere Links gleichzeitig mit Massenaktionen',
icon: '⚡'
}
]
},
{
category: 'Sicherheit & Kontrolle',
icon: '🔐',
items: [
{
title: 'Passwortschutz',
description: 'Sichere sensible Links mit individuellen Passwörtern',
icon: '🔒'
},
{
title: 'Ablaufdatum',
description: 'Setze automatische Ablaufzeiten für temporäre Links',
icon: '⏰'
},
{
title: 'Click Limits',
description: 'Limitiere die Anzahl der Aufrufe pro Link',
icon: '🎯'
},
{
title: 'Link Deaktivierung',
description: 'Aktiviere und deaktiviere Links jederzeit nach Bedarf',
icon: '🔄'
},
{
title: 'SSL Verschlüsselung',
description: 'Alle Daten sind mit modernster SSL-Technologie geschützt',
icon: '🛡️'
},
{
title: 'GDPR Konform',
description: 'Vollständige DSGVO-Konformität für europäische Nutzer',
icon: '🇪🇺'
}
]
},
{
category: 'Analytics & Insights',
icon: '📊',
items: [
{
title: 'Echtzeit-Statistiken',
description: 'Verfolge Klicks und Aktivitäten in Echtzeit',
icon: '📈'
},
{
title: 'Geräte-Analyse',
description: 'Erkenne Mobile, Desktop und Tablet Zugriffe',
icon: '💻'
},
{
title: 'Browser-Statistiken',
description: 'Detaillierte Aufschlüsselung nach Browsern',
icon: '🌐'
},
{
title: 'Geografische Daten',
description: 'Sehe woher deine Besucher kommen',
icon: '🗺️'
},
{
title: 'Referrer Tracking',
description: 'Verstehe von welchen Seiten Traffic kommt',
icon: '🔍'
},
{
title: 'Export Funktionen',
description: 'Exportiere Analytics-Daten für weitere Analysen',
icon: '📥'
}
]
},
{
category: 'Personalisierung',
icon: '🎨',
items: [
{
title: 'Öffentliche Profile',
description: 'Erstelle deine persönliche Link-Seite mit Bio und Social Links',
icon: '👤'
},
{
title: 'Custom Themes',
description: 'Wähle aus 5 verschiedenen Themes oder nutze Dark Mode',
icon: '🎭'
},
{
title: 'Mehrsprachigkeit',
description:
'5 Sprachen verfügbar: Deutsch, Englisch, Französisch, Spanisch, Italienisch',
icon: '🌍'
},
{
title: 'Branded URLs',
description: 'Links mit deinem Benutzernamen für bessere Wiedererkennung',
icon: '🏆'
},
{
title: 'Custom QR Codes',
description: 'Passe QR Code Farben und Formate an deine Marke an',
icon: '🎨'
},
{
title: 'Profil QR Codes',
description: 'Teile dein Profil einfach per QR Code',
icon: '📲'
}
]
},
{
category: 'Pro Features',
icon: '⭐',
items: [
{
title: 'Unbegrenzte Links',
description: 'Keine Limits bei der Anzahl deiner Kurz-URLs',
icon: '♾️'
},
{
title: 'Erweiterte Analytics',
description: 'Tiefgehende Einblicke und detaillierte Berichte',
icon: '📊'
},
{
title: 'Priority Support',
description: 'Bevorzugter Support mit schnellen Antwortzeiten',
icon: '🎖️'
},
{
title: 'Keine Werbung',
description: 'Werbefreie Nutzung für fokussiertes Arbeiten',
icon: '🚫'
},
{
title: 'Early Access',
description: 'Sei der Erste bei neuen Features (Lifetime Plan)',
icon: '🚀'
}
]
}
];
const comparisons = [
{ feature: 'Links pro Monat', free: '10', pro: 'Unbegrenzt' },
{ feature: 'Analytics', free: 'Basis', pro: 'Erweitert' },
{ feature: 'QR Codes', free: 'Standard', pro: 'Anpassbar' },
{ feature: 'Custom Short Codes', free: '✓', pro: '✓' },
{ feature: 'Passwortschutz', free: '✓', pro: '✓' },
{ feature: 'Ablaufdatum', free: '✓', pro: '✓' },
{ feature: 'Priority Support', free: '✗', pro: '✓' },
{ feature: 'Werbefrei', free: '✗', pro: '✓' },
{ feature: 'Export Funktionen', free: '✗', pro: '✓' }
];
</script>
<svelte:head>
<title>Features - ulo.ad | Alle Funktionen im Überblick</title>
<meta
name="description"
content="Entdecke alle Features von ulo.ad: URL-Verkürzung, QR-Codes, Analytics, Passwortschutz und mehr. Der moderne Link-Shortener für Profis."
/>
</svelte:head>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<!-- Hero Section -->
<div
class="relative overflow-hidden bg-gradient-to-br from-theme-primary/10 to-theme-primary/5 px-4 py-16"
>
<div class="bg-grid-pattern absolute inset-0 opacity-5"></div>
<div class="relative mx-auto max-w-7xl text-center">
<h1 class="mb-6 text-5xl font-bold text-theme-text">
Alles was du brauchst,<br />
<span class="text-theme-primary">in einem Tool vereint</span>
</h1>
<p class="mx-auto mb-8 max-w-3xl text-xl text-theme-text-muted">
Von einfacher URL-Verkürzung bis zu fortgeschrittenen Analytics und API-Integration. ulo.ad
bietet dir alle Tools für professionelles Link-Management.
</p>
<div class="flex justify-center gap-4">
<a
href="/auth/register"
class="rounded-lg bg-theme-primary px-6 py-3 font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
>
Jetzt starten
</a>
<a
href="/pricing"
class="rounded-lg bg-theme-surface px-6 py-3 font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Preise ansehen
</a>
</div>
</div>
</div>
<!-- Community Roadmap Section -->
<div class="bg-theme-surface/50 px-4 py-16">
<div class="mx-auto max-w-6xl">
<div class="mb-12 text-center">
<h2 class="mb-4 text-3xl font-bold text-theme-text">🚀 Community Roadmap</h2>
<p class="text-xl text-theme-text-muted">Stimme für die Features, die dir wichtig sind!</p>
</div>
{#if filteredRequests.length > 0}
<!-- Filters -->
<div class="mb-8 flex flex-wrap justify-center gap-4">
<div class="flex gap-2">
<button
onclick={() => (statusFilter = 'all')}
class="rounded-lg px-4 py-2 transition-colors {statusFilter === 'all'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
Alle
</button>
<button
onclick={() => (statusFilter = 'new')}
class="rounded-lg px-4 py-2 transition-colors {statusFilter === 'new'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
Neu
</button>
<button
onclick={() => (statusFilter = 'planned')}
class="rounded-lg px-4 py-2 transition-colors {statusFilter === 'planned'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
Geplant
</button>
<button
onclick={() => (statusFilter = 'in_progress')}
class="rounded-lg px-4 py-2 transition-colors {statusFilter === 'in_progress'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
In Entwicklung
</button>
</div>
<div class="flex gap-2">
<button
onclick={() => (sortBy = 'votes')}
class="rounded-lg px-4 py-2 transition-colors {sortBy === 'votes'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
🔥 Beliebteste
</button>
<button
onclick={() => (sortBy = 'newest')}
class="rounded-lg px-4 py-2 transition-colors {sortBy === 'newest'
? 'bg-theme-primary text-theme-background'
: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
>
🆕 Neueste
</button>
</div>
</div>
<!-- Feature Requests List -->
<div class="space-y-4">
{#each filteredRequests as request}
{@const isVoted = data.userVotedIds.includes(request.id)}
{@const statusBadge = getStatusBadge(request.status)}
{@const categoryIcon = getCategoryIcon(request.category)}
<div
class="rounded-xl bg-theme-surface p-6 shadow-lg transition-shadow hover:shadow-xl"
>
<div class="flex gap-4">
<!-- Vote Button -->
<div class="flex flex-col items-center">
<form
method="POST"
action="?/vote"
use:enhance={() => {
votingInProgress = request.id;
return async ({ result, update }) => {
votingInProgress = null;
if (result.type === 'success') {
// Optimistic update
if (isVoted) {
data.userVotedIds = data.userVotedIds.filter((id) => id !== request.id);
request.vote_count = Math.max(0, (request.vote_count || 0) - 1);
} else {
data.userVotedIds = [...data.userVotedIds, request.id];
request.vote_count = (request.vote_count || 0) + 1;
}
}
update();
};
}}
>
<input type="hidden" name="featureRequestId" value={request.id} />
<input type="hidden" name="action" value={isVoted ? 'remove' : 'add'} />
<button
type="submit"
disabled={!data.user || votingInProgress === request.id}
class="flex flex-col items-center gap-1 {isVoted
? 'text-theme-primary'
: 'text-theme-text-muted hover:text-theme-primary'}
{!data.user ? 'cursor-not-allowed opacity-50' : 'transition-colors'}
{votingInProgress === request.id ? 'animate-pulse' : ''}"
title={!data.user
? 'Zum Abstimmen anmelden'
: isVoted
? 'Stimme zurücknehmen'
: 'Abstimmen'}
>
<svg
class="h-6 w-6 {isVoted ? 'fill-current' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
<span class="text-lg font-semibold">{request.vote_count || 0}</span>
</button>
</form>
</div>
<!-- Content -->
<div class="flex-1">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="mb-2 text-lg font-semibold text-theme-text">
{#if request.category}
<span class="mr-2">{categoryIcon}</span>
{/if}
{request.message}
</h3>
{#if request.name}
<p class="mb-2 text-sm text-theme-text-muted">
Vorgeschlagen von {request.name}
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="rounded-full px-3 py-1 text-xs font-medium {statusBadge.class}">
{statusBadge.text}
</span>
{#if request.priority === 'critical'}
<span
class="rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"
>
🔴 Kritisch
</span>
{:else if request.priority === 'high'}
<span
class="rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
>
🟠 Hoch
</span>
{/if}
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="py-12 text-center">
<p class="text-theme-text-muted">
Noch keine Community-Vorschläge vorhanden. Sei der Erste!
</p>
</div>
{/if}
</div>
</div>
<!-- Features Grid -->
<div class="px-4 py-16">
<div class="mx-auto max-w-7xl">
{#each features as category}
<div class="mb-16">
<div class="mb-8 flex items-center gap-3">
<span class="text-3xl">{category.icon}</span>
<h2 class="text-3xl font-bold text-theme-text">{category.category}</h2>
</div>
<div
class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
{#each category.items as item}
{@const combinedText = `${item.title}. ${item.description}`}
{@const firstSentenceEnd = combinedText.indexOf('.') + 1}
{@const firstPart = combinedText.slice(0, firstSentenceEnd)}
{@const secondPart = combinedText.slice(firstSentenceEnd).trim()}
<div
class="flex aspect-square transform flex-col items-start justify-start rounded-lg bg-theme-surface p-4 text-left transition-shadow duration-200 hover:scale-[1.02] hover:shadow-lg"
>
<span class="mb-3 text-2xl">{item.icon}</span>
<p class="text-sm leading-relaxed text-theme-text">
<span class="font-bold">{firstPart}</span>
{#if secondPart}
<span class="font-normal"> {secondPart}</span>
{/if}
</p>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Comparison Table -->
<div class="bg-theme-surface/50 px-4 py-16">
<div class="mx-auto max-w-4xl">
<h2 class="mb-12 text-center text-3xl font-bold text-theme-text">Free vs Pro Vergleich</h2>
<div class="overflow-hidden rounded-xl bg-theme-surface shadow-lg">
<div class="grid grid-cols-3 bg-theme-primary text-theme-background">
<div class="p-4 font-semibold">Feature</div>
<div class="p-4 text-center font-semibold">Free</div>
<div class="p-4 text-center font-semibold">Pro</div>
</div>
{#each comparisons as item, index}
<div
class="grid grid-cols-3 {index % 2 === 0
? 'bg-theme-background'
: 'bg-theme-surface/50'}"
>
<div class="p-4 font-medium text-theme-text">{item.feature}</div>
<div class="p-4 text-center text-theme-text-muted">
{#if item.free === '✓'}
<span class="text-green-500"></span>
{:else if item.free === '✗'}
<span class="text-red-500"></span>
{:else}
{item.free}
{/if}
</div>
<div class="p-4 text-center text-theme-text">
{#if item.pro === '✓'}
<span class="font-bold text-green-500"></span>
{:else}
<span class="font-semibold text-theme-primary">{item.pro}</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Stats Section -->
<div class="px-4 py-16">
<div class="mx-auto max-w-6xl">
<h2 class="mb-12 text-center text-3xl font-bold text-theme-text">Warum ulo.ad?</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<div class="text-center">
<div class="mb-2 text-4xl font-bold text-theme-primary">99.9%</div>
<div class="text-theme-text-muted">Uptime SLA</div>
</div>
<div class="text-center">
<div class="mb-2 text-4xl font-bold text-theme-primary">~50ms</div>
<div class="text-theme-text-muted">Avg. Response</div>
</div>
<div class="text-center">
<div class="mb-2 text-4xl font-bold text-theme-primary">5</div>
<div class="text-theme-text-muted">Sprachen</div>
</div>
<div class="text-center">
<div class="mb-2 text-4xl font-bold text-theme-primary">24/7</div>
<div class="text-theme-text-muted">Support</div>
</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="bg-gradient-to-r from-theme-primary/10 to-theme-primary/5 px-4 py-16">
<div class="mx-auto max-w-4xl text-center">
<h2 class="mb-6 text-3xl font-bold text-theme-text">Bereit durchzustarten?</h2>
<p class="mb-8 text-xl text-theme-text-muted">
Starte kostenlos mit 10 Links pro Monat oder wähle einen Pro Plan für unbegrenzte
Möglichkeiten.
</p>
<div class="flex justify-center gap-4">
<a
href="/auth/register"
class="rounded-lg bg-theme-primary px-8 py-4 text-lg font-medium text-theme-background transition-colors hover:bg-theme-primary-hover"
>
Kostenlos starten
</a>
<a
href="/pricing"
class="rounded-lg bg-theme-surface px-8 py-4 text-lg font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
>
Pro Features ansehen
</a>
</div>
<p class="mt-6 text-sm text-theme-text-muted">
Keine Kreditkarte erforderlich • Jederzeit kündbar • DSGVO-konform
</p>
</div>
</div>
<!-- Feature Request Form -->
<div class="bg-theme-surface/50 px-4 py-16">
<div class="mx-auto max-w-2xl">
<div class="rounded-xl bg-theme-surface p-8 shadow-lg">
<h2 class="mb-2 text-2xl font-bold text-theme-text">Feedback & Wünsche</h2>
<p class="mb-6 text-theme-text-muted">
Hast du eine Idee oder vermisst du eine Funktion? Lass es uns wissen!
</p>
{#if showSuccess}
<div
class="mb-6 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20"
>
<p class="text-green-800 dark:text-green-300">Vielen Dank für dein Feedback! 🎉</p>
</div>
{/if}
<form
method="POST"
action="?/requestFeature"
use:enhance={() => {
isSubmitting = true;
return async ({ result }) => {
isSubmitting = false;
if (result.type === 'success') {
showSuccess = true;
// Clear the form
const form = document.getElementById('feedback-form') as HTMLFormElement;
if (form) form.reset();
setTimeout(() => (showSuccess = false), 5000);
}
};
}}
id="feedback-form"
class="space-y-4"
>
<div>
<label for="message" class="mb-2 block text-sm font-medium text-theme-text">
Deine Nachricht
</label>
<textarea
id="message"
name="message"
required
rows="5"
class="w-full resize-none rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text focus:border-transparent focus:ring-2 focus:ring-theme-primary"
placeholder="Erzähl uns von deiner Idee oder deinem Feature-Wunsch..."
></textarea>
</div>
<details class="group">
<summary
class="cursor-pointer text-sm text-theme-text-muted transition-colors hover:text-theme-text"
>
Optional: Kontaktdaten hinterlassen
</summary>
<div class="mt-4 space-y-4">
<div>
<label for="name" class="mb-2 block text-sm font-medium text-theme-text">
Name (optional)
</label>
<input
type="text"
id="name"
name="name"
class="w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text focus:border-transparent focus:ring-2 focus:ring-theme-primary"
placeholder="Max Mustermann"
/>
</div>
<div>
<label for="email" class="mb-2 block text-sm font-medium text-theme-text">
E-Mail (optional)
</label>
<input
type="email"
id="email"
name="email"
class="w-full rounded-lg border border-theme-border bg-theme-background px-4 py-2 text-theme-text focus:border-transparent focus:ring-2 focus:ring-theme-primary"
placeholder="max@beispiel.de"
/>
</div>
</div>
</details>
<button
type="submit"
disabled={isSubmitting}
class="w-full rounded-lg bg-theme-primary px-6 py-3 font-medium text-theme-background transition-colors hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? 'Wird gesendet...' : 'Feedback absenden'}
</button>
</form>
</div>
</div>
</div>
</div>
<style>
.bg-grid-pattern {
background-image:
linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
</style>

View file

@ -1,312 +0,0 @@
# Feature-Voting-System Dokumentation
## Übersicht
Das Feature-Voting-System ermöglicht es Nutzern, Feature-Wünsche einzureichen und für Community-Vorschläge abzustimmen. Admins können diese Vorschläge moderieren und deren Status verwalten.
## Architektur
### Datenbank-Schema
#### 1. `feature_requests` Collection
Speichert alle Feature-Anfragen mit folgenden Feldern:
| Feld | Typ | Beschreibung | Pflichtfeld |
| ------------ | -------- | -------------------------- | ----------- |
| `id` | text | Eindeutige ID (15 Zeichen) | ✅ |
| `message` | text | Die Feature-Beschreibung | ✅ |
| `name` | text | Name des Einreichers | ❌ |
| `email` | email | E-Mail des Einreichers | ❌ |
| `status` | select | Status der Anfrage | ❌ |
| `published` | bool | Ob öffentlich sichtbar | ❌ |
| `vote_count` | number | Anzahl der Votes | ❌ |
| `priority` | select | Priorität | ❌ |
| `category` | select | Kategorie | ❌ |
| `created` | autodate | Erstellungsdatum | Auto |
| `updated` | autodate | Letztes Update | Auto |
**Status-Optionen:**
- `new` - Neue Anfrage
- `reviewed` - In Prüfung
- `planned` - Geplant
- `in_progress` - In Entwicklung
- `completed` - Fertiggestellt
- `rejected` - Abgelehnt
**Prioritäts-Optionen:**
- `low` - Niedrig
- `medium` - Mittel
- `high` - Hoch
- `critical` - Kritisch
**Kategorie-Optionen:**
- `ui` - User Interface
- `performance` - Performance
- `integration` - Integration
- `security` - Sicherheit
- `features` - Features
- `other` - Sonstiges
**Zugriffsregeln:**
- **List/View:** Öffentlich (für published=true)
- **Create:** Öffentlich (für Feedback-Formular)
- **Update/Delete:** Nur Admins
#### 2. `feature_votes` Collection
Verwaltet die Abstimmungen der Nutzer:
| Feld | Typ | Beschreibung | Pflichtfeld |
| ----------------- | -------- | ---------------------------- | ----------- |
| `id` | text | Eindeutige ID | ✅ |
| `user` | relation | Verweis auf users Collection | ✅ |
| `feature_request` | relation | Verweis auf feature_requests | ✅ |
| `created` | autodate | Zeitpunkt der Abstimmung | Auto |
**Zugriffsregeln:**
- **List/View:** Nur für eingeloggte Nutzer
- **Create:** Nur für eingeloggte Nutzer (user muss auth.id sein)
- **Update:** Nicht erlaubt
- **Delete:** Nur eigene Votes
## Frontend-Komponenten
### 1. Feedback-Formular
**Ort:** Am Ende der Features-Seite
**Funktionen:**
- Einfaches Textfeld für Feedback
- Optionale Kontaktdaten (Name & E-Mail)
- Validierung und Erfolgs-Feedback
- Automatisches Leeren nach Absenden
**Code-Snippet:**
```svelte
<form method="POST" action="?/requestFeature">
<textarea name="message" required />
<details>
<summary>Optional: Kontaktdaten</summary>
<input type="text" name="name" />
<input type="email" name="email" />
</details>
<button type="submit">Feedback absenden</button>
</form>
```
### 2. Community Roadmap
**Ort:** Nach dem Hero-Bereich der Features-Seite
**Komponenten:**
- **Filter-Buttons:** Status-Filter (Alle/Neu/Geplant/In Entwicklung)
- **Sortierung:** Nach Votes oder Datum
- **Feature-Karten:** Zeigen Vorschläge mit Vote-Button
- **Vote-Button:** Interaktiv für eingeloggte Nutzer
**Features:**
- Optimistische Updates beim Voten
- Echtzeit-Vote-Zähler
- Status-Badges mit Farben
- Kategorie-Icons
- Prioritäts-Anzeige
### 3. Vote-Mechanismus
**Ablauf:**
1. Nutzer klickt auf Vote-Button
2. Form wird per POST an `?/vote` Action gesendet
3. Server prüft Authentifizierung
4. Vote wird erstellt/gelöscht
5. vote_count wird aktualisiert
6. UI wird optimistisch aktualisiert
## Server-Actions
### 1. `requestFeature` Action
**Zweck:** Speichert neue Feature-Anfragen
**Ablauf:**
```typescript
1. Formular-Daten validieren
2. Feature-Request in PocketBase speichern
3. Status auf 'new' setzen
4. Erfolgs-Feedback zurückgeben
```
### 2. `vote` Action
**Zweck:** Verwaltet Nutzer-Abstimmungen
**Ablauf:**
```typescript
1. Authentifizierung prüfen
2. Bei 'add':
- Prüfen ob Vote existiert
- Vote erstellen
- vote_count erhöhen
3. Bei 'remove':
- Vote finden
- Vote löschen
- vote_count verringern
```
## Admin-Workflow
### 1. Moderation neuer Anfragen
1. Admin öffnet PocketBase Admin-Panel
2. Navigiert zu `feature_requests` Collection
3. Filtert nach `published = false`
4. Prüft neue Anfragen
5. Setzt bei Freigabe:
- `published = true`
- `status` (z.B. "reviewed")
- `category` (optional)
- `priority` (optional)
### 2. Status-Verwaltung
Typischer Workflow:
```
new → reviewed → planned → in_progress → completed
rejected
```
### 3. Vote-Count Management
- `vote_count` wird automatisch aktualisiert
- Denormalisiert für Performance
- Kann bei Bedarf manuell korrigiert werden
## Sicherheit
### Zugriffskontrollen
- **Öffentlich:** Ansicht der Roadmap, Feedback einreichen
- **Eingeloggte Nutzer:** Abstimmen, eigene Votes verwalten
- **Admins:** Moderation, Status-Verwaltung, alle Felder bearbeiten
### Validierung
- Server-seitige Validierung aller Eingaben
- CSRF-Schutz durch SvelteKit
- SQL-Injection-Schutz durch PocketBase
- Rate-Limiting durch PocketBase
## Performance-Optimierungen
1. **Denormalisierte vote_count:** Vermeidet COUNT-Queries
2. **Optimistische Updates:** Sofortiges UI-Feedback
3. **Pagination:** Max. 50 Feature-Requests laden
4. **Indexierung:** Auf user und feature_request in votes
## Erweiterungsmöglichkeiten
### Zukünftige Features
1. **E-Mail-Benachrichtigungen:**
- Bei Status-Änderungen
- Bei Implementierung
2. **Kommentare:**
- Diskussion zu Feature-Requests
- Admin-Antworten
3. **Anhänge:**
- Screenshots
- Mockups
4. **Export:**
- CSV-Export für Admins
- Roadmap als PDF
5. **API-Endpoints:**
- Öffentliche API für Roadmap
- Webhook für Status-Updates
## Wartung
### Regelmäßige Aufgaben
1. **Wöchentlich:**
- Neue Anfragen moderieren
- Status aktualisieren
2. **Monatlich:**
- Implementierte Features auf "completed" setzen
- Alte/irrelevante Anfragen archivieren
3. **Quartalsweise:**
- Prioritäten überprüfen
- Roadmap-Planung aktualisieren
### Backup
Feature-Requests sind Teil des regulären PocketBase-Backups.
Wichtig: Beide Collections (`feature_requests` und `feature_votes`) sichern.
## Troubleshooting
### Problem: Votes werden nicht gezählt
**Lösung:**
1. Prüfe ob user eingeloggt ist
2. Prüfe feature_votes Collection Rules
3. Manuell vote_count korrigieren
### Problem: Feature-Requests nicht sichtbar
**Lösung:**
1. Prüfe `published = true`
2. Prüfe Collection Rules
3. Cache leeren
### Problem: Doppelte Votes
**Lösung:**
1. Unique Index auf user+feature_request erstellen
2. Duplikate in DB entfernen
3. vote_count neu berechnen
## Code-Referenzen
- **Frontend:** `/src/routes/features/+page.svelte`
- **Server:** `/src/routes/features/+page.server.ts`
- **Types:** Automatisch generiert in `./$types`
- **PocketBase:** Collections `feature_requests` und `feature_votes`
## Deployment-Checklist
- [ ] PocketBase Collections erstellt
- [ ] Zugriffsregeln konfiguriert
- [ ] Admin-User angelegt
- [ ] Erste Test-Features eingereicht
- [ ] Vote-Funktionalität getestet
- [ ] Moderation getestet
- [ ] Mobile Ansicht geprüft
- [ ] Performance getestet
---
_Letzte Aktualisierung: 14. August 2025_
_Version: 1.0.0_

View file

@ -1,142 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import * as m from '$paraglide/messages';
let { form }: { form: ActionData } = $props();
let isSubmitting = $state(false);
let emailSent = $state(false);
</script>
<Navigation user={null} currentPath={$page.url.pathname} />
<div class="flex min-h-screen items-center justify-center bg-theme-background p-4">
<div class="w-full max-w-md">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<div class="mb-6 text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<h1 class="text-2xl font-bold text-theme-text">{m.auth_reset_password_title()}</h1>
<p class="mt-2 text-theme-text-muted">
{m.auth_reset_password_subtitle()}
</p>
</div>
{#if emailSent || form?.success}
<div
class="rounded-lg bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
<div class="flex">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
<p class="text-sm font-medium">{m.auth_reset_email_sent_title()}</p>
<p class="mt-1 text-sm">
{m.auth_reset_email_sent_message()}
</p>
</div>
</div>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm text-theme-accent hover:text-theme-accent-hover">
{m.auth_back_to_login()}
</a>
</div>
{:else}
<form
method="POST"
action="?/requestReset"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
await update();
isSubmitting = false;
if (result.type === 'success') {
emailSent = true;
}
};
}}
>
<div class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_email_address_label()}
</label>
<input
type="email"
id="email"
name="email"
required
placeholder={m.auth_email_placeholder()}
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
{#if form?.error}
<div
class="animate-fade-in rounded-lg border border-red-400 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
⚠️ {form.error}
</div>
{/if}
<button
type="submit"
disabled={isSubmitting}
class="flex w-full items-center justify-center rounded-lg bg-theme-primary px-4 py-3 font-medium text-white transition duration-200 hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<svg class="mr-2 h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{m.auth_send_reset_button_loading()}
{:else}
{m.auth_send_reset_button()}
{/if}
</button>
</div>
</form>
<div class="mt-6 border-t border-theme-border pt-6 text-center">
<p class="text-sm text-theme-text-muted">
{m.auth_remember_password()}
<a href="/login" class="font-medium text-theme-accent hover:text-theme-accent-hover">
{m.auth_back_to_login()}
</a>
</p>
</div>
{/if}
</div>
</div>
</div>

View file

@ -1,132 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<h1 class="mb-8 text-3xl font-bold text-theme-text">Impressum</h1>
<div class="space-y-6 text-theme-text">
<section>
<h2 class="mb-3 text-xl font-semibold">Angaben gemäß § 5 TMG</h2>
<div class="space-y-1 text-theme-text-muted">
<p>[Ihr Name oder Firmenname]</p>
<p>[Ihre Straße und Hausnummer]</p>
<p>[Ihre Postleitzahl und Ort]</p>
<p>Deutschland</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">Kontakt</h2>
<div class="space-y-1 text-theme-text-muted">
<p>Telefon: [Ihre Telefonnummer]</p>
<p>E-Mail: [Ihre E-Mail-Adresse]</p>
<p>
Website: {typeof window !== 'undefined' ? window.location.origin : 'https://uload.de'}
</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">Umsatzsteuer-ID</h2>
<div class="text-theme-text-muted">
<p>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</p>
<p>[Ihre USt-IdNr.]</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<div class="space-y-1 text-theme-text-muted">
<p>[Name des Verantwortlichen]</p>
<p>[Adresse des Verantwortlichen]</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">EU-Streitschlichtung</h2>
<div class="text-theme-text-muted">
<p>
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS)
bereit:
</p>
<p>
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noopener noreferrer"
class="text-theme-primary hover:underline">https://ec.europa.eu/consumers/odr/</a
>
</p>
<p class="mt-2">Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">
Verbraucherstreitbeilegung/Universalschlichtungsstelle
</h2>
<div class="text-theme-text-muted">
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">Haftungsausschluss</h2>
<h3 class="mt-4 mb-2 font-semibold">Haftung für Inhalte</h3>
<p class="text-theme-text-muted">
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>
<h3 class="mt-4 mb-2 font-semibold">Haftung für Links</h3>
<p class="text-theme-text-muted">
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>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">Urheberrecht</h2>
<p class="text-theme-text-muted">
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>
</section>
</div>
<div
class="mt-8 rounded-lg bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"
>
<p class="font-semibold">⚠️ Wichtiger Hinweis:</p>
<p>
Bitte ersetzen Sie alle Platzhalter in eckigen Klammern [ ] mit Ihren tatsächlichen Daten.
</p>
</div>
</div>
</div>
</div>
<Footer />

View file

@ -1,8 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { user } = await parent();
return {
user
};
};

View file

@ -1,315 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import { trackAuth } from '$lib/analytics';
import * as m from '$paraglide/messages';
import { toastMessages, notify } from '$lib/services/toast';
import { onMount } from 'svelte';
let { form, data }: { form: ActionData; data: PageData } = $props();
let isSubmitting = $state(false);
// Check URL parameters for messages
const justRegistered = $page.url.searchParams.get('registered') === 'true';
const userEmail = $page.url.searchParams.get('email') || '';
const emailVerified = $page.url.searchParams.get('verified') === 'true';
const errorType = $page.url.searchParams.get('error');
const note = $page.url.searchParams.get('note');
const isAdditional = $derived(data?.isAdditional || false);
// Show toasts for URL parameters
onMount(() => {
if (emailVerified) {
if (note === 'already-verified') {
notify.info(m.auth_email_already_verified_notify(), m.auth_email_already_verified_notify_desc());
} else {
toastMessages.emailVerified();
}
} else if (errorType === 'token-expired') {
notify.warning(m.auth_token_expired_notify(), m.auth_token_expired_notify_desc());
} else if (justRegistered) {
toastMessages.registerSuccess();
}
});
</script>
<Navigation user={null} currentPath={$page.url.pathname} />
<div class="flex min-h-screen items-center justify-center bg-theme-background p-4">
<div class="w-full max-w-md">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<div class="mb-6 text-center">
<svg
class="mx-auto mb-4 h-12 w-12 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>
<h1 class="text-2xl font-bold text-theme-text">
{isAdditional ? m.auth_add_account() : m.auth_welcome_back()}
</h1>
<p class="mt-2 text-theme-text-muted">
{isAdditional ? m.auth_add_account_subtitle() : m.auth_welcome_back_subtitle()}
</p>
</div>
{#if isAdditional}
<div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">{m.auth_add_account_info()}</h3>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-300">
{m.auth_add_account_switch_info()}
</p>
</div>
</div>
</div>
{:else if emailVerified}
<div
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-4 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400"
>
<div class="flex items-start">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
{#if note === 'already-verified'}
<p class="text-sm font-medium">
{m.auth_email_already_verified()}
</p>
<p class="mt-1 text-sm">
{m.auth_email_already_verified_message()}
</p>
{:else}
<p class="text-sm font-medium">
{m.auth_email_verified()}
</p>
<p class="mt-1 text-sm">
{m.auth_email_verified_message()}
</p>
{/if}
</div>
</div>
</div>
{:else if errorType === 'token-expired'}
<div
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
<div class="flex items-start">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
<p class="text-sm font-medium">
{m.auth_verification_link_expired()}
</p>
<p class="mt-1 text-sm">
{m.auth_verification_link_expired_message()}
</p>
</div>
</div>
</div>
{:else if errorType === 'invalid-token'}
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
<div class="flex items-start">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
<p class="text-sm font-medium">
{m.auth_invalid_verification_link()}
</p>
<p class="mt-1 text-sm">
{m.auth_invalid_verification_link_message()}
</p>
</div>
</div>
</div>
{:else if justRegistered}
<div
class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
<div class="flex items-start">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<div class="ml-3">
<p class="text-sm font-medium">
{m.auth_registration_success()}
</p>
<p class="mt-1 text-sm">
{m.auth_registration_success_message({ email: userEmail })}
</p>
<p class="mt-3 text-xs opacity-75">
{m.auth_registration_tip()}
</p>
</div>
</div>
</div>
{/if}
<form
method="POST"
action="?/login{isAdditional ? '&additional=true' : ''}"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
if (result.type === 'redirect') {
// Track successful login
trackAuth('login', 'email');
toastMessages.loginSuccess();
// Reset submitting state before redirect
isSubmitting = false;
// Let the redirect happen
await update();
} else if (result.type === 'failure' && result.data?.error) {
toastMessages.loginError(result.data.error);
await update();
isSubmitting = false;
} else {
await update();
isSubmitting = false;
}
};
}}
>
<div class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_email_label()}
</label>
<input
type="email"
id="email"
name="email"
required
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<label for="password" class="block text-sm font-medium text-theme-text">
{m.auth_password_label()}
</label>
<a
href="/forgot-password"
class="text-sm text-theme-accent hover:text-theme-accent-hover"
>
{m.auth_forgot_password()}
</a>
</div>
<input
type="password"
id="password"
name="password"
required
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
{#if form?.error}
<div
class="animate-fade-in rounded-lg border border-red-400 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
⚠️ {form.error}
</div>
{/if}
<button
type="submit"
disabled={isSubmitting}
class="flex w-full items-center justify-center rounded-lg bg-theme-primary px-4 py-3 font-medium text-theme-background transition duration-200 hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<svg class="mr-2 h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{m.auth_login_button_loading()}
{:else}
{m.auth_login_button()}
{/if}
</button>
</div>
</form>
<div class="mt-6 border-t border-theme-border pt-6 text-center">
{#if isAdditional}
<p class="text-sm text-theme-text-muted">
Noch keinen Account?
<a href="/register?additional=true" class="font-medium text-theme-accent hover:text-theme-accent-hover"
>Neuen Account erstellen</a
>
</p>
<p class="mt-2 text-sm text-theme-text-muted">
<a href="/my" class="font-medium text-theme-accent hover:text-theme-accent-hover"
>Zurück zu meinem Account</a
>
</p>
{:else}
<p class="text-sm text-theme-text-muted">
{m.auth_no_account()}
<a href="/register" class="font-medium text-theme-accent hover:text-theme-accent-hover"
>{m.auth_create_account()}</a
>
</p>
{/if}
</div>
</div>
</div>
</div>

View file

@ -1,200 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import { trackAuth } from '$lib/analytics';
import * as m from '$paraglide/messages';
let { form, data }: { form: ActionData; data: PageData } = $props();
let isSubmitting = $state(false);
let isAdditionalAccount = $derived($page.url.searchParams.get('additional') === 'true');
</script>
<Navigation user={null} currentPath={$page.url.pathname} />
<div class="flex min-h-screen items-center justify-center bg-theme-background p-4">
<div class="w-full max-w-md">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<div class="mb-6 text-center">
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
<h1 class="text-2xl font-bold text-theme-text">{m.auth_create_account_title()}</h1>
<p class="mt-2 text-theme-text-muted">{m.auth_create_account_subtitle()}</p>
</div>
{#if isAdditionalAccount}
<div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Zusätzlichen Account erstellen</h3>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-300">
Du erstellst einen zusätzlichen Account. Nach der Registrierung kannst du zwischen deinen Accounts wechseln.
</p>
</div>
</div>
</div>
{:else if data?.invitation}
<div class="mb-6 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="flex-1">
<h3 class="text-sm font-medium text-green-800 dark:text-green-200">Team Invitation</h3>
<p class="mt-1 text-sm text-green-700 dark:text-green-300">
{data.invitation.inviterName} has invited you to join their team. Create your account to accept.
</p>
</div>
</div>
</div>
{/if}
<form
method="POST"
action="?/register{isAdditionalAccount ? '&additional=true' : ''}"
use:enhance={() => {
isSubmitting = true;
console.log('[CLIENT] Starting registration submission');
return async ({ result, update }) => {
console.log('[CLIENT] Registration result:', result);
console.log('[CLIENT] Result type:', result.type);
if (result.type === 'failure') {
console.error('[CLIENT] Registration failed with status:', result.status);
console.error('[CLIENT] Error data:', result.data);
if (result.data?.error) {
console.error('[CLIENT] Error message:', result.data.error);
}
// Log all properties of result.data
console.error('[CLIENT] All error properties:', JSON.stringify(result.data, null, 2));
}
await update();
isSubmitting = false;
// Track successful signup
if (result.type === 'redirect') {
console.log('[CLIENT] Registration successful, redirecting');
trackAuth('signup', 'email');
}
};
}}
>
<div class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_email_label()}
</label>
<input
type="email"
id="email"
name="email"
required
value={data?.invitation?.email || ''}
readonly={!!data?.invitation?.email}
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none {data?.invitation?.email ? 'bg-gray-100 dark:bg-gray-800' : ''}"
/>
{#if data?.invitation?.token}
<input type="hidden" name="inviteToken" value={data.invitation.token} />
{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_password_label()}
</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
</div>
<div>
<label for="passwordConfirm" class="mb-1 block text-sm font-medium text-theme-text">
{m.auth_password_confirm_label()}
</label>
<input
type="password"
id="passwordConfirm"
name="passwordConfirm"
required
minlength="8"
class="w-full rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
/>
<p class="mt-1 text-xs text-theme-text-muted">
{m.auth_username_auto()}
</p>
</div>
{#if form?.error}
<div
class="animate-fade-in rounded-lg border border-red-400 bg-red-50 p-3 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
⚠️ {form.error}
</div>
{/if}
<button
type="submit"
disabled={isSubmitting}
class="flex w-full items-center justify-center rounded-lg bg-theme-primary px-4 py-3 font-medium text-theme-background transition duration-200 hover:bg-theme-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSubmitting}
<svg class="mr-2 h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{m.auth_register_button_loading()}
{:else}
{m.auth_register_button()}
{/if}
</button>
</div>
</form>
<div class="mt-6 border-t border-theme-border pt-6 text-center">
{#if !isAdditionalAccount}
<p class="text-sm text-theme-text-muted">
{m.auth_have_account()}
<a href="/login" class="font-medium text-theme-accent hover:text-theme-accent-hover"
>{m.auth_sign_in()}</a
>
</p>
{:else}
<p class="text-sm text-theme-text-muted">
<a href="/my" class="font-medium text-theme-accent hover:text-theme-accent-hover"
>Zurück zu meinem Account</a
>
</p>
{/if}
</div>
</div>
</div>
</div>

View file

@ -1,280 +0,0 @@
<script lang="ts">
import Navigation from '$lib/components/Navigation.svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<Navigation user={data.user} currentPath={$page.url.pathname} />
<div class="min-h-screen bg-theme-background">
<div class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div class="rounded-xl border border-theme-border bg-theme-surface p-8 shadow-xl">
<h1 class="mb-8 text-3xl font-bold text-theme-text">Sicherheit</h1>
<div
class="mb-6 rounded-lg bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
<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>
<div class="space-y-6 text-theme-text">
<section>
<h2 class="mb-3 text-xl font-semibold">🛡️ Verschlüsselung</h2>
<h3 class="mt-4 mb-2 font-semibold">SSL/TLS-Verschlüsselung</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Verschlüsselte Speicherung</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Ende-zu-Ende Verschlüsselung für Premium-Nutzer</h3>
<p class="text-theme-text-muted">
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>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">🔐 Authentifizierung & Zugriffskontrolle</h2>
<h3 class="mt-4 mb-2 font-semibold">Sichere Authentifizierung</h3>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<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 class="mt-4 mb-2 font-semibold">Passwortgeschützte Links</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">IP-Whitelisting für Enterprise</h3>
<p class="text-theme-text-muted">
Enterprise-Kunden können IP-Whitelisting aktivieren, um den Zugriff auf ihre Links nur
von bestimmten IP-Adressen oder IP-Bereichen zu erlauben.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">🛠️ Infrastruktur-Sicherheit</h2>
<h3 class="mt-4 mb-2 font-semibold">Hosting & Server</h3>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<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 class="mt-4 mb-2 font-semibold">DDoS-Schutz</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Web Application Firewall (WAF)</h3>
<p class="text-theme-text-muted">
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>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">🔍 Überwachung & Schutz</h2>
<h3 class="mt-4 mb-2 font-semibold">Malware & Phishing-Schutz</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Echtzeit-Überwachung</h3>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<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 class="mt-4 mb-2 font-semibold">Link-Validierung</h3>
<p class="text-theme-text-muted">
Regelmäßige Überprüfung aller Ziel-URLs auf Verfügbarkeit und Sicherheit. Gefährliche
oder kompromittierte Websites werden automatisch blockiert.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">📊 Datenschutz & Compliance</h2>
<h3 class="mt-4 mb-2 font-semibold">DSGVO-Konformität</h3>
<p class="text-theme-text-muted">
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 class="mt-4 mb-2 font-semibold">Datensparsamkeit</h3>
<p class="text-theme-text-muted">
Wir sammeln nur die minimal notwendigen Daten für den Betrieb unseres Services. Keine
unnötige Datensammlung oder -weitergabe an Dritte.
</p>
<h3 class="mt-4 mb-2 font-semibold">Regelmäßige Audits</h3>
<p class="text-theme-text-muted">
Unabhängige Sicherheitsaudits und Penetrationstests werden regelmäßig durchgeführt, um
höchste Sicherheitsstandards zu gewährleisten.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">🔄 Backup & Wiederherstellung</h2>
<h3 class="mt-4 mb-2 font-semibold">Automatische Backups</h3>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<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 class="mt-4 mb-2 font-semibold">Disaster Recovery</h3>
<p class="text-theme-text-muted">
Umfassender Disaster-Recovery-Plan mit RPO (Recovery Point Objective) von maximal 24
Stunden und RTO (Recovery Time Objective) von maximal 4 Stunden.
</p>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">👤 Ihre Verantwortung</h2>
<h3 class="mt-4 mb-2 font-semibold">Best Practices für Nutzer</h3>
<ul class="list-inside list-disc space-y-1 text-theme-text-muted">
<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>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">🚨 Sicherheitsvorfälle melden</h2>
<h3 class="mt-4 mb-2 font-semibold">Verantwortungsvolle Offenlegung</h3>
<p class="text-theme-text-muted">
Wir schätzen die Arbeit von Sicherheitsforschern. Wenn Sie eine Sicherheitslücke
entdecken, melden Sie diese bitte verantwortungsvoll an:
</p>
<div class="mt-2 rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
<p class="font-mono text-sm text-theme-text">security@uload.de</p>
</div>
<p class="mt-2 text-theme-text-muted">
Bitte geben Sie uns angemessene Zeit zur Behebung, bevor Sie die Schwachstelle
öffentlich machen.
</p>
<h3 class="mt-4 mb-2 font-semibold">Bug Bounty Programm</h3>
<p class="text-theme-text-muted">
Für kritische Sicherheitslücken bieten wir Belohnungen im Rahmen unseres Bug Bounty
Programms. Details finden Sie unter:
</p>
<div class="mt-2 rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
<p class="font-mono text-sm text-theme-text">uload.de/bug-bounty</p>
</div>
</section>
<section>
<h2 class="mb-3 text-xl font-semibold">📋 Zertifizierungen & Standards</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-theme-border p-4">
<h3 class="mb-2 font-semibold">ISO 27001</h3>
<p class="text-sm text-theme-text-muted">
Informationssicherheits-Management-System zertifiziert
</p>
</div>
<div class="rounded-lg border border-theme-border p-4">
<h3 class="mb-2 font-semibold">SSL Labs A+</h3>
<p class="text-sm text-theme-text-muted">
Höchste Bewertung für SSL/TLS-Konfiguration
</p>
</div>
<div class="rounded-lg border border-theme-border p-4">
<h3 class="mb-2 font-semibold">OWASP Compliance</h3>
<p class="text-sm text-theme-text-muted">
Einhaltung der OWASP-Sicherheitsrichtlinien
</p>
</div>
<div class="rounded-lg border border-theme-border p-4">
<h3 class="mb-2 font-semibold">PCI DSS Ready</h3>
<p class="text-sm text-theme-text-muted">
Bereit für Payment Card Industry Standards
</p>
</div>
</div>
</section>
<section class="mt-8">
<h2 class="mb-3 text-xl font-semibold">📞 Kontakt</h2>
<p class="text-theme-text-muted">
Bei Fragen zur Sicherheit unseres Services kontaktieren Sie uns:
</p>
<div class="mt-3 space-y-2">
<p class="text-theme-text-muted">
<span class="font-semibold">E-Mail:</span> security@uload.de
</p>
<p class="text-theme-text-muted">
<span class="font-semibold">PGP-Schlüssel:</span> Verfügbar auf Anfrage
</p>
<p class="text-theme-text-muted">
<span class="font-semibold">Notfall-Hotline:</span> +49 (0) [Telefonnummer]
</p>
</div>
</section>
</div>
<div
class="mt-8 rounded-lg bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
<p class="font-semibold">💡 Tipp:</p>
<p>
Aktivieren Sie die Zwei-Faktor-Authentifizierung in Ihren Account-Einstellungen für
maximale Sicherheit!
</p>
</div>
<div
class="mt-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
<p class="font-semibold">✓ Letzte Sicherheitsüberprüfung:</p>
<p>
{new Date().toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
</div>
</div>
</div>

View file

@ -1,8 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { user } = await parent();
return {
user
};
};

View file

@ -1,4 +1,9 @@
version: '3.8'
# =============================================================================
# uload Docker Compose - Coolify Deployment
# =============================================================================
# This file is used by Coolify for deployment.
# Environment variables are injected by Coolify.
# =============================================================================
services:
app:
@ -8,10 +13,38 @@ services:
ports:
- '3000:3000'
environment:
NODE_ENV: ${NODE_ENV:-production}
NODE_ENV: production
PORT: 3000
HOST: 0.0.0.0
ORIGIN: ${ORIGIN:-https://ulo.ad}
PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID}
# Database (set in Coolify)
DATABASE_URL: ${DATABASE_URL}
# Redis (optional, set in Coolify)
REDIS_URL: ${REDIS_URL:-}
# Auth
AUTH_SECRET: ${AUTH_SECRET}
# External Services (set in Coolify)
RESEND_API_KEY: ${RESEND_API_KEY:-}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
# R2 Storage (set in Coolify)
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-}
R2_ENDPOINT: ${R2_ENDPOINT:-}
# Analytics (optional)
PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL:-}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View file

@ -1,17 +1,98 @@
version: '3.8'
# =============================================================================
# uload Docker Compose - Production (standalone)
# =============================================================================
# Use this for manual production deployment without Coolify.
# For Coolify deployments, use docker-compose.coolify.yml instead.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# PostgreSQL Database
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: uload-db-prod
restart: always
environment:
POSTGRES_DB: uload
POSTGRES_USER: uload
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U uload -d uload"]
interval: 10s
timeout: 5s
retries: 5
networks:
- uload-network
# ---------------------------------------------------------------------------
# Redis Cache
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: uload-redis-prod
restart: always
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- uload-network
# ---------------------------------------------------------------------------
# uload Web Application
# ---------------------------------------------------------------------------
app:
build:
context: .
dockerfile: Dockerfile
container_name: uload-app-prod
ports:
- '3000:3000'
environment:
NODE_ENV: production
PORT: 3000
HOST: 0.0.0.0
ORIGIN: ${ORIGIN:-https://ulo.ad}
PUBLIC_POCKETBASE_URL: ${PUBLIC_POCKETBASE_URL}
PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID}
restart: unless-stopped
DATABASE_URL: postgresql://uload:${DB_PASSWORD}@postgres:5432/uload
REDIS_URL: redis://redis:6379
AUTH_SECRET: ${AUTH_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY:-}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
R2_BUCKET_NAME: ${R2_BUCKET_NAME:-}
R2_ENDPOINT: ${R2_ENDPOINT:-}
PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL:-}
PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID:-}
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- uload-network
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
uload-network:
driver: bridge

View file

@ -1,6 +1,24 @@
version: '3.8'
# =============================================================================
# uload Docker Compose - Local Development
# =============================================================================
#
# Usage:
# docker compose up -d # Start all services
# docker compose up -d postgres # Start only PostgreSQL
# docker compose logs -f # Follow logs
# docker compose down # Stop all services
# docker compose down -v # Stop and remove volumes
#
# Connection strings for local development:
# DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
# REDIS_URL=redis://localhost:6379
#
# =============================================================================
services:
# ---------------------------------------------------------------------------
# PostgreSQL Database
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: uload-db-dev
@ -22,6 +40,28 @@ services:
networks:
- uload-network
# ---------------------------------------------------------------------------
# Redis Cache
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: uload-redis-dev
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- uload-network
# ---------------------------------------------------------------------------
# pgAdmin (Database GUI)
# ---------------------------------------------------------------------------
pgadmin:
image: dpage/pgadmin4:latest
container_name: uload-pgadmin-dev
@ -43,6 +83,8 @@ services:
volumes:
postgres_data:
driver: local
redis_data:
driver: local
pgadmin_data:
driver: local