mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 05:09:39 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,76 @@
|
|||
{
|
||||
"name": "@uload/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ULOAD URL Shortener Backend",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/uload-database": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^18.0.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-cls": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ua-parser-js": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
"name": "@uload/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ULOAD URL Shortener Backend",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/uload-database": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^18.0.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-cls": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ua-parser-js": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,61 +20,56 @@ 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 },
|
||||
}),
|
||||
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',
|
||||
}),
|
||||
// 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,
|
||||
// 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,
|
||||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [
|
||||
HealthController,
|
||||
RedirectController,
|
||||
LinksController,
|
||||
AnalyticsController,
|
||||
],
|
||||
providers: [
|
||||
// Repositories
|
||||
LinkRepository,
|
||||
ClickRepository,
|
||||
// Services
|
||||
LinksService,
|
||||
RedirectService,
|
||||
AnalyticsService,
|
||||
],
|
||||
// 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
|
||||
}
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Add custom middleware here if needed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import * as Joi from 'joi';
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
// Server
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test')
|
||||
.default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
// Server
|
||||
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: Joi.string().uri().required(),
|
||||
// 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(),
|
||||
// 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(),
|
||||
// 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(),
|
||||
// Frontend
|
||||
FRONTEND_URL: Joi.string().uri().optional(),
|
||||
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
|
|
@ -14,85 +14,82 @@ import { LinksService } from '../services/links.service';
|
|||
@Controller('api/analytics')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly linksService: LinksService,
|
||||
) {}
|
||||
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;
|
||||
@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');
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
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;
|
||||
@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');
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
@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
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalLinks,
|
||||
// Add more overview stats as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheckService,
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
} from '@nestjs/terminus';
|
||||
import { HealthCheckService, HealthCheck, HealthCheckResult } from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private health: HealthCheckService) {}
|
||||
constructor(private health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@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',
|
||||
};
|
||||
}
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'live',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
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';
|
||||
|
|
@ -16,116 +16,112 @@ import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../service
|
|||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
constructor(private readonly linksService: LinksService) {}
|
||||
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,
|
||||
});
|
||||
@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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
@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');
|
||||
}
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
@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,
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
@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');
|
||||
}
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
@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');
|
||||
}
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,103 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
HttpStatus,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
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,
|
||||
) {}
|
||||
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',
|
||||
});
|
||||
}
|
||||
@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);
|
||||
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',
|
||||
});
|
||||
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 '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 '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 '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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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));
|
||||
// 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!);
|
||||
}
|
||||
// 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);
|
||||
@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',
|
||||
});
|
||||
}
|
||||
if (!result.success) {
|
||||
return response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,25 +5,25 @@ 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],
|
||||
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);
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
|
|
|
|||
|
|
@ -1,162 +1,158 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
clicks,
|
||||
type Click,
|
||||
type NewClick,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
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 }[];
|
||||
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);
|
||||
private readonly logger = new Logger(ClickRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
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 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 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 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)];
|
||||
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));
|
||||
}
|
||||
if (fromDate) {
|
||||
conditions.push(gte(clicks.clickedAt, fromDate));
|
||||
}
|
||||
if (toDate) {
|
||||
conditions.push(lte(clicks.clickedAt, toDate));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Total clicks
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
// 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);
|
||||
// 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 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 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);
|
||||
// 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);
|
||||
// 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,144 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
links,
|
||||
type Link,
|
||||
type NewLink,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
or,
|
||||
ilike,
|
||||
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;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkRepository {
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
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 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 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 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;
|
||||
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)];
|
||||
const conditions = [eq(links.userId, userId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(links.title, `%${search}%`),
|
||||
ilike(links.originalUrl, `%${search}%`),
|
||||
ilike(links.shortCode, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
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));
|
||||
}
|
||||
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),
|
||||
]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
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 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 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 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 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 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,44 +4,44 @@ import { ConfigService } from '@nestjs/config';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
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'],
|
||||
});
|
||||
// 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 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'],
|
||||
});
|
||||
// Global prefix for API routes (except health and redirect)
|
||||
app.setGlobalPrefix('v1', {
|
||||
exclude: ['health', 'health/(.*)', ':code'],
|
||||
});
|
||||
|
||||
const port = configService.get('PORT') || 3003;
|
||||
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`);
|
||||
await app.listen(port);
|
||||
logger.log(`ULOAD Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -5,98 +5,94 @@ 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;
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
ip?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickRepository: ClickRepository,
|
||||
private readonly redirectService: RedirectService,
|
||||
) {}
|
||||
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();
|
||||
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;
|
||||
// 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';
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
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);
|
||||
await this.clickRepository.create(clickData);
|
||||
|
||||
// Increment click count on the link
|
||||
await this.redirectService.incrementClickCount(linkId);
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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 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),
|
||||
]);
|
||||
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 };
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,140 +5,133 @@ 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;
|
||||
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;
|
||||
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;
|
||||
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');
|
||||
}
|
||||
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;
|
||||
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',
|
||||
);
|
||||
}
|
||||
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);
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
// 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 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}`);
|
||||
const link = await this.linkRepository.create(newLink);
|
||||
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
|
||||
|
||||
return link;
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
async updateLink(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: UpdateLinkDto,
|
||||
): Promise<Link | null> {
|
||||
const link = await this.linkRepository.update(id, userId, dto);
|
||||
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}`);
|
||||
}
|
||||
if (link) {
|
||||
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
async deleteLink(id: string, userId: string): Promise<boolean> {
|
||||
const deleted = await this.linkRepository.delete(id, userId);
|
||||
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}`);
|
||||
}
|
||||
if (deleted) {
|
||||
this.logger.log(`Deleted link ${id} for user ${userId}`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getLinkById(id: string, userId: string): Promise<Link | null> {
|
||||
return this.linkRepository.findByIdAndUserId(id, userId);
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
async getLinkCount(userId: string): Promise<number> {
|
||||
return this.linkRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,75 +3,72 @@ 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';
|
||||
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);
|
||||
private readonly logger = new Logger(RedirectService.name);
|
||||
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
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 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 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 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 };
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
shortCode: string,
|
||||
password: string,
|
||||
): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
async verifyPassword(shortCode: string, password: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +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
|
||||
}
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
{
|
||||
"name": "@uload/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@uload/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,94 +2,113 @@
|
|||
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' },
|
||||
],
|
||||
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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -2,137 +2,194 @@
|
|||
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>
|
||||
<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>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<!-- Trust badges -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
></path>
|
||||
</svg>
|
||||
DSGVO-konform
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Blitzschnell
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
100% Sicher
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main headline -->
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
More than links.
|
||||
<span class="bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Your digital identity.
|
||||
</span>
|
||||
</h1>
|
||||
<!-- 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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- Visual preview -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Link shortening preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Smart Links</h3>
|
||||
<p class="text-sm text-gray-600">Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz</p>
|
||||
<a
|
||||
href="/features"
|
||||
class="mt-4 inline-block text-xs text-primary-600 group-hover:underline"
|
||||
>
|
||||
Mehr erfahren →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Profile cards preview -->
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</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>
|
||||
<!-- Profile cards preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Profile Cards</h3>
|
||||
<p class="text-sm text-gray-600">Beeindruckende Profilseiten mit Drag & Drop Builder</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-purple-600 group-hover:underline">
|
||||
Templates ansehen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Team collaboration preview -->
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</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>
|
||||
<!-- Team collaboration preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Team Workspace</h3>
|
||||
<p class="text-sm text-gray-600">Gemeinsam Links verwalten mit granularen Berechtigungen</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-green-600 group-hover:underline">
|
||||
Für Teams →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,87 +1,86 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/about', label: 'Über uns' },
|
||||
{ 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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- 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 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>
|
||||
<!-- 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');
|
||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfass
|
|||
## 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?
|
||||
|
|
@ -19,15 +20,19 @@ Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
|
|||
## 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
|
||||
|
|
@ -54,6 +59,7 @@ https://ulo.ad/angebot
|
|||
### Was ist erlaubt?
|
||||
|
||||
✅ **Anonymisierte Daten**
|
||||
|
||||
- Gerätetyp
|
||||
- Browser
|
||||
- Ungefährer Standort
|
||||
|
|
@ -62,6 +68,7 @@ https://ulo.ad/angebot
|
|||
### Was braucht Zustimmung?
|
||||
|
||||
❌ **Personenbezogene Daten**
|
||||
|
||||
- Vollständige IP-Adressen
|
||||
- Device Fingerprinting
|
||||
- Cross-Site Tracking
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, löse
|
|||
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
|
||||
```
|
||||
|
|
@ -53,6 +55,7 @@ Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Geh
|
|||
### 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
|
||||
|
|
|
|||
|
|
@ -1,17 +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(),
|
||||
}),
|
||||
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,
|
||||
blog: blogCollection,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,49 +4,56 @@ import Navigation from '../components/Navigation.astro';
|
|||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
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 {
|
||||
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>
|
||||
<!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} />
|
||||
<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} />
|
||||
<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)} />
|
||||
<!-- 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)} />
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -2,31 +2,27 @@
|
|||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
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>
|
||||
<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>
|
||||
<div class="prose prose-lg prose-gray max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
|
|
@ -2,123 +2,129 @@
|
|||
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' }
|
||||
{ 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.'
|
||||
}
|
||||
{
|
||||
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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -3,57 +3,74 @@ 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>§ 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>§ 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>§ 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>§ 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>§ 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>§ 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>§ 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>§ 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>§ 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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ 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 },
|
||||
}));
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = { post: CollectionEntry<'blog'> };
|
||||
|
|
@ -15,69 +15,80 @@ 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);
|
||||
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>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-12">
|
||||
<a
|
||||
href="/blog"
|
||||
class="inline-flex items-center gap-2 text-sm text-primary-600 hover:underline mb-6"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Zurück zum Blog
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl mb-4">
|
||||
{post.data.title}
|
||||
</h1>
|
||||
<div class="flex items-center gap-4 text-gray-500">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{
|
||||
post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg prose-gray max-w-none prose-headings:font-bold prose-a:text-primary-600 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded">
|
||||
<Content />
|
||||
</div>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -3,66 +3,67 @@ 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()
|
||||
(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);
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,74 +3,89 @@ import LegalLayout from '../layouts/LegalLayout.astro';
|
|||
---
|
||||
|
||||
<LegalLayout title="Datenschutzerklärung" lastUpdated="Januar 2024">
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
<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>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>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>
|
||||
<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>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>
|
||||
<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>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>
|
||||
<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>
|
||||
<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>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>
|
||||
<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>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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -4,155 +4,166 @@ 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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
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>
|
||||
<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>
|
||||
))}
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -3,53 +3,61 @@ import LegalLayout from '../layouts/LegalLayout.astro';
|
|||
---
|
||||
|
||||
<LegalLayout title="Impressum">
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
<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>
|
||||
<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>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>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>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>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 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>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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -13,211 +13,223 @@ const appUrl = 'https://app.ulo.ad';
|
|||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'Smart Links',
|
||||
description: 'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.'
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description: 'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'QR-Code Generator',
|
||||
description: 'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.'
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Profile Cards',
|
||||
description: 'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.'
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description: 'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.'
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: 'API & Integrationen',
|
||||
description: 'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.'
|
||||
}
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'Smart Links',
|
||||
description:
|
||||
'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description:
|
||||
'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'QR-Code Generator',
|
||||
description:
|
||||
'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.',
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Profile Cards',
|
||||
description:
|
||||
'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.',
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description:
|
||||
'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.',
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: 'API & Integrationen',
|
||||
description:
|
||||
'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Link einfügen',
|
||||
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
|
||||
image: '/screenshots/paste.png'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Anpassen',
|
||||
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
|
||||
image: '/screenshots/customize.png'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Teilen & Tracken',
|
||||
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
|
||||
image: '/screenshots/share.png'
|
||||
}
|
||||
{
|
||||
number: '1',
|
||||
title: 'Link einfügen',
|
||||
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
|
||||
image: '/screenshots/paste.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Anpassen',
|
||||
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
|
||||
image: '/screenshots/customize.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Teilen & Tracken',
|
||||
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
|
||||
image: '/screenshots/share.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Links pro Monat', included: true },
|
||||
{ text: 'Basis Analytics', included: true },
|
||||
{ text: 'QR-Code Generator', included: true },
|
||||
{ text: 'Link Anpassung', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: false },
|
||||
{ text: 'Team Features', included: false }
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: `${appUrl}/register`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '4,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Freelancer & Creators',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Passwortschutz', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro wählen',
|
||||
href: `${appUrl}/register?plan=pro`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Pro Jährlich',
|
||||
price: '3,33',
|
||||
period: '/Monat',
|
||||
description: 'Spare 20€ pro Jahr',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Jährlich sparen',
|
||||
href: `${appUrl}/register?plan=pro-yearly`
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Spare 20€'
|
||||
},
|
||||
{
|
||||
name: 'Lifetime',
|
||||
price: '129,99',
|
||||
period: 'einmalig',
|
||||
description: 'Einmal zahlen, für immer nutzen',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Lebenslanger Zugang', included: true },
|
||||
{ text: 'Alle zukünftigen Features', included: true },
|
||||
{ text: 'Early Access', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Keine Abo-Gebühren', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Lifetime sichern',
|
||||
href: `${appUrl}/register?plan=lifetime`
|
||||
},
|
||||
badge: 'Einmalig'
|
||||
}
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Links pro Monat', included: true },
|
||||
{ text: 'Basis Analytics', included: true },
|
||||
{ text: 'QR-Code Generator', included: true },
|
||||
{ text: 'Link Anpassung', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: false },
|
||||
{ text: 'Team Features', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: `${appUrl}/register`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '4,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Freelancer & Creators',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Passwortschutz', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro wählen',
|
||||
href: `${appUrl}/register?plan=pro`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro Jährlich',
|
||||
price: '3,33',
|
||||
period: '/Monat',
|
||||
description: 'Spare 20€ pro Jahr',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Jährlich sparen',
|
||||
href: `${appUrl}/register?plan=pro-yearly`,
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Spare 20€',
|
||||
},
|
||||
{
|
||||
name: 'Lifetime',
|
||||
price: '129,99',
|
||||
period: 'einmalig',
|
||||
description: 'Einmal zahlen, für immer nutzen',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Lebenslanger Zugang', included: true },
|
||||
{ text: 'Alle zukünftigen Features', included: true },
|
||||
{ text: 'Early Access', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Keine Abo-Gebühren', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Lifetime sichern',
|
||||
href: `${appUrl}/register?plan=lifetime`,
|
||||
},
|
||||
badge: 'Einmalig',
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie lange bleiben meine Links aktiv?',
|
||||
answer: 'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine eigene Domain verwenden?',
|
||||
answer: 'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).'
|
||||
},
|
||||
{
|
||||
question: 'Wie funktionieren die Analytics?',
|
||||
answer: 'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.'
|
||||
},
|
||||
{
|
||||
question: 'Was sind Profile Cards?',
|
||||
answer: 'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.'
|
||||
},
|
||||
{
|
||||
question: 'Gibt es eine API?',
|
||||
answer: 'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer: 'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.'
|
||||
}
|
||||
{
|
||||
question: 'Wie lange bleiben meine Links aktiv?',
|
||||
answer:
|
||||
'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine eigene Domain verwenden?',
|
||||
answer:
|
||||
'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).',
|
||||
},
|
||||
{
|
||||
question: 'Wie funktionieren die Analytics?',
|
||||
answer:
|
||||
'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.',
|
||||
},
|
||||
{
|
||||
question: 'Was sind Profile Cards?',
|
||||
answer:
|
||||
'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.',
|
||||
},
|
||||
{
|
||||
question: 'Gibt es eine API?',
|
||||
answer:
|
||||
'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer:
|
||||
'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Intelligenter URL-Shortener">
|
||||
<HeroSection />
|
||||
<HeroSection />
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du für professionelles Link-Management brauchst"
|
||||
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
/>
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du für professionelles Link-Management brauchst"
|
||||
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
/>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum perfekten Link"
|
||||
subtitle="So einfach funktioniert uLoad"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum perfekten Link"
|
||||
subtitle="So einfach funktioniert uLoad"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Transparente Preise, keine versteckten Kosten"
|
||||
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
|
||||
plans={pricingPlans}
|
||||
/>
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Transparente Preise, keine versteckten Kosten"
|
||||
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
|
||||
plans={pricingPlans}
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über uLoad wissen musst"
|
||||
faqs={faqs}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über uLoad wissen musst"
|
||||
faqs={faqs}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="cta"
|
||||
title="Bereit für smarte Links?"
|
||||
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
|
||||
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
|
||||
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
|
||||
variant="default"
|
||||
/>
|
||||
<CTASection
|
||||
id="cta"
|
||||
title="Bereit für smarte Links?"
|
||||
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
|
||||
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
|
||||
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
|
||||
variant="default"
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
|
|
@ -3,194 +3,200 @@ 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>
|
||||
<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>
|
||||
<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>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>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>
|
||||
<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>
|
||||
<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>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>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>
|
||||
<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>
|
||||
<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>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>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>
|
||||
<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>
|
||||
<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>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>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>
|
||||
<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>
|
||||
<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>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>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>
|
||||
<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>
|
||||
<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>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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Config } from 'drizzle-kit'
|
||||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/lib/db/schema.ts',
|
||||
|
|
@ -6,8 +6,9 @@ export default {
|
|||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL || 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev'
|
||||
process.env.DATABASE_URL ||
|
||||
'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
} satisfies Config
|
||||
strict: true,
|
||||
} satisfies Config;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1763571183375,
|
||||
"tag": "0000_material_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1763571183375,
|
||||
"tag": "0000_material_puma",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ export default ts.config(
|
|||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
|
|
@ -33,8 +33,8 @@ export default ts.config(
|
|||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test';
|
|||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
port: 4173,
|
||||
},
|
||||
testDir: 'e2e'
|
||||
testDir: 'e2e',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@
|
|||
"linkedin": "https://linkedin.com/in/tillschneider",
|
||||
"website": "https://ulo.ad"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfass
|
|||
## 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?
|
||||
|
|
@ -22,15 +23,19 @@ Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
|
|||
## Die wichtigsten Metriken
|
||||
|
||||
### 1. Click-Through-Rate (CTR)
|
||||
|
||||
Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
|
||||
|
||||
### 2. Conversion Rate
|
||||
|
||||
Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen (Kauf, Anmeldung, Download).
|
||||
|
||||
### 3. Bounce Rate
|
||||
|
||||
Wie viele Nutzer verlassen Ihre Seite sofort wieder? Eine hohe Bounce Rate deutet auf Probleme hin.
|
||||
|
||||
### 4. Geographic Distribution
|
||||
|
||||
Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
|
||||
|
||||
## UTM-Parameter richtig einsetzen
|
||||
|
|
@ -58,12 +63,14 @@ https://ulo.ad/angebot
|
|||
### Was ist erlaubt?
|
||||
|
||||
✅ **Anonymisierte Daten**
|
||||
|
||||
- Gerätetyp
|
||||
- Browser
|
||||
- Ungefährer Standort (Land/Stadt)
|
||||
- Referrer
|
||||
|
||||
✅ **Aggregierte Metriken**
|
||||
|
||||
- Gesamtklicks
|
||||
- Durchschnittliche Verweildauer
|
||||
- Conversion-Raten
|
||||
|
|
@ -71,6 +78,7 @@ https://ulo.ad/angebot
|
|||
### Was braucht Zustimmung?
|
||||
|
||||
❌ **Personenbezogene Daten**
|
||||
|
||||
- Vollständige IP-Adressen
|
||||
- Device Fingerprinting
|
||||
- Cross-Site Tracking
|
||||
|
|
@ -81,6 +89,7 @@ https://ulo.ad/angebot
|
|||
### 1. Konsistente Namenskonvention
|
||||
|
||||
Entwickeln Sie ein einheitliches Schema:
|
||||
|
||||
```
|
||||
utm_source: [channel]
|
||||
utm_medium: [type]
|
||||
|
|
@ -101,6 +110,7 @@ Löschen Sie alte, inaktive Links und konsolidieren Sie ähnliche Kampagnen.
|
|||
## A/B-Testing mit Links
|
||||
|
||||
Testen Sie verschiedene Varianten:
|
||||
|
||||
- Verschiedene Call-to-Actions
|
||||
- Unterschiedliche Landing Pages
|
||||
- Alternative Platzierungen
|
||||
|
|
@ -109,16 +119,19 @@ Testen Sie verschiedene Varianten:
|
|||
## Tools und Integration
|
||||
|
||||
### Google Analytics 4
|
||||
|
||||
- Automatisches UTM-Tracking
|
||||
- Conversion-Tracking
|
||||
- Audience-Segmentierung
|
||||
|
||||
### Marketing-Automation
|
||||
|
||||
- HubSpot
|
||||
- Mailchimp
|
||||
- ActiveCampaign
|
||||
|
||||
### Social Media Tools
|
||||
|
||||
- Buffer
|
||||
- Hootsuite
|
||||
- Sprout Social
|
||||
|
|
@ -141,4 +154,4 @@ Testen Sie verschiedene Varianten:
|
|||
|
||||
Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern und dabei vollständig DSGVO-konform bleiben.
|
||||
|
||||
Starten Sie noch heute mit professionellem Link-Tracking – Ihre Conversion-Rate wird es Ihnen danken!
|
||||
Starten Sie noch heute mit professionellem Link-Tracking – Ihre Conversion-Rate wird es Ihnen danken!
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, löse
|
|||
Vergleichen Sie diese beiden URLs:
|
||||
|
||||
**Lange URL (schlecht):**
|
||||
|
||||
```
|
||||
https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024&ref=user789&tracking=enabled
|
||||
```
|
||||
|
||||
**Kurze URL (gut):**
|
||||
|
||||
```
|
||||
https://ulo.ad/summer-sale
|
||||
```
|
||||
|
|
@ -59,6 +61,7 @@ Unsere Analyse von über 10.000 Link-Klicks hat vier Hauptfaktoren identifiziert
|
|||
### 1. Erkennbare Domain (60% Wichtigkeit)
|
||||
|
||||
Menschen wollen wissen, wo sie landen werden. Eine klare, erkennbare Domain ist der wichtigste Vertrauensfaktor:
|
||||
|
||||
- Verwenden Sie Ihre Marken-Domain wenn möglich
|
||||
- Bei Kurz-URLs: Wählen Sie einen Service mit gutem Ruf
|
||||
- Vermeiden Sie obskure URL-Shortener
|
||||
|
|
@ -66,6 +69,7 @@ Menschen wollen wissen, wo sie landen werden. Eine klare, erkennbare Domain ist
|
|||
### 2. Keine kryptischen Zeichen (25% Wichtigkeit)
|
||||
|
||||
Zufällige Zahlen-Buchstaben-Kombinationen wie "x7h9k2p" schrecken Nutzer ab. Stattdessen:
|
||||
|
||||
- Nutzen Sie sprechende Begriffe
|
||||
- Verwenden Sie relevante Keywords
|
||||
- Halten Sie es lesbar und merkbar
|
||||
|
|
@ -73,6 +77,7 @@ Zufällige Zahlen-Buchstaben-Kombinationen wie "x7h9k2p" schrecken Nutzer ab. St
|
|||
### 3. Optimale Länge (10% Wichtigkeit)
|
||||
|
||||
Die magische Grenze liegt bei etwa 50 Zeichen:
|
||||
|
||||
- **15-30 Zeichen**: Optimal für Social Media
|
||||
- **30-50 Zeichen**: Ideal für E-Mail-Marketing
|
||||
- **Über 50 Zeichen**: Deutlicher Rückgang der Klickrate
|
||||
|
|
@ -93,6 +98,7 @@ Der Unterschied? Der zweite Link kommuniziert sofort, was den Nutzer erwartet. D
|
|||
### 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
|
||||
|
|
@ -101,6 +107,7 @@ Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
|
|||
### 3. A/B-Testing ist Ihr Freund
|
||||
|
||||
Testen Sie verschiedene URL-Varianten:
|
||||
|
||||
- Kurz vs. deskriptiv
|
||||
- Mit Markenname vs. ohne
|
||||
- Verschiedene Keywords
|
||||
|
|
@ -109,6 +116,7 @@ Testen Sie verschiedene URL-Varianten:
|
|||
### 4. Performance-Tracking implementieren
|
||||
|
||||
Ohne Daten keine Optimierung. Moderne Link-Management-Tools bieten:
|
||||
|
||||
- Detaillierte Klick-Statistiken
|
||||
- Geografische Verteilung
|
||||
- Geräteerkennung
|
||||
|
|
@ -120,6 +128,7 @@ Ohne Daten keine Optimierung. Moderne Link-Management-Tools bieten:
|
|||
### E-Commerce: 67% mehr Conversions
|
||||
|
||||
Ein großer Online-Händler verkürzte seine Produkt-URLs von durchschnittlich 120 auf 45 Zeichen:
|
||||
|
||||
- **67% höhere Conversion Rate**
|
||||
- **42% mehr Social Shares**
|
||||
- **31% niedrigere Bounce Rate**
|
||||
|
|
@ -127,6 +136,7 @@ Ein großer Online-Händler verkürzte seine Produkt-URLs von durchschnittlich 1
|
|||
### Newsletter-Marketing: Verdoppelte Klickrate
|
||||
|
||||
Ein B2B-Unternehmen wechselte von langen Tracking-URLs zu personalisierten Kurz-URLs:
|
||||
|
||||
- **Vorher:** `company.com/newsletter/2024/march/article-5?utm_source=email&utm_medium=newsletter`
|
||||
- **Nachher:** `co.link/cloud-guide`
|
||||
- **Resultat:** 2,1x höhere Klickrate
|
||||
|
|
@ -136,6 +146,7 @@ Ein B2B-Unternehmen wechselte von langen Tracking-URLs zu personalisierten Kurz-
|
|||
### KI-optimierte Personalisierung
|
||||
|
||||
Moderne Systeme nutzen KI, um für jeden Nutzer die optimale URL-Variante zu generieren – basierend auf:
|
||||
|
||||
- Demografischen Daten
|
||||
- Bisherigem Klickverhalten
|
||||
- Kontext der Interaktion
|
||||
|
|
@ -144,6 +155,7 @@ Moderne Systeme nutzen KI, um für jeden Nutzer die optimale URL-Variante zu gen
|
|||
### Voice-First Optimization
|
||||
|
||||
Mit dem Aufstieg von Sprachassistenten werden "sprechbare" URLs wichtiger:
|
||||
|
||||
- Einfache Wörter statt Buchstaben-Zahlen-Kombinationen
|
||||
- Vermeidung ähnlich klingender Begriffe
|
||||
- Klare, eindeutige Aussprache
|
||||
|
|
@ -169,4 +181,4 @@ Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkunge
|
|||
4. **Messen**: Tracking der Performance-Verbesserungen
|
||||
5. **Iterieren**: Kontinuierliche Optimierung basierend auf Daten
|
||||
|
||||
Tools wie [uload](https://ulo.ad) wurden speziell entwickelt, um die Erkenntnisse der URL-Psychologie in die Praxis umzusetzen. Mit Features wie personalisierten Kurz-URLs, detaillierten Analytics und A/B-Testing können Sie sofort damit beginnen, Ihre Link-Performance zu optimieren.
|
||||
Tools wie [uload](https://ulo.ad) wurden speziell entwickelt, um die Erkenntnisse der URL-Psychologie in die Praxis umzusetzen. Mit Features wie personalisierten Kurz-URLs, detaillierten Analytics und A/B-Testing können Sie sofort damit beginnen, Ihre Link-Performance zu optimieren.
|
||||
|
|
|
|||
|
|
@ -6,19 +6,24 @@ export const authorSchema = z.object({
|
|||
name: z.string(),
|
||||
bio: z.string().optional(),
|
||||
avatar: z.string().optional(),
|
||||
social: z.object({
|
||||
twitter: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional()
|
||||
}).optional()
|
||||
social: z
|
||||
.object({
|
||||
twitter: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Blog Post Schema
|
||||
export const blogSchema = z.object({
|
||||
title: z.string(),
|
||||
excerpt: z.string(),
|
||||
date: z.string().or(z.date()).transform(val => new Date(val)),
|
||||
date: z
|
||||
.string()
|
||||
.or(z.date())
|
||||
.transform((val) => new Date(val)),
|
||||
author: z.string(), // Author ID
|
||||
tags: z.array(z.string()).default([]),
|
||||
category: z.enum(['tutorial', 'psychology', 'feature', 'announcement', 'case-study']),
|
||||
|
|
@ -27,11 +32,13 @@ export const blogSchema = z.object({
|
|||
featured: z.boolean().default(false),
|
||||
series: z.string().optional(),
|
||||
layout: z.string().default('blog'),
|
||||
seo: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
canonical: z.string().optional()
|
||||
}).optional()
|
||||
seo: z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
canonical: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
|
|
@ -54,4 +61,4 @@ export interface BlogCategory {
|
|||
export interface BlogTag {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ function addSecurityHeaders(response: Response) {
|
|||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
dev ? '' : 'upgrade-insecure-requests' // Only in production
|
||||
dev ? '' : 'upgrade-insecure-requests', // Only in production
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
getVariantContent,
|
||||
getTrustBadges,
|
||||
getFreeText,
|
||||
type VariantContent
|
||||
type VariantContent,
|
||||
} from '../config/variants';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
</script>
|
||||
|
||||
{#if showDebug}
|
||||
<div class="fixed top-20 right-4 z-50 rounded-lg bg-black/80 p-4 text-white shadow-lg">
|
||||
<div class="fixed right-4 top-20 z-50 rounded-lg bg-black/80 p-4 text-white shadow-lg">
|
||||
<div class="font-mono text-xs">
|
||||
<div class="font-bold text-green-400">A/B Test Debug</div>
|
||||
<div>Variant: <span class="text-yellow-400">{variant}</span></div>
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
>
|
||||
<!-- Background decoration -->
|
||||
<div
|
||||
class="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-purple-300 opacity-20 blur-3xl"
|
||||
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-purple-300 opacity-20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-blue-300 opacity-20 blur-3xl"
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
<div class="text-center">
|
||||
<!-- Headline -->
|
||||
<h1
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl md:text-6xl dark:text-white"
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl md:text-6xl"
|
||||
>
|
||||
{#if variant === 'b2' && content.headline.includes(',')}
|
||||
<!-- Special formatting for logos variant -->
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-gray-600 sm:text-xl dark:text-gray-300">
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300 sm:text-xl">
|
||||
{content.subheadline}
|
||||
</p>
|
||||
|
||||
|
|
@ -216,7 +216,7 @@
|
|||
<a
|
||||
href="#url-form"
|
||||
onclick={handleCtaClick}
|
||||
class="inline-block rounded-lg px-8 py-4 font-semibold whitespace-nowrap text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl {content.ctaStyle ||
|
||||
class="inline-block whitespace-nowrap rounded-lg px-8 py-4 font-semibold text-white shadow-lg transition-all hover:scale-105 hover:shadow-xl {content.ctaStyle ||
|
||||
'bg-theme-primary hover:bg-theme-primary-hover'}"
|
||||
>
|
||||
{content.ctaText}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
subheadline: m.hero_control_subheadline(),
|
||||
ctaText: m.hero_control_cta(),
|
||||
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
// Variant A - Value Focused
|
||||
|
|
@ -44,7 +44,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaText: m.hero_a1_cta(),
|
||||
ctaStyle: 'bg-blue-600 hover:bg-blue-700',
|
||||
features: [m.hero_a1_feature_1(), m.hero_a1_feature_2(), m.hero_a1_feature_3()],
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'a2':
|
||||
|
|
@ -57,7 +57,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaStyle:
|
||||
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
|
||||
features: ['3 hours saved weekly', '75% faster workflows', 'ROI in 2 weeks'],
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'a3':
|
||||
|
|
@ -69,7 +69,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaText: 'Unlock Link Power →',
|
||||
ctaStyle: 'bg-black hover:bg-gray-800',
|
||||
features: ['10x more clicks', 'Conversion tracking', 'Smart redirects'],
|
||||
layout: 'centered'
|
||||
layout: 'centered',
|
||||
};
|
||||
|
||||
// Variant B - Social Proof
|
||||
|
|
@ -83,9 +83,9 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaStyle: 'bg-purple-600 hover:bg-purple-700',
|
||||
socialProof: {
|
||||
type: 'numbers',
|
||||
content: m.hero_b1_social()
|
||||
content: m.hero_b1_social(),
|
||||
},
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'b2':
|
||||
|
|
@ -99,9 +99,9 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700',
|
||||
socialProof: {
|
||||
type: 'logos',
|
||||
content: 'Google • Meta • Microsoft • Spotify • Netflix'
|
||||
content: 'Google • Meta • Microsoft • Spotify • Netflix',
|
||||
},
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'b3':
|
||||
|
|
@ -114,9 +114,9 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaStyle: 'bg-green-600 hover:bg-green-700',
|
||||
socialProof: {
|
||||
type: 'testimonial',
|
||||
content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews'
|
||||
content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews',
|
||||
},
|
||||
layout: 'centered'
|
||||
layout: 'centered',
|
||||
};
|
||||
|
||||
// Variant C - Feature Focused
|
||||
|
|
@ -134,9 +134,9 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
m.hero_c1_feature_3(),
|
||||
m.hero_c1_feature_4(),
|
||||
m.hero_c1_feature_5(),
|
||||
m.hero_c1_feature_6()
|
||||
m.hero_c1_feature_6(),
|
||||
],
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
case 'c2':
|
||||
|
|
@ -148,7 +148,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
ctaText: 'Create Your First QR Code',
|
||||
ctaStyle: 'bg-orange-600 hover:bg-orange-700',
|
||||
features: ['Dynamic QR codes', 'Custom designs', 'Scan analytics', 'Bulk generation'],
|
||||
layout: 'split'
|
||||
layout: 'split',
|
||||
};
|
||||
|
||||
case 'c3':
|
||||
|
|
@ -163,9 +163,9 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
'Zapier automation',
|
||||
'Slack notifications',
|
||||
'WordPress plugin',
|
||||
'API & Webhooks'
|
||||
'API & Webhooks',
|
||||
],
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
|
||||
// Default to control
|
||||
|
|
@ -177,7 +177,7 @@ export function getVariantContent(variantId: string): VariantContent {
|
|||
subheadline: m.hero_control_subheadline(),
|
||||
ctaText: m.hero_control_cta(),
|
||||
ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
|
||||
layout: 'standard'
|
||||
layout: 'standard',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ export function getTrustBadges(): Array<{ icon: string; text: string }> {
|
|||
{ icon: '🔒', text: m.hero_trust_badge_1() },
|
||||
{ icon: '🇪🇺', text: m.hero_trust_badge_2() },
|
||||
{ icon: '⚡', text: m.hero_trust_badge_3() },
|
||||
{ icon: '🚀', text: m.hero_trust_badge_4() }
|
||||
{ icon: '🚀', text: m.hero_trust_badge_4() },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export class HashManager {
|
|||
control: 40, // Baseline
|
||||
a1: 20, // Value-focused variant
|
||||
b1: 20, // Social proof variant
|
||||
c1: 20 // Feature-focused variant
|
||||
c1: 20, // Feature-focused variant
|
||||
};
|
||||
|
||||
// Storage key for backup
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ export function clickOutside(node: HTMLElement, callback: () => void) {
|
|||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,16 +11,16 @@ const createMockElement = (width = 44, height = 44) => ({
|
|||
getBoundingClientRect: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }),
|
||||
style: {},
|
||||
appendChild: vi.fn(),
|
||||
remove: vi.fn()
|
||||
remove: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock global objects
|
||||
Object.defineProperty(window, 'navigator', {
|
||||
value: {
|
||||
maxTouchPoints: 0,
|
||||
userAgent: 'Mozilla/5.0 (Test Browser)'
|
||||
userAgent: 'Mozilla/5.0 (Test Browser)',
|
||||
},
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('Touch Utilities', () => {
|
||||
|
|
@ -95,26 +95,25 @@ describe('Touch Actions (Integration)', () => {
|
|||
test('should calculate touch distances correctly', () => {
|
||||
const touch1 = { clientX: 0, clientY: 0 };
|
||||
const touch2 = { clientX: 100, clientY: 100 };
|
||||
|
||||
|
||||
// Math.sqrt(100^2 + 100^2) = Math.sqrt(20000) ≈ 141.42
|
||||
const expectedDistance = Math.sqrt(20000);
|
||||
const actualDistance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2)
|
||||
);
|
||||
|
||||
|
||||
expect(actualDistance).toBeCloseTo(expectedDistance, 2);
|
||||
});
|
||||
|
||||
test('should detect horizontal swipes', () => {
|
||||
const startTouch = { clientX: 0, clientY: 100 };
|
||||
const endTouch = { clientX: 100, clientY: 100 };
|
||||
|
||||
|
||||
const deltaX = endTouch.clientX - startTouch.clientX;
|
||||
const deltaY = endTouch.clientY - startTouch.clientY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
|
||||
// Horizontal swipe: |deltaX| > |deltaY|
|
||||
expect(absDeltaX).toBeGreaterThan(absDeltaY);
|
||||
expect(deltaX).toBeGreaterThan(0); // Right swipe
|
||||
|
|
@ -123,12 +122,12 @@ describe('Touch Actions (Integration)', () => {
|
|||
test('should detect vertical swipes', () => {
|
||||
const startTouch = { clientX: 100, clientY: 0 };
|
||||
const endTouch = { clientX: 100, clientY: 100 };
|
||||
|
||||
|
||||
const deltaX = endTouch.clientX - startTouch.clientX;
|
||||
const deltaY = endTouch.clientY - startTouch.clientY;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
|
||||
// Vertical swipe: |deltaY| > |deltaX|
|
||||
expect(absDeltaY).toBeGreaterThan(absDeltaX);
|
||||
expect(deltaY).toBeGreaterThan(0); // Down swipe
|
||||
|
|
@ -142,7 +141,7 @@ describe('Touch Actions (Integration)', () => {
|
|||
{ width: 48, height: 48, expected: true },
|
||||
{ width: 40, height: 40, expected: false },
|
||||
{ width: 44, height: 40, expected: false },
|
||||
{ width: 40, height: 44, expected: false }
|
||||
{ width: 40, height: 44, expected: false },
|
||||
];
|
||||
|
||||
sizes.forEach(({ width, height, expected }) => {
|
||||
|
|
@ -158,22 +157,22 @@ describe('Touch Actions (Integration)', () => {
|
|||
const events = Array.from({ length: 100 }, (_, i) => ({
|
||||
clientX: i,
|
||||
clientY: i,
|
||||
timestamp: Date.now() + i
|
||||
timestamp: Date.now() + i,
|
||||
}));
|
||||
|
||||
// In einer echten Implementation würden wir Throttling/Debouncing testen
|
||||
expect(events).toHaveLength(100);
|
||||
|
||||
|
||||
// Teste dass Events innerhalb vernünftiger Zeit verarbeitet werden können
|
||||
const startTime = Date.now();
|
||||
events.forEach(event => {
|
||||
events.forEach((event) => {
|
||||
// Simuliere Event-Verarbeitung
|
||||
const deltaX = event.clientX;
|
||||
const deltaY = event.clientY;
|
||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(100); // Sollte sehr schnell sein
|
||||
});
|
||||
});
|
||||
|
|
@ -182,11 +181,11 @@ describe('Touch Actions (Integration)', () => {
|
|||
test('should maintain focus accessibility', () => {
|
||||
// Touch-Actions sollten Keyboard-Navigation nicht beeinträchtigen
|
||||
const element = createMockElement();
|
||||
|
||||
|
||||
// Simuliere dass Element fokussierbar bleibt
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute = vi.fn();
|
||||
|
||||
|
||||
expect(element.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -195,9 +194,9 @@ describe('Touch Actions (Integration)', () => {
|
|||
const element = createMockElement();
|
||||
element.getAttribute = vi.fn().mockReturnValue('button');
|
||||
element.textContent = 'Touch Button';
|
||||
|
||||
|
||||
expect(element.getAttribute('role')).toBe('button');
|
||||
expect(element.textContent).toBe('Touch Button');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const ripple: Action<HTMLElement, { color?: string; duration?: number }>
|
|||
// Erstelle neuen Ripple
|
||||
rippleElement = document.createElement('div');
|
||||
const rect = node.getBoundingClientRect();
|
||||
|
||||
|
||||
// Berechne Position des Touches/Clicks
|
||||
let clientX: number, clientY: number;
|
||||
if (event instanceof TouchEvent && event.touches.length > 0) {
|
||||
|
|
@ -50,7 +50,7 @@ export const ripple: Action<HTMLElement, { color?: string; duration?: number }>
|
|||
pointerEvents: 'none',
|
||||
transform: 'scale(0)',
|
||||
transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`,
|
||||
zIndex: '1000'
|
||||
zIndex: '1000',
|
||||
});
|
||||
|
||||
// Stelle sicher, dass das Parent-Element relative Position hat
|
||||
|
|
@ -95,7 +95,7 @@ export const ripple: Action<HTMLElement, { color?: string; duration?: number }>
|
|||
if (rippleElement) {
|
||||
rippleElement.remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ export const swipe: Action<HTMLElement, SwipeOptions> = (node, options = {}) =>
|
|||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
onSwipeUp,
|
||||
onSwipeDown
|
||||
onSwipeDown,
|
||||
} = options;
|
||||
|
||||
let startX: number;
|
||||
|
|
@ -176,7 +176,7 @@ export const swipe: Action<HTMLElement, SwipeOptions> = (node, options = {}) =>
|
|||
destroy() {
|
||||
node.removeEventListener('touchstart', handleTouchStart);
|
||||
node.removeEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -226,7 +226,7 @@ export const longPress: Action<HTMLElement, LongPressOptions> = (node, options =
|
|||
node.removeEventListener('pointerup', cancelLongPress);
|
||||
node.removeEventListener('pointercancel', cancelLongPress);
|
||||
node.removeEventListener('pointermove', cancelLongPress);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -326,7 +326,7 @@ export const touchDrag: Action<HTMLElement, TouchDragOptions> = (node, options =
|
|||
node.removeEventListener('pointermove', handleMove);
|
||||
node.removeEventListener('pointerup', handleEnd);
|
||||
node.removeEventListener('pointercancel', handleEnd);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -340,4 +340,4 @@ export function isOptimalTouchTarget(element: HTMLElement): boolean {
|
|||
const rect = element.getBoundingClientRect();
|
||||
const minSize = 44; // 44px ist die empfohlene Mindestgröße für Touch-Targets
|
||||
return rect.width >= minSize && rect.height >= minSize;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const EVENTS = {
|
|||
|
||||
// Error events
|
||||
ERROR_OCCURRED: 'error-occurred',
|
||||
RATE_LIMITED: 'rate-limited'
|
||||
RATE_LIMITED: 'rate-limited',
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
|
||||
|
|
@ -95,7 +95,7 @@ export function trackLinkClick(linkData: {
|
|||
short_code: linkData.shortCode,
|
||||
username: linkData.username,
|
||||
has_password: linkData.hasPassword || false,
|
||||
is_expiring: linkData.isExpiring || false
|
||||
is_expiring: linkData.isExpiring || false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ export function trackLinkCreated(linkData: {
|
|||
short_code: linkData.shortCode,
|
||||
has_password: linkData.hasPassword || false,
|
||||
has_expiry: linkData.hasExpiry || false,
|
||||
has_click_limit: linkData.hasClickLimit || false
|
||||
has_click_limit: linkData.hasClickLimit || false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ export function trackAuth(type: 'signup' | 'login' | 'logout', method?: string):
|
|||
const eventMap = {
|
||||
signup: EVENTS.USER_SIGNUP,
|
||||
login: EVENTS.USER_LOGIN,
|
||||
logout: EVENTS.USER_LOGOUT
|
||||
logout: EVENTS.USER_LOGOUT,
|
||||
};
|
||||
|
||||
trackEvent(eventMap[type], method ? { method } : undefined);
|
||||
|
|
@ -140,6 +140,6 @@ export function trackError(error: {
|
|||
trackEvent(EVENTS.ERROR_OCCURRED, {
|
||||
error_type: error.type,
|
||||
error_message: error.message || 'Unknown error',
|
||||
error_code: error.code || 'unknown'
|
||||
error_code: error.code || 'unknown',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export async function registerUser(data: RegisterData): Promise<RegisterResult>
|
|||
password: data.password,
|
||||
passwordConfirm: data.passwordConfirm,
|
||||
username,
|
||||
emailVisibility: true
|
||||
emailVisibility: true,
|
||||
};
|
||||
|
||||
console.log('Creating user with minimal data:', { email, username });
|
||||
|
|
@ -72,7 +72,7 @@ export async function registerUser(data: RegisterData): Promise<RegisterResult>
|
|||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser
|
||||
user: newUser,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
|
|
@ -111,7 +111,7 @@ export async function registerUser(data: RegisterData): Promise<RegisterResult>
|
|||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Registration system error. Please try again later or contact support.'
|
||||
error: 'Registration system error. Please try again later or contact support.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ export async function registerUser(data: RegisterData): Promise<RegisterResult>
|
|||
// Generic error
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || 'Registration failed. Please try again.'
|
||||
error: error?.message || 'Registration failed. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ export async function loginUser(email: string, password: string) {
|
|||
console.error('Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid email or password'
|
||||
error: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ describe('Cache System', () => {
|
|||
const shortTTL = 10; // 10ms
|
||||
|
||||
cache.set(key, value, shortTTL);
|
||||
|
||||
|
||||
// Should be available immediately
|
||||
expect(cache.get(key)).toBe(value);
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// Should be null after expiration
|
||||
expect(cache.get(key)).toBeNull();
|
||||
});
|
||||
|
|
@ -82,12 +82,12 @@ describe('Cache System', () => {
|
|||
describe('Cache Cleanup', () => {
|
||||
test('should cleanup expired entries', async () => {
|
||||
const shortTTL = 10; // 10ms
|
||||
|
||||
|
||||
cache.set('key1', 'value1', shortTTL);
|
||||
cache.set('key2', 'value2', 60000); // 1 minute
|
||||
|
||||
// Wait for first key to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
cache.cleanup();
|
||||
|
||||
|
|
@ -120,10 +120,10 @@ describe('Cache System', () => {
|
|||
const arrayValue = [1, 2, 3, 'test'];
|
||||
|
||||
const objectKey = 'object-test';
|
||||
const objectValue = {
|
||||
nested: { deep: true },
|
||||
const objectValue = {
|
||||
nested: { deep: true },
|
||||
array: [1, 2, 3],
|
||||
date: new Date().toISOString()
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
|
||||
cache.set(arrayKey, arrayValue);
|
||||
|
|
@ -155,7 +155,7 @@ describe('Cache System', () => {
|
|||
|
||||
test('should handle concurrent access', () => {
|
||||
const key = 'concurrent-test';
|
||||
|
||||
|
||||
// Simulate concurrent writes
|
||||
cache.set(key, 'value1');
|
||||
cache.set(key, 'value2');
|
||||
|
|
@ -203,17 +203,17 @@ describe('Cache System', () => {
|
|||
nested: {
|
||||
deep: {
|
||||
very: {
|
||||
deep: 'value'
|
||||
}
|
||||
}
|
||||
}
|
||||
deep: 'value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const key = 'large-value-test';
|
||||
cache.set(key, largeValue);
|
||||
|
||||
|
||||
const result = cache.get(key);
|
||||
expect(result).toEqual(largeValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,10 +60,7 @@ export function cacheKey(...parts: (string | number)[]): string {
|
|||
}
|
||||
|
||||
// Cache-Decorator für async Funktionen
|
||||
export function cached<T>(
|
||||
keyGenerator: (...args: any[]) => string,
|
||||
ttlMs: number = 5 * 60 * 1000
|
||||
) {
|
||||
export function cached<T>(keyGenerator: (...args: any[]) => string, ttlMs: number = 5 * 60 * 1000) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
|
|
@ -92,5 +89,5 @@ export const CacheKeys = {
|
|||
linkRedirect: (shortCode: string) => cacheKey('redirect', shortCode),
|
||||
analyticsDaily: (linkId: string, date: string) => cacheKey('analytics', linkId, date),
|
||||
userCards: (userId: string) => cacheKey('user', userId, 'cards'),
|
||||
publicCard: (username: string, cardId: string) => cacheKey('public', username, cardId)
|
||||
} as const;
|
||||
publicCard: (username: string, cardId: string) => cacheKey('public', username, cardId),
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
let showDropdown = $state(false);
|
||||
let accounts = $derived($accountsStore);
|
||||
let currentAccount = $derived($currentViewingAccount);
|
||||
|
||||
|
||||
function toggleDropdown() {
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
if (!account) return 'Unknown';
|
||||
return account.name || account.username || account.email;
|
||||
}
|
||||
|
||||
|
||||
function addAccount() {
|
||||
showDropdown = false;
|
||||
// Navigate to login page for adding existing account
|
||||
|
|
@ -59,20 +59,32 @@
|
|||
<span class="max-w-[150px] truncate">
|
||||
{getAccountDisplayName(currentAccount)}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown ? 'rotate-180' : ''}" />
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{:else if accounts.currentUser}
|
||||
<User class="h-4 w-4 text-theme-text-muted" />
|
||||
<span class="max-w-[150px] truncate">
|
||||
{getAccountDisplayName(accounts.currentUser)}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown ? 'rotate-180' : ''}" />
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
class="absolute z-50 {position === 'left-outside' ? 'left-0 top-full mt-2' : 'right-0 mt-2'} w-72 {position === 'left-outside' ? 'origin-top-left' : 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
class="absolute z-50 {position === 'left-outside'
|
||||
? 'left-0 top-full mt-2'
|
||||
: 'right-0 mt-2'} w-72 {position === 'left-outside'
|
||||
? 'origin-top-left'
|
||||
: 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
>
|
||||
<!-- Personal Account Section -->
|
||||
{#if accounts.currentUser}
|
||||
|
|
@ -85,7 +97,7 @@
|
|||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<User class="h-5 w-5 text-theme-text-muted" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getAccountDisplayName(accounts.currentUser)}
|
||||
</div>
|
||||
|
|
@ -99,7 +111,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- Team Accounts Section -->
|
||||
{#if accounts.sharedAccounts && accounts.sharedAccounts.length > 0}
|
||||
<div class="border-b border-theme-border p-2">
|
||||
|
|
@ -113,7 +125,7 @@
|
|||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<Users class="h-5 w-5 text-purple-500" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getAccountDisplayName(shared.expand.owner)}
|
||||
</div>
|
||||
|
|
@ -121,7 +133,9 @@
|
|||
<span class="text-theme-text-muted">
|
||||
@{shared.expand.owner.username}
|
||||
</span>
|
||||
<span class="rounded-full bg-purple-100 dark:bg-purple-900/20 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-900/20 dark:text-purple-400"
|
||||
>
|
||||
{m.account_team_member()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -144,24 +158,23 @@
|
|||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- Add Account Button -->
|
||||
<div class="border-t border-theme-border p-2">
|
||||
<button
|
||||
onclick={addAccount}
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all hover:bg-theme-primary/10"
|
||||
class="hover:bg-theme-primary/10 flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary/10">
|
||||
<div class="bg-theme-primary/10 flex h-5 w-5 items-center justify-center rounded-full">
|
||||
<UserPlus class="h-3.5 w-3.5 text-theme-primary" />
|
||||
</div>
|
||||
<span class="text-theme-text">{m.account_add_account()}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
|
|
@ -18,32 +18,31 @@
|
|||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
secondary: 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover',
|
||||
ghost: 'bg-transparent text-theme-text hover:bg-theme-surface',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700'
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
};
|
||||
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-2 text-base'
|
||||
lg: 'px-4 py-2 text-base',
|
||||
};
|
||||
|
||||
const classes = $derived(`
|
||||
|
||||
const classes = $derived(
|
||||
`
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
rounded-lg transition-colors
|
||||
${className}
|
||||
`.trim());
|
||||
`.trim()
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={classes}
|
||||
{...restProps}
|
||||
>
|
||||
<button class={classes} {...restProps}>
|
||||
{@render children()}
|
||||
</button>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
tabletBreakpoint = 1024,
|
||||
emptyMessage = 'No items found',
|
||||
children,
|
||||
mobileCard
|
||||
mobileCard,
|
||||
}: Props = $props();
|
||||
|
||||
let windowWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1200);
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
// Filter columns based on screen size
|
||||
let visibleColumns = $derived(
|
||||
columns.filter(col => {
|
||||
columns.filter((col) => {
|
||||
if (isMobile && col.hideOnMobile) return false;
|
||||
if (isTablet && col.hideOnTablet) return false;
|
||||
return true;
|
||||
|
|
@ -50,13 +50,13 @@
|
|||
// Generate grid template columns
|
||||
let gridTemplate = $derived(() => {
|
||||
if (isMobile) return 'grid-cols-1';
|
||||
|
||||
const widths = visibleColumns.map(col => {
|
||||
|
||||
const widths = visibleColumns.map((col) => {
|
||||
if (col.width === 'flex') return '1fr';
|
||||
if (col.width) return col.width;
|
||||
return 'auto';
|
||||
});
|
||||
|
||||
|
||||
// For Tailwind, we need to use predefined classes or inline styles
|
||||
return widths.join(' ');
|
||||
});
|
||||
|
|
@ -72,15 +72,18 @@
|
|||
|
||||
function getAlignment(align?: string) {
|
||||
switch (align) {
|
||||
case 'center': return 'text-center justify-center';
|
||||
case 'right': return 'text-right justify-end';
|
||||
default: return 'text-left justify-start';
|
||||
case 'center':
|
||||
return 'text-center justify-center';
|
||||
case 'right':
|
||||
return 'text-right justify-end';
|
||||
default:
|
||||
return 'text-left justify-start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items && items.length > 0}
|
||||
<div class="rounded-xl border border-theme-border bg-theme-surface shadow-xl overflow-hidden">
|
||||
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
|
||||
{#if title}
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-6 py-4">
|
||||
<h2 class="text-xl font-semibold text-theme-text">
|
||||
|
|
@ -99,9 +102,11 @@
|
|||
{:else}
|
||||
<!-- Desktop/Tablet Table View -->
|
||||
<!-- Table Header -->
|
||||
<div
|
||||
class="hidden md:grid items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
|
||||
style="grid-template-columns: {visibleColumns.map(col => col.width === 'flex' ? '1fr' : (col.width || 'auto')).join(' ')}"
|
||||
<div
|
||||
class="hidden items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text md:grid"
|
||||
style="grid-template-columns: {visibleColumns
|
||||
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
|
||||
.join(' ')}"
|
||||
>
|
||||
{#each visibleColumns as column}
|
||||
<div class={getAlignment(column.align)}>
|
||||
|
|
@ -114,9 +119,11 @@
|
|||
<div class="divide-y divide-theme-border">
|
||||
{#each items as item}
|
||||
<!-- Desktop Row -->
|
||||
<div
|
||||
class="hidden md:grid items-center gap-4 px-6 py-4 transition-colors hover:bg-theme-surface-hover"
|
||||
style="grid-template-columns: {visibleColumns.map(col => col.width === 'flex' ? '1fr' : (col.width || 'auto')).join(' ')}"
|
||||
<div
|
||||
class="hidden items-center gap-4 px-6 py-4 transition-colors hover:bg-theme-surface-hover md:grid"
|
||||
style="grid-template-columns: {visibleColumns
|
||||
.map((col) => (col.width === 'flex' ? '1fr' : col.width || 'auto'))
|
||||
.join(' ')}"
|
||||
>
|
||||
{#each visibleColumns as column}
|
||||
<div class={getAlignment(column.align)}>
|
||||
|
|
@ -135,14 +142,16 @@
|
|||
</div>
|
||||
|
||||
<!-- Mobile Card -->
|
||||
<div class="md:hidden p-4 space-y-3 bg-theme-surface hover:bg-theme-surface-hover transition-colors">
|
||||
<div
|
||||
class="space-y-3 bg-theme-surface p-4 transition-colors hover:bg-theme-surface-hover md:hidden"
|
||||
>
|
||||
{#if renderMobileCard}
|
||||
{@html renderMobileCard(item)}
|
||||
{:else}
|
||||
<!-- Default mobile layout -->
|
||||
<div class="space-y-2">
|
||||
{#each columns.filter(col => !col.hideOnMobile) as column}
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
{#each columns.filter((col) => !col.hideOnMobile) as column}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-theme-text-muted">{column.label}:</span>
|
||||
<span class="text-theme-text">
|
||||
{#if column.render}
|
||||
|
|
@ -174,4 +183,4 @@
|
|||
|
||||
<style>
|
||||
/* Add any custom styles if needed */
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
variant = 'secondary',
|
||||
size = 'md',
|
||||
position = 'right',
|
||||
class: className = ''
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
const rect = buttonRef.getBoundingClientRect();
|
||||
dropdownPosition = {
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: position === 'left' ? rect.left + window.scrollX : rect.right + window.scrollX - 192
|
||||
left: position === 'left' ? rect.left + window.scrollX : rect.right + window.scrollX - 192,
|
||||
};
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
|
|
@ -60,8 +60,7 @@
|
|||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
// Check if click is outside both the dropdown container and the menu
|
||||
if (dropdownRef && !dropdownRef.contains(target) &&
|
||||
menuRef && !menuRef.contains(target)) {
|
||||
if (dropdownRef && !dropdownRef.contains(target) && menuRef && !menuRef.contains(target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
|
@ -74,17 +73,25 @@
|
|||
}
|
||||
|
||||
function getItemClasses(color?: string) {
|
||||
const baseClasses = 'flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors';
|
||||
const baseClasses =
|
||||
'flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors';
|
||||
if (!color) return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
|
||||
|
||||
switch(color) {
|
||||
case '#dc2626': return `${baseClasses} text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20`;
|
||||
case '#ea580c': return `${baseClasses} text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20`;
|
||||
case '#16a34a': return `${baseClasses} text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20`;
|
||||
case '#2563eb': return `${baseClasses} text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20`;
|
||||
case '#9333ea': return `${baseClasses} text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20`;
|
||||
case '#4f46e5': return `${baseClasses} text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20`;
|
||||
default: return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
|
||||
|
||||
switch (color) {
|
||||
case '#dc2626':
|
||||
return `${baseClasses} text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20`;
|
||||
case '#ea580c':
|
||||
return `${baseClasses} text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20`;
|
||||
case '#16a34a':
|
||||
return `${baseClasses} text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20`;
|
||||
case '#2563eb':
|
||||
return `${baseClasses} text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20`;
|
||||
case '#9333ea':
|
||||
return `${baseClasses} text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20`;
|
||||
case '#4f46e5':
|
||||
return `${baseClasses} text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20`;
|
||||
default:
|
||||
return `${baseClasses} text-theme-text hover:bg-theme-surface-hover`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,18 +105,19 @@
|
|||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-3 py-2 text-base',
|
||||
lg: 'px-4 py-3 text-lg'
|
||||
lg: 'px-4 py-3 text-lg',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-theme-primary text-white hover:bg-theme-primary-hover',
|
||||
secondary: 'bg-theme-surface border border-theme-border text-theme-text hover:bg-theme-surface-hover',
|
||||
ghost: 'text-theme-text hover:bg-theme-surface-hover'
|
||||
secondary:
|
||||
'bg-theme-surface border border-theme-border text-theme-text hover:bg-theme-surface-hover',
|
||||
ghost: 'text-theme-text hover:bg-theme-surface-hover',
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
left: 'left-0',
|
||||
right: 'right-0'
|
||||
right: 'right-0',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -117,7 +125,9 @@
|
|||
<button
|
||||
bind:this={buttonRef}
|
||||
onclick={toggleDropdown}
|
||||
class="inline-flex items-center gap-2 rounded-lg font-medium transition-colors {sizeClasses[size]} {variantClasses[variant]}"
|
||||
class="inline-flex items-center gap-2 rounded-lg font-medium transition-colors {sizeClasses[
|
||||
size
|
||||
]} {variantClasses[variant]}"
|
||||
type="button"
|
||||
>
|
||||
{#if buttonIcon}
|
||||
|
|
@ -139,25 +149,23 @@
|
|||
{#if item.divider}
|
||||
<div class="border-t border-theme-border"></div>
|
||||
{:else if item.type === 'form'}
|
||||
<form
|
||||
method={item.formMethod || 'POST'}
|
||||
<form
|
||||
method={item.formMethod || 'POST'}
|
||||
action={item.formAction}
|
||||
use:enhance={item.enhanceOptions || (() => {
|
||||
return async ({ update }) => {
|
||||
closeDropdown();
|
||||
await update();
|
||||
};
|
||||
})}
|
||||
use:enhance={item.enhanceOptions ||
|
||||
(() => {
|
||||
return async ({ update }) => {
|
||||
closeDropdown();
|
||||
await update();
|
||||
};
|
||||
})}
|
||||
>
|
||||
{#if item.formData}
|
||||
{#each Object.entries(item.formData) as [name, value]}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/each}
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class={getItemClasses(item.color)}
|
||||
>
|
||||
<button type="submit" class={getItemClasses(item.color)}>
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{/if}
|
||||
|
|
@ -165,11 +173,7 @@
|
|||
</button>
|
||||
</form>
|
||||
{:else if item.href}
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={() => closeDropdown()}
|
||||
class={getItemClasses(item.color)}
|
||||
>
|
||||
<a href={item.href} onclick={() => closeDropdown()} class={getItemClasses(item.color)}>
|
||||
{#if item.icon}
|
||||
{@html item.icon}
|
||||
{/if}
|
||||
|
|
@ -190,4 +194,4 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,26 +19,26 @@
|
|||
let collapsed = $state(false);
|
||||
let mounted = $state(false);
|
||||
let showThemeDropdown = $state(false);
|
||||
|
||||
|
||||
// Subscribe to workspace stores for reactive URL updates
|
||||
let currentWorkspaceId = $state(activeWorkspace.getId());
|
||||
let currentWorkspaceData = $state(activeWorkspace.getData());
|
||||
|
||||
|
||||
// Subscribe to changes
|
||||
$effect(() => {
|
||||
const unsubId = activeWorkspace.id.subscribe(id => {
|
||||
const unsubId = activeWorkspace.id.subscribe((id) => {
|
||||
currentWorkspaceId = id;
|
||||
});
|
||||
const unsubData = activeWorkspace.data.subscribe(data => {
|
||||
const unsubData = activeWorkspace.data.subscribe((data) => {
|
||||
currentWorkspaceData = data;
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
unsubId();
|
||||
unsubData();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Reactive URL builder
|
||||
function buildUrl(path: string): string {
|
||||
if (currentWorkspaceId && !path.includes('workspace=')) {
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
let themeDropdownElement: HTMLDivElement;
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
{#if user && mounted}
|
||||
<aside
|
||||
class="sidebar-transition animate-slide-in fixed top-4 bottom-4 left-4 z-40 hidden flex-col lg:flex"
|
||||
class="sidebar-transition animate-slide-in fixed bottom-4 left-4 top-4 z-40 hidden flex-col lg:flex"
|
||||
class:w-64={!collapsed}
|
||||
class:w-20={collapsed}
|
||||
>
|
||||
|
|
@ -128,7 +128,13 @@
|
|||
<!-- Content Container -->
|
||||
<div class="relative flex h-full flex-col p-4">
|
||||
<!-- Logo Section -->
|
||||
<div class="mb-8" class:flex={!collapsed} class:flex-col={collapsed} class:items-center={collapsed} class:justify-between={!collapsed}>
|
||||
<div
|
||||
class="mb-8"
|
||||
class:flex={!collapsed}
|
||||
class:flex-col={collapsed}
|
||||
class:items-center={collapsed}
|
||||
class:justify-between={!collapsed}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 transition-opacity hover:opacity-80"
|
||||
|
|
@ -182,7 +188,9 @@
|
|||
{#if !collapsed}
|
||||
<div class="mb-6 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-theme-text-muted uppercase tracking-wider">Benachrichtigungen</span>
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-theme-text-muted"
|
||||
>Benachrichtigungen</span
|
||||
>
|
||||
<NotificationBell position="left-outside" />
|
||||
</div>
|
||||
<WorkspaceSwitcher position="left-outside" />
|
||||
|
|
@ -306,11 +314,10 @@
|
|||
<span class="text-transition font-medium text-theme-text">Templates</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto space-y-2 border-t border-theme-border/30 pt-4">
|
||||
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
|
||||
<!-- Theme Toggle -->
|
||||
<div class="relative" bind:this={themeDropdownElement}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold tracking-wider text-theme-text uppercase">
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
|
||||
Navigation
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
|
||||
<!-- Legal -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-sm font-semibold tracking-wider text-theme-text uppercase">
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-theme-text">
|
||||
Rechtliches
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' }
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
];
|
||||
|
||||
let currentLanguage = $state(languages[0]);
|
||||
|
|
|
|||
|
|
@ -5,34 +5,43 @@
|
|||
let { user } = $props();
|
||||
|
||||
let usageInfo = $derived(getLimitDisplayInfo(user));
|
||||
|
||||
|
||||
let barColor = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger': return 'bg-red-500';
|
||||
case 'warning': return 'bg-yellow-500';
|
||||
default: return 'bg-blue-500';
|
||||
case 'danger':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
});
|
||||
|
||||
let textColor = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger': return 'text-red-700';
|
||||
case 'warning': return 'text-yellow-700';
|
||||
default: return 'text-blue-700';
|
||||
case 'danger':
|
||||
return 'text-red-700';
|
||||
case 'warning':
|
||||
return 'text-yellow-700';
|
||||
default:
|
||||
return 'text-blue-700';
|
||||
}
|
||||
});
|
||||
|
||||
let icon = $derived(() => {
|
||||
switch (usageInfo.status) {
|
||||
case 'danger': return X;
|
||||
case 'warning': return AlertTriangle;
|
||||
default: return Check;
|
||||
case 'danger':
|
||||
return X;
|
||||
case 'warning':
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return Check;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svelte:component this={icon} class="h-4 w-4 {textColor}" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
|
|
@ -52,9 +61,9 @@
|
|||
|
||||
{#if !usageInfo.unlimited}
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300 {barColor}"
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300 {barColor}"
|
||||
style="width: {Math.min(usageInfo.percentage, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
|
|
@ -62,11 +71,11 @@
|
|||
<!-- Status Messages -->
|
||||
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{#if usageInfo.status === 'danger'}
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">
|
||||
<span class="font-medium text-red-600 dark:text-red-400">
|
||||
Monatslimit erreicht! Upgrade für mehr Links.
|
||||
</span>
|
||||
{:else if usageInfo.status === 'warning'}
|
||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
<span class="font-medium text-yellow-600 dark:text-yellow-400">
|
||||
{usageInfo.limit - usageInfo.current} Links verbleibend
|
||||
</span>
|
||||
{:else}
|
||||
|
|
@ -76,8 +85,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
<div class="text-xs font-medium text-green-600 dark:text-green-400">
|
||||
🎉 Du hast unbegrenzten Zugang zu allen Features!
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="slide-in fixed top-0 bottom-0 left-0 z-50 w-72 bg-theme-surface shadow-2xl lg:hidden"
|
||||
class="slide-in fixed bottom-0 left-0 top-0 z-50 w-72 bg-theme-surface shadow-2xl lg:hidden"
|
||||
>
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<!-- Header -->
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
<span class="font-medium">Templates</span>
|
||||
</a>
|
||||
|
||||
<div class="my-2 border-t border-theme-border/30"></div>
|
||||
<div class="border-theme-border/30 my-2 border-t"></div>
|
||||
|
||||
<a
|
||||
href="/pricing"
|
||||
|
|
@ -218,7 +218,7 @@
|
|||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto space-y-2 border-t border-theme-border/30 pt-4">
|
||||
<div class="border-theme-border/30 mt-auto space-y-2 border-t pt-4">
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={handleLinkClick}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav
|
||||
class="sticky top-0 z-50 hidden border-b border-theme-border bg-theme-surface/80 shadow-sm backdrop-blur-xl md:block"
|
||||
class="bg-theme-surface/80 sticky top-0 z-50 hidden border-b border-theme-border shadow-sm backdrop-blur-xl md:block"
|
||||
>
|
||||
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Desktop Navigation - Absolutely Centered -->
|
||||
<div class="absolute top-1/2 left-1/2 hidden -translate-x-1/2 -translate-y-1/2 xl:flex">
|
||||
<div class="absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 xl:flex">
|
||||
<div class="flex items-center gap-6">
|
||||
{#if user}
|
||||
<a
|
||||
|
|
@ -289,12 +289,12 @@
|
|||
|
||||
<!-- Main navigation content -->
|
||||
<div
|
||||
class="relative z-20 flex overflow-hidden rounded-full border-2 border-theme-border/20 bg-theme-surface/95 shadow-2xl backdrop-blur-xl transition-all duration-300 before:pointer-events-none before:absolute before:inset-0 before:rounded-full before:bg-gradient-to-t before:from-black/20 before:to-transparent"
|
||||
class="border-theme-border/20 bg-theme-surface/95 relative z-20 flex overflow-hidden rounded-full border-2 shadow-2xl backdrop-blur-xl transition-all duration-300 before:pointer-events-none before:absolute before:inset-0 before:rounded-full before:bg-gradient-to-t before:from-black/20 before:to-transparent"
|
||||
>
|
||||
<!-- Left Half: Logo -->
|
||||
<a
|
||||
href="/"
|
||||
class="relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors hover:bg-theme-surface-hover/50"
|
||||
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
|
||||
>
|
||||
<svg class="h-6 w-6 text-theme-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -308,12 +308,12 @@
|
|||
</a>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative z-10 w-px bg-theme-border/30"></div>
|
||||
<div class="bg-theme-border/30 relative z-10 w-px"></div>
|
||||
|
||||
<!-- Right Half: Menu -->
|
||||
<button
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors hover:bg-theme-surface-hover/50"
|
||||
class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
|
||||
aria-label="Menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
|
|
@ -342,7 +342,7 @@
|
|||
<!-- Mobile Menu Backdrop -->
|
||||
{#if mobileMenuOpen}
|
||||
<button
|
||||
class="fixed inset-0 z-35 bg-black/40 md:hidden"
|
||||
class="z-35 fixed inset-0 bg-black/40 md:hidden"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (mobileMenuOpen = false)}
|
||||
aria-label="Close mobile menu"
|
||||
|
|
@ -353,16 +353,16 @@
|
|||
<!-- Mobile Menu - Dropdown from bottom on mobile, from top on tablet/desktop -->
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="animate-slide-up md:animate-slide-down fixed bottom-[80px] left-1/2 z-40 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 px-4 md:top-[65px] md:bottom-auto md:max-w-md"
|
||||
class="animate-slide-up md:animate-slide-down fixed bottom-[80px] left-1/2 z-40 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 px-4 md:bottom-auto md:top-[65px] md:max-w-md"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[60vh] w-full flex-col overflow-hidden rounded-2xl border border-theme-border/30 bg-theme-surface/95 shadow-2xl backdrop-blur-xl"
|
||||
class="border-theme-border/30 bg-theme-surface/95 flex max-h-[60vh] w-full flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if user}
|
||||
<!-- Main Navigation -->
|
||||
<div class="pb-1">
|
||||
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">Navigation</h3>
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
|
||||
<a
|
||||
href="/my/links"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
|
|
@ -533,8 +533,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="pt-2 pb-1">
|
||||
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">Account</h3>
|
||||
<div class="pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Account</h3>
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
|
|
@ -569,7 +569,7 @@
|
|||
Settings
|
||||
</span>
|
||||
</a>
|
||||
|
||||
|
||||
<a
|
||||
href="/settings/team"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
|
|
@ -601,8 +601,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="border-t border-theme-border/30 pt-2 pb-1">
|
||||
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">Preferences</h3>
|
||||
<div class="border-theme-border/30 border-t pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
|
|
@ -645,7 +645,7 @@
|
|||
{:else}
|
||||
<!-- Guest Navigation -->
|
||||
<div class="pb-1">
|
||||
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">Navigation</h3>
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Navigation</h3>
|
||||
<a
|
||||
href="/features"
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
|
|
@ -733,8 +733,8 @@
|
|||
</div>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<div class="border-t border-theme-border/30 pt-2 pb-1">
|
||||
<h3 class="px-3 pt-1 pb-1 text-xs font-normal text-theme-text-muted/50">Preferences</h3>
|
||||
<div class="border-theme-border/30 border-t pb-1 pt-2">
|
||||
<h3 class="text-theme-text-muted/50 px-3 pb-1 pt-1 text-xs font-normal">Preferences</h3>
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
|
|
@ -778,7 +778,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Sticky Buttons at bottom -->
|
||||
<div class="space-y-2 border-t border-theme-border/30 p-3">
|
||||
<div class="border-theme-border/30 space-y-2 border-t p-3">
|
||||
{#if !user}
|
||||
<a
|
||||
href="/login"
|
||||
|
|
|
|||
|
|
@ -6,18 +6,18 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
|
||||
interface Props {
|
||||
position?: 'right' | 'left-outside';
|
||||
}
|
||||
|
||||
|
||||
let { position = 'right' }: Props = $props();
|
||||
let showDropdown = $state(false);
|
||||
|
||||
|
||||
onMount(() => {
|
||||
// Load notifications on mount
|
||||
notifications.load(pb);
|
||||
|
||||
|
||||
// Set up real-time subscription
|
||||
pb.collection('notifications').subscribe('*', (e) => {
|
||||
if (e.action === 'create') {
|
||||
|
|
@ -30,32 +30,32 @@
|
|||
notifications.load(pb);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
pb.collection('notifications').unsubscribe('*');
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function handleClickOutside() {
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
|
||||
async function handleMarkAsRead(notificationId: string) {
|
||||
await notifications.markAsRead(pb, notificationId);
|
||||
}
|
||||
|
||||
|
||||
async function handleMarkAllAsRead() {
|
||||
await notifications.markAllAsRead(pb);
|
||||
}
|
||||
|
||||
|
||||
async function handleDelete(notificationId: string) {
|
||||
await notifications.delete(pb, notificationId);
|
||||
}
|
||||
|
||||
|
||||
async function handleAction(notification: any) {
|
||||
// Mark as read first
|
||||
await handleMarkAsRead(notification.id);
|
||||
|
||||
|
||||
// Navigate to action URL if available
|
||||
if (notification.action_url) {
|
||||
if (notification.action_url.startsWith('http')) {
|
||||
|
|
@ -64,10 +64,10 @@
|
|||
goto(notification.action_url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
|
||||
function getNotificationIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'team_invite':
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
return '🔔';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getNotificationIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'team_invite':
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
return 'text-theme-text-muted bg-theme-primary/10';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function formatTime(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
|
||||
if (minutes < 1) return 'Gerade eben';
|
||||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||
if (hours < 24) return `vor ${hours} Std.`;
|
||||
|
|
@ -121,101 +121,113 @@
|
|||
<div class="relative" use:clickOutside={handleClickOutside}>
|
||||
<!-- Bell Button -->
|
||||
<button
|
||||
onclick={() => showDropdown = !showDropdown}
|
||||
class="relative p-2 text-theme-text-muted hover:text-theme-text transition-colors"
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="relative p-2 text-theme-text-muted transition-colors hover:text-theme-text"
|
||||
aria-label="Benachrichtigungen"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Bell class="h-5 w-5" />
|
||||
{#if $unreadCount > 0}
|
||||
<span class="absolute -top-1 -right-1 bg-theme-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-bold">
|
||||
<span
|
||||
class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary text-xs font-bold text-white"
|
||||
>
|
||||
{$unreadCount > 9 ? '9+' : $unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Dropdown Panel -->
|
||||
{#if showDropdown}
|
||||
<div
|
||||
<div
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
class="absolute {position === 'left-outside' ? 'left-0 top-full mt-2 origin-top-left' : 'right-0 mt-2 origin-top-right'} w-96 max-h-[600px] rounded-lg border border-theme-border bg-theme-surface shadow-xl overflow-hidden z-50">
|
||||
class="absolute {position === 'left-outside'
|
||||
? 'left-0 top-full mt-2 origin-top-left'
|
||||
: 'right-0 mt-2 origin-top-right'} z-50 max-h-[600px] w-96 overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-theme-border p-2">
|
||||
<div class="px-3 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center justify-between px-3 py-2">
|
||||
<h3 class="text-sm font-medium text-theme-text">Benachrichtigungen</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $unreadCount > 0}
|
||||
<button
|
||||
onclick={handleMarkAllAsRead}
|
||||
class="text-xs text-theme-primary hover:text-theme-primary-hover transition-colors"
|
||||
class="text-xs text-theme-primary transition-colors hover:text-theme-primary-hover"
|
||||
>
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => showDropdown = false}
|
||||
class="p-1 rounded-md text-theme-text-muted hover:text-theme-text hover:bg-theme-surface-hover transition-colors"
|
||||
onclick={() => (showDropdown = false)}
|
||||
class="rounded-md p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div class="overflow-y-auto max-h-[500px]">
|
||||
<div class="max-h-[500px] overflow-y-auto">
|
||||
{#if $notifications.loading}
|
||||
<div class="p-8 text-center text-theme-text-muted">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-theme-primary mx-auto"></div>
|
||||
<div
|
||||
class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-theme-primary"
|
||||
></div>
|
||||
<p class="mt-2 text-sm">Lade Benachrichtigungen...</p>
|
||||
</div>
|
||||
{:else if $notifications.notifications.length === 0}
|
||||
<div class="p-8 text-center text-theme-text-muted">
|
||||
<Bell class="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<Bell class="mx-auto mb-3 h-12 w-12 opacity-20" />
|
||||
<p class="text-sm">Keine Benachrichtigungen</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-2">
|
||||
{#each $notifications.notifications as notification, i}
|
||||
<div
|
||||
class="group rounded-md px-3 py-3 mb-1 transition-colors hover:bg-theme-surface-hover {!notification.read ? 'bg-theme-primary/5' : ''}"
|
||||
class="group mb-1 rounded-md px-3 py-3 transition-colors hover:bg-theme-surface-hover {!notification.read
|
||||
? 'bg-theme-primary/5'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full flex-shrink-0 {getNotificationIconColor(notification.type)}">
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full {getNotificationIconColor(
|
||||
notification.type
|
||||
)}"
|
||||
>
|
||||
<span class="text-base">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<button
|
||||
onclick={() => handleAction(notification)}
|
||||
class="flex-1 text-left"
|
||||
>
|
||||
<button onclick={() => handleAction(notification)} class="flex-1 text-left">
|
||||
<p class="text-sm font-medium text-theme-text">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p class="text-xs text-theme-text-muted mt-0.5">
|
||||
<p class="mt-0.5 text-xs text-theme-text-muted">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p class="text-xs text-theme-text-muted mt-1.5">
|
||||
<p class="mt-1.5 text-xs text-theme-text-muted">
|
||||
{formatTime(notification.created)}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{#if !notification.read}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
class="p-1 rounded text-theme-text-muted hover:text-theme-primary hover:bg-theme-surface-hover transition-colors"
|
||||
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-primary"
|
||||
title="Als gelesen markieren"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
|
|
@ -226,21 +238,21 @@
|
|||
e.stopPropagation();
|
||||
handleDelete(notification.id);
|
||||
}}
|
||||
class="p-1 rounded text-theme-text-muted hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
class="rounded p-1 text-theme-text-muted transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if notification.type === 'team_invite' && notification.action_url}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(notification);
|
||||
}}
|
||||
class="mt-2 inline-flex items-center gap-1 px-2.5 py-1 bg-theme-primary/10 text-theme-primary text-xs font-medium rounded-md hover:bg-theme-primary/20 transition-colors"
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-2 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium text-theme-primary transition-colors"
|
||||
>
|
||||
Einladung annehmen
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
|
|
@ -255,4 +267,4 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { User as UserType, SharedAccess } from '$lib/types/accounts';
|
||||
|
||||
|
||||
interface Props {
|
||||
user: UserType | null;
|
||||
sharedAccounts?: SharedAccess[];
|
||||
}
|
||||
|
||||
|
||||
let { user, sharedAccounts = [] }: Props = $props();
|
||||
|
||||
|
||||
let isOpen = $state(false);
|
||||
let currentAccount = $state<string>(user?.id || '');
|
||||
|
||||
|
||||
// Get current viewing context from URL params or session
|
||||
$effect(() => {
|
||||
const viewingAs = $page.url.searchParams.get('viewing_as');
|
||||
|
|
@ -23,13 +23,13 @@
|
|||
currentAccount = user?.id || '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function switchAccount(accountId: string) {
|
||||
if (accountId === currentAccount) {
|
||||
isOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Update URL with viewing context
|
||||
const url = new URL($page.url);
|
||||
if (accountId === user?.id) {
|
||||
|
|
@ -37,25 +37,25 @@
|
|||
} else {
|
||||
url.searchParams.set('viewing_as', accountId);
|
||||
}
|
||||
|
||||
|
||||
await goto(url.toString());
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
|
||||
// Get display name for current account
|
||||
const currentAccountName = $derived(() => {
|
||||
if (currentAccount === user?.id) {
|
||||
return user?.name || user?.username || 'My Account';
|
||||
}
|
||||
|
||||
const shared = sharedAccounts.find(s => s.owner === currentAccount);
|
||||
|
||||
const shared = sharedAccounts.find((s) => s.owner === currentAccount);
|
||||
if (shared?.expand?.owner) {
|
||||
return shared.expand.owner.name || shared.expand.owner.username || shared.expand.owner.email;
|
||||
}
|
||||
|
||||
|
||||
return 'Unknown Account';
|
||||
});
|
||||
|
||||
|
||||
// Check if viewing a shared account
|
||||
const isViewingShared = $derived(currentAccount !== user?.id);
|
||||
</script>
|
||||
|
|
@ -63,8 +63,8 @@
|
|||
{#if user && sharedAccounts.length > 0}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => isOpen = !isOpen}
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-theme-text hover:bg-theme-surface-hover transition-colors"
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isViewingShared}
|
||||
|
|
@ -76,22 +76,21 @@
|
|||
</div>
|
||||
<ChevronDown class="h-4 w-4 {isOpen ? 'rotate-180' : ''} transition-transform" />
|
||||
</button>
|
||||
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<button
|
||||
onclick={() => isOpen = false}
|
||||
class="fixed inset-0 z-40"
|
||||
aria-label="Close menu"
|
||||
<button onclick={() => (isOpen = false)} class="fixed inset-0 z-40" aria-label="Close menu"
|
||||
></button>
|
||||
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div class="absolute right-0 top-full z-50 mt-2 w-64 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-800">
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-2 w-64 rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<!-- My Account -->
|
||||
<button
|
||||
onclick={() => switchAccount(user.id)}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left hover:bg-theme-surface-hover transition-colors"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<User class="h-4 w-4 text-theme-text-muted" />
|
||||
|
|
@ -104,26 +103,28 @@
|
|||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
||||
{#if sharedAccounts.length > 0}
|
||||
<div class="my-2 border-t border-theme-border"></div>
|
||||
|
||||
|
||||
<!-- Shared Accounts -->
|
||||
<div class="mb-1 px-3 py-1">
|
||||
<p class="text-xs font-medium text-theme-text-muted">Team Accounts</p>
|
||||
</div>
|
||||
|
||||
|
||||
{#each sharedAccounts as shared}
|
||||
{#if shared.invitation_status === 'accepted'}
|
||||
<button
|
||||
onclick={() => switchAccount(shared.owner)}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left hover:bg-theme-surface-hover transition-colors"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="h-4 w-4 text-theme-text-muted" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-text">
|
||||
{shared.expand?.owner?.name || shared.expand?.owner?.username || 'Team Account'}
|
||||
{shared.expand?.owner?.name ||
|
||||
shared.expand?.owner?.username ||
|
||||
'Team Account'}
|
||||
</p>
|
||||
<p class="text-xs text-theme-text-muted">
|
||||
{shared.expand?.owner?.email}
|
||||
|
|
@ -141,4 +142,4 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
totalUsers: 0,
|
||||
totalLinks: 0,
|
||||
totalFolders: 0,
|
||||
totalClicks: 0
|
||||
totalClicks: 0,
|
||||
});
|
||||
|
||||
let isVisible = $state(false);
|
||||
|
|
@ -77,26 +77,26 @@
|
|||
icon: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z',
|
||||
label: 'Users',
|
||||
key: 'totalUsers' as const,
|
||||
color: 'blue'
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||
label: 'Links',
|
||||
key: 'totalLinks' as const,
|
||||
color: 'purple'
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z',
|
||||
label: 'Folders',
|
||||
key: 'totalFolders' as const,
|
||||
color: 'green'
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: 'M22 12h-4l-3 9L9 3l-3 9H2',
|
||||
label: 'Clicks',
|
||||
key: 'totalClicks' as const,
|
||||
color: 'orange'
|
||||
}
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@
|
|||
clickable = false,
|
||||
removable = false,
|
||||
onclick,
|
||||
onremove
|
||||
onremove,
|
||||
}: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-1.5 py-0 text-[10px]',
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-1.5 text-base'
|
||||
lg: 'px-4 py-1.5 text-base',
|
||||
};
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
name="name"
|
||||
value={tag.name}
|
||||
required
|
||||
class="w-full rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text focus:ring-1 focus:ring-theme-accent focus:outline-none"
|
||||
class="w-full rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
|
|
@ -86,32 +86,38 @@
|
|||
<TagBadge {tag} size="lg" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-theme-text-muted">
|
||||
<div class="relative flex items-center gap-1.5 group/stat">
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<Link class="h-3.5 w-3.5" />
|
||||
<span>{tag.linkCount || 0} links</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Used in {tag.linkCount || 0} links
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-theme-border">•</span>
|
||||
<div class="relative flex items-center gap-1.5 group/stat">
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<MousePointer class="h-3.5 w-3.5" />
|
||||
<span>{tag.totalClicks || 0} clicks</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Total clicks: {tag.totalClicks || 0}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-theme-border">•</span>
|
||||
<div class="relative flex items-center gap-1.5 group/stat">
|
||||
<div class="group/stat relative flex items-center gap-1.5">
|
||||
<Hash class="h-3.5 w-3.5" />
|
||||
<span>{tag.usage_count || 0} uses</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Usage count: {tag.usage_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
{#if tag.is_public}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span class="text-green-600 dark:text-green-400 font-medium">Public</span>
|
||||
<span class="font-medium text-green-600 dark:text-green-400">Public</span>
|
||||
{:else}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span class="font-medium">Private</span>
|
||||
|
|
@ -124,26 +130,26 @@
|
|||
label: 'Edit',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>',
|
||||
color: '#9333ea',
|
||||
action: startEdit
|
||||
action: startEdit,
|
||||
},
|
||||
{
|
||||
label: 'View Links',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>',
|
||||
color: '#2563eb',
|
||||
href: `/my/links?tag=${tag.name}`
|
||||
href: `/my/links?tag=${tag.name}`,
|
||||
},
|
||||
{
|
||||
label: tag.is_public ? 'Make Private' : 'Make Public',
|
||||
icon: tag.is_public
|
||||
icon: tag.is_public
|
||||
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
|
||||
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
|
||||
color: '#ea580c',
|
||||
type: 'form',
|
||||
formAction: '?/togglePublic',
|
||||
formData: { id: tag.id, is_public: String(!tag.is_public) }
|
||||
formData: { id: tag.id, is_public: String(!tag.is_public) },
|
||||
},
|
||||
{
|
||||
divider: true
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
|
|
@ -158,12 +164,12 @@
|
|||
await update();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
buttonText="Actions"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@
|
|||
onToggleSelect?: (tagId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tags,
|
||||
let {
|
||||
tags,
|
||||
viewMode,
|
||||
isSelectMode = false,
|
||||
selectedTags = new Set<string>(),
|
||||
onToggleSelect = () => {}
|
||||
onToggleSelect = () => {},
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -28,14 +28,18 @@
|
|||
{:else if viewMode === 'cards'}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each tags as tag}
|
||||
<div class="relative {isSelectMode && selectedTags.has(tag.id) ? 'ring-2 ring-theme-primary rounded-xl' : ''}">
|
||||
<div
|
||||
class="relative {isSelectMode && selectedTags.has(tag.id)
|
||||
? 'rounded-xl ring-2 ring-theme-primary'
|
||||
: ''}"
|
||||
>
|
||||
{#if isSelectMode}
|
||||
<div class="absolute top-3 left-3 z-10">
|
||||
<div class="absolute left-3 top-3 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTags.has(tag.id)}
|
||||
onchange={() => onToggleSelect(tag.id)}
|
||||
class="h-5 w-5 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer bg-white"
|
||||
class="h-5 w-5 cursor-pointer rounded border-theme-border bg-white text-theme-primary focus:ring-theme-primary"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -44,14 +48,18 @@
|
|||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-theme-border bg-theme-surface shadow-xl overflow-hidden">
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-4 sm:px-6 py-4">
|
||||
<h2 class="text-lg sm:text-xl font-semibold text-theme-text">
|
||||
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-xl">
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-4 py-4 sm:px-6">
|
||||
<h2 class="text-lg font-semibold text-theme-text sm:text-xl">
|
||||
Your Tags ({tags.length} total)
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Desktop Table Header -->
|
||||
<div class="hidden lg:grid {isSelectMode ? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]' : 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text">
|
||||
<div
|
||||
class="hidden lg:grid {isSelectMode
|
||||
? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]'
|
||||
: 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-6 py-3 text-sm font-medium text-theme-text"
|
||||
>
|
||||
{#if isSelectMode}<div></div>{/if}
|
||||
<div>Tag Name</div>
|
||||
<div>Links</div>
|
||||
|
|
@ -61,7 +69,11 @@
|
|||
<div class="text-right">Actions</div>
|
||||
</div>
|
||||
<!-- Tablet Table Header -->
|
||||
<div class="hidden md:grid lg:hidden {isSelectMode ? 'grid-cols-[40px_1fr_100px_120px_140px]' : 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text">
|
||||
<div
|
||||
class="hidden md:grid lg:hidden {isSelectMode
|
||||
? 'grid-cols-[40px_1fr_100px_120px_140px]'
|
||||
: 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border bg-theme-surface-hover px-4 py-3 text-sm font-medium text-theme-text"
|
||||
>
|
||||
{#if isSelectMode}<div></div>{/if}
|
||||
<div>Tag Name</div>
|
||||
<div>Links</div>
|
||||
|
|
@ -71,7 +83,7 @@
|
|||
<!-- Table Body -->
|
||||
<div>
|
||||
{#each tags as tag}
|
||||
<TagListItem
|
||||
<TagListItem
|
||||
{tag}
|
||||
{isSelectMode}
|
||||
isSelected={selectedTags.has(tag.id)}
|
||||
|
|
@ -83,8 +95,6 @@
|
|||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-md">
|
||||
<p class="text-theme-text-muted">
|
||||
No tags yet. Create your first tag to organize your links!
|
||||
</p>
|
||||
<p class="text-theme-text-muted">No tags yet. Create your first tag to organize your links!</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
let {
|
||||
tag,
|
||||
isSelectMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect = () => {}
|
||||
onToggleSelect = () => {},
|
||||
}: Props = $props();
|
||||
let editingTag = $state(false);
|
||||
|
||||
|
|
@ -30,14 +30,20 @@
|
|||
</script>
|
||||
|
||||
<!-- Desktop View -->
|
||||
<div class="hidden lg:grid {isSelectMode ? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]' : 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border {isSelected ? 'bg-theme-primary/5' : 'bg-theme-surface'} px-6 py-4 transition-colors hover:bg-theme-surface-hover">
|
||||
<div
|
||||
class="hidden lg:grid {isSelectMode
|
||||
? 'grid-cols-[40px_minmax(200px,1fr)_100px_120px_100px_80px_140px]'
|
||||
: 'grid-cols-[minmax(200px,1fr)_100px_120px_100px_80px_140px]'} items-center gap-4 border-b border-theme-border {isSelected
|
||||
? 'bg-theme-primary/5'
|
||||
: 'bg-theme-surface'} px-6 py-4 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
{#if isSelectMode}
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onchange={onToggleSelect}
|
||||
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
|
||||
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -60,7 +66,7 @@
|
|||
name="name"
|
||||
value={tag.name}
|
||||
required
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:ring-1 focus:ring-theme-accent focus:outline-none"
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
|
|
@ -101,32 +107,38 @@
|
|||
<div class="flex items-center">
|
||||
<TagBadge {tag} size="md" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Links Column -->
|
||||
<div class="text-sm text-theme-text-muted group/stat relative">
|
||||
<div class="group/stat relative text-sm text-theme-text-muted">
|
||||
<span>{tag.linkCount || 0} links</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Used in {tag.linkCount || 0} links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Clicks Column -->
|
||||
<div class="text-sm text-theme-text-muted group/stat relative flex items-center gap-1">
|
||||
<div class="group/stat relative flex items-center gap-1 text-sm text-theme-text-muted">
|
||||
<MousePointer class="h-3 w-3" />
|
||||
<span>{tag.totalClicks || 0} clicks</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Total clicks from all links: {tag.totalClicks || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Uses Column -->
|
||||
<div class="text-sm text-theme-text-muted group/stat relative">
|
||||
<div class="group/stat relative text-sm text-theme-text-muted">
|
||||
<span>{tag.usage_count || 0} uses</span>
|
||||
<div class="invisible absolute left-0 bottom-full z-10 mb-1 rounded-lg bg-gray-900 px-2 py-1 text-xs text-white shadow-lg opacity-0 transition-all group-hover/stat:visible group-hover/stat:opacity-100 whitespace-nowrap">
|
||||
<div
|
||||
class="invisible absolute bottom-full left-0 z-10 mb-1 whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-all group-hover/stat:visible group-hover/stat:opacity-100"
|
||||
>
|
||||
Internal usage counter
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status Column -->
|
||||
<div class="text-sm">
|
||||
{#if tag.is_public}
|
||||
|
|
@ -135,12 +147,12 @@
|
|||
<span class="text-theme-text-muted">Private</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Actions Column -->
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="rounded bg-theme-primary/10 px-3 py-1 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded px-3 py-1 text-sm font-medium text-theme-primary transition"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
|
|
@ -168,14 +180,20 @@
|
|||
</div>
|
||||
|
||||
<!-- Tablet View -->
|
||||
<div class="hidden md:grid lg:hidden {isSelectMode ? 'grid-cols-[40px_1fr_100px_120px_140px]' : 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border {isSelected ? 'bg-theme-primary/5' : 'bg-theme-surface'} px-4 py-4 transition-colors hover:bg-theme-surface-hover">
|
||||
<div
|
||||
class="hidden md:grid lg:hidden {isSelectMode
|
||||
? 'grid-cols-[40px_1fr_100px_120px_140px]'
|
||||
: 'grid-cols-[1fr_100px_120px_140px]'} items-center gap-4 border-b border-theme-border {isSelected
|
||||
? 'bg-theme-primary/5'
|
||||
: 'bg-theme-surface'} px-4 py-4 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
{#if isSelectMode}
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onchange={onToggleSelect}
|
||||
class="h-4 w-4 rounded border-theme-border text-theme-primary focus:ring-theme-primary cursor-pointer"
|
||||
class="h-4 w-4 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -198,7 +216,7 @@
|
|||
name="name"
|
||||
value={tag.name}
|
||||
required
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:ring-1 focus:ring-theme-accent focus:outline-none"
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
|
|
@ -232,14 +250,14 @@
|
|||
<div class="text-sm text-theme-text-muted">
|
||||
{tag.linkCount || 0} links
|
||||
</div>
|
||||
<div class="text-sm text-theme-text-muted flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 text-sm text-theme-text-muted">
|
||||
<MousePointer class="h-3 w-3" />
|
||||
<span>{tag.totalClicks || 0}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="rounded bg-theme-primary/10 px-3 py-1 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded px-3 py-1 text-sm font-medium text-theme-primary transition"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
|
|
@ -267,7 +285,11 @@
|
|||
</div>
|
||||
|
||||
<!-- Mobile View -->
|
||||
<div class="md:hidden border-b border-theme-border {isSelected ? 'bg-theme-primary/5' : 'bg-theme-surface'} p-4 transition-colors hover:bg-theme-surface-hover">
|
||||
<div
|
||||
class="border-b border-theme-border md:hidden {isSelected
|
||||
? 'bg-theme-primary/5'
|
||||
: 'bg-theme-surface'} p-4 transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
{#if editingTag}
|
||||
<form
|
||||
method="POST"
|
||||
|
|
@ -286,7 +308,7 @@
|
|||
name="name"
|
||||
value={tag.name}
|
||||
required
|
||||
class="w-full rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:ring-1 focus:ring-theme-accent focus:outline-none"
|
||||
class="w-full rounded border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
/>
|
||||
<select
|
||||
name="color"
|
||||
|
|
@ -324,8 +346,8 @@
|
|||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#if isSelectMode}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
|
|
@ -339,12 +361,12 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<TagBadge {tag} size="md" />
|
||||
{#if tag.is_public}
|
||||
<span class="text-xs text-green-600 dark:text-green-400 font-medium">Public</span>
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">Public</span>
|
||||
{:else}
|
||||
<span class="text-xs text-theme-text-muted">Private</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-theme-text-muted">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>{tag.linkCount || 0} links</span>
|
||||
|
|
@ -355,37 +377,37 @@
|
|||
<span>{tag.usage_count || 0} uses</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if !isSelectMode}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="flex-1 rounded bg-theme-primary/10 px-3 py-2 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="flex-1"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-red-100 px-3 py-2 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
|
||||
onclick={startEdit}
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded px-3 py-2 text-sm font-medium text-theme-primary transition"
|
||||
>
|
||||
Delete
|
||||
Edit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
class="flex-1"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded bg-red-100 px-3 py-2 text-sm font-medium text-red-600 transition hover:bg-red-200 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
try {
|
||||
const tags = await pb.collection('tags').getList<Tag>(1, 100, {
|
||||
filter: `user_id="${userId}"`,
|
||||
sort: '-usage_count,name'
|
||||
sort: '-usage_count,name',
|
||||
});
|
||||
availableTags = tags.items;
|
||||
} catch (err) {
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
color: randomColor,
|
||||
user_id: userId,
|
||||
is_public: false,
|
||||
usage_count: 0
|
||||
usage_count: 0,
|
||||
});
|
||||
|
||||
availableTags = [...availableTags, newTag];
|
||||
|
|
@ -129,12 +129,12 @@
|
|||
{placeholder}
|
||||
onfocus={handleInputFocus}
|
||||
onblur={handleInputBlur}
|
||||
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:ring-2 focus:ring-theme-accent focus:outline-none"
|
||||
class="w-full rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text placeholder-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||
/>
|
||||
|
||||
{#if isDropdownOpen && (filteredTags.length > 0 || canCreateNewTag || isCreatingTag)}
|
||||
<div
|
||||
class="absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-auto rounded-md border border-theme-border bg-white shadow-lg dark:bg-gray-800"
|
||||
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border border-theme-border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
{#if isCreatingTag}
|
||||
<div class="border-b border-theme-border p-3">
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
bind:value={newTagName}
|
||||
type="text"
|
||||
placeholder="Enter tag name"
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm focus:ring-1 focus:ring-theme-accent focus:outline-none"
|
||||
class="flex-1 rounded border border-theme-border bg-theme-surface px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-theme-accent"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -12,29 +12,29 @@
|
|||
let totalClicks = $derived(tags.reduce((sum, tag) => sum + (tag.totalClicks || 0), 0));
|
||||
let totalLinks = $derived(tags.reduce((sum, tag) => sum + (tag.linkCount || 0), 0));
|
||||
let averageLinksPerTag = $derived(totalTags > 0 ? (totalLinks / totalTags).toFixed(1) : '0');
|
||||
let mostUsedTag = $derived(tags.reduce((max, tag) =>
|
||||
(tag.usage_count || 0) > (max?.usage_count || 0) ? tag : max,
|
||||
tags[0]
|
||||
));
|
||||
let mostClickedTag = $derived(tags.reduce((max, tag) =>
|
||||
(tag.totalClicks || 0) > (max?.totalClicks || 0) ? tag : max,
|
||||
tags[0]
|
||||
));
|
||||
let mostUsedTag = $derived(
|
||||
tags.reduce(
|
||||
(max, tag) => ((tag.usage_count || 0) > (max?.usage_count || 0) ? tag : max),
|
||||
tags[0]
|
||||
)
|
||||
);
|
||||
let mostClickedTag = $derived(
|
||||
tags.reduce(
|
||||
(max, tag) => ((tag.totalClicks || 0) > (max?.totalClicks || 0) ? tag : max),
|
||||
tags[0]
|
||||
)
|
||||
);
|
||||
|
||||
let topTagsByClicks = $derived(
|
||||
[...tags]
|
||||
.sort((a, b) => (b.totalClicks || 0) - (a.totalClicks || 0))
|
||||
.slice(0, 10)
|
||||
[...tags].sort((a, b) => (b.totalClicks || 0) - (a.totalClicks || 0)).slice(0, 10)
|
||||
);
|
||||
|
||||
let topTagsByLinks = $derived(
|
||||
[...tags]
|
||||
.sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0))
|
||||
.slice(0, 10)
|
||||
[...tags].sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0)).slice(0, 10)
|
||||
);
|
||||
|
||||
let maxClicks = $derived(Math.max(...topTagsByClicks.map(t => t.totalClicks || 0), 1));
|
||||
let maxLinks = $derived(Math.max(...topTagsByLinks.map(t => t.linkCount || 0), 1));
|
||||
let maxClicks = $derived(Math.max(...topTagsByClicks.map((t) => t.totalClicks || 0), 1));
|
||||
let maxLinks = $derived(Math.max(...topTagsByLinks.map((t) => t.linkCount || 0), 1));
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
|
|
@ -93,8 +93,10 @@
|
|||
<div>
|
||||
<p class="text-sm font-medium text-theme-text-muted">Top Tag</p>
|
||||
{#if mostClickedTag}
|
||||
<p class="mt-2 text-lg font-bold text-theme-text truncate">{mostClickedTag.name}</p>
|
||||
<p class="text-xs text-theme-text-muted">{formatNumber(mostClickedTag.totalClicks || 0)} Klicks</p>
|
||||
<p class="mt-2 truncate text-lg font-bold text-theme-text">{mostClickedTag.name}</p>
|
||||
<p class="text-xs text-theme-text-muted">
|
||||
{formatNumber(mostClickedTag.totalClicks || 0)} Klicks
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-lg text-theme-text-muted">-</p>
|
||||
{/if}
|
||||
|
|
@ -117,12 +119,14 @@
|
|||
<div class="space-y-3">
|
||||
{#each topTagsByClicks as tag, index}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-theme-text truncate max-w-[200px]">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="max-w-[200px] truncate text-sm font-medium text-theme-text">
|
||||
{tag.name}
|
||||
</span>
|
||||
<span class="text-sm text-theme-text-muted">
|
||||
|
|
@ -130,7 +134,7 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-theme-surface-hover">
|
||||
<div
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-500"
|
||||
style="width: {((tag.totalClicks || 0) / maxClicks) * 100}%"
|
||||
></div>
|
||||
|
|
@ -153,12 +157,14 @@
|
|||
<div class="space-y-3">
|
||||
{#each topTagsByLinks as tag, index}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-theme-surface-hover text-sm font-medium text-theme-text"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium text-theme-text truncate max-w-[200px]">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="max-w-[200px] truncate text-sm font-medium text-theme-text">
|
||||
{tag.name}
|
||||
</span>
|
||||
<span class="text-sm text-theme-text-muted">
|
||||
|
|
@ -166,7 +172,7 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-theme-surface-hover">
|
||||
<div
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-green-500 to-green-600 transition-all duration-500"
|
||||
style="width: {((tag.linkCount || 0) / maxLinks) * 100}%"
|
||||
></div>
|
||||
|
|
@ -182,7 +188,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Detaillierte Tabelle -->
|
||||
<div class="rounded-xl border border-theme-border bg-theme-surface shadow-md overflow-hidden">
|
||||
<div class="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-md">
|
||||
<div class="border-b border-theme-border bg-theme-surface-hover px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-theme-text">Detaillierte Tag-Statistiken</h3>
|
||||
</div>
|
||||
|
|
@ -190,38 +196,49 @@
|
|||
<table class="w-full">
|
||||
<thead class="border-b border-theme-border bg-theme-surface-hover">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Tag
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Links
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Klicks
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
CTR
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Verwendungen
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-theme-text"
|
||||
>
|
||||
Erstellt
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-theme-border">
|
||||
{#each tags as tag}
|
||||
<tr class="hover:bg-theme-surface-hover transition-colors">
|
||||
<tr class="transition-colors hover:bg-theme-surface-hover">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background-color: {tag.color}"
|
||||
></div>
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {tag.color}"></div>
|
||||
<span class="font-medium text-theme-text">{tag.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -232,7 +249,9 @@
|
|||
{formatNumber(tag.totalClicks || 0)}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>
|
||||
{calculateCTR(tag)}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -241,11 +260,15 @@
|
|||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{#if tag.is_public}
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400"
|
||||
>
|
||||
Öffentlich
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-900/20 dark:text-gray-400">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-900/20 dark:text-gray-400"
|
||||
>
|
||||
Privat
|
||||
</span>
|
||||
{/if}
|
||||
|
|
@ -264,4 +287,4 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
import { themeStore } from '$lib/themes/theme-store';
|
||||
import { themes } from '$lib/themes/presets';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
|
||||
// Subscribe to stores for reactive values
|
||||
let isDark = $state(false);
|
||||
let preset = $state('');
|
||||
|
||||
|
||||
$effect(() => {
|
||||
const unsubscribeDark = themeStore.isDark.subscribe(value => isDark = value);
|
||||
const unsubscribePreset = themeStore.preset.subscribe(value => preset = value);
|
||||
|
||||
const unsubscribeDark = themeStore.isDark.subscribe((value) => (isDark = value));
|
||||
const unsubscribePreset = themeStore.preset.subscribe((value) => (preset = value));
|
||||
|
||||
return () => {
|
||||
unsubscribeDark();
|
||||
unsubscribePreset();
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@
|
|||
const priceDisplay = {
|
||||
monthly: '4,99€/Monat',
|
||||
yearly: '39,99€/Jahr',
|
||||
lifetime: '129,99€ einmalig'
|
||||
lifetime: '129,99€ einmalig',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'btn-sm',
|
||||
md: '',
|
||||
lg: 'btn-lg'
|
||||
lg: 'btn-lg',
|
||||
};
|
||||
|
||||
async function handleUpgrade() {
|
||||
|
|
@ -28,9 +28,9 @@
|
|||
const response = await fetch('/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ priceType })
|
||||
body: JSON.stringify({ priceType }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -59,4 +59,4 @@
|
|||
<span class="hidden sm:inline">Stats</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,29 +16,29 @@
|
|||
let showDropdown = $state(false);
|
||||
let workspaces = $derived($allWorkspaces);
|
||||
let workspacesState = $derived($workspacesStore);
|
||||
|
||||
|
||||
// Use activeWorkspace store as the primary source
|
||||
let activeWorkspaceId = $state(activeWorkspace.getId());
|
||||
let activeWorkspaceData = $state(activeWorkspace.getData());
|
||||
|
||||
|
||||
// Subscribe to activeWorkspace changes
|
||||
$effect(() => {
|
||||
const unsubId = activeWorkspace.id.subscribe(id => {
|
||||
const unsubId = activeWorkspace.id.subscribe((id) => {
|
||||
activeWorkspaceId = id;
|
||||
});
|
||||
const unsubData = activeWorkspace.data.subscribe(data => {
|
||||
const unsubData = activeWorkspace.data.subscribe((data) => {
|
||||
activeWorkspaceData = data;
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
unsubId();
|
||||
unsubData();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Derive current workspace from activeWorkspace or fallback to old store
|
||||
let current = $derived(activeWorkspaceData || $currentWorkspace);
|
||||
|
||||
|
||||
function toggleDropdown() {
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
async function switchToWorkspace(workspaceId: string) {
|
||||
// Find the workspace data
|
||||
const workspace = workspaces.find(w => w.id === workspaceId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (workspace) {
|
||||
// Set in the new active workspace store
|
||||
activeWorkspace.set(workspace);
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
if (!workspace) return 'Unknown';
|
||||
return workspace.name || 'Unnamed Workspace';
|
||||
}
|
||||
|
||||
|
||||
function createWorkspace() {
|
||||
showDropdown = false;
|
||||
// Navigate to workspace creation page with current workspace context
|
||||
|
|
@ -92,20 +92,30 @@
|
|||
<span class="max-w-[150px] truncate">
|
||||
{getWorkspaceDisplayName(activeWorkspaceData || current)}
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown ? 'rotate-180' : ''}" />
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{:else}
|
||||
<Building2 class="h-4 w-4 text-theme-text-muted" />
|
||||
<span class="max-w-[150px] truncate">
|
||||
Select Workspace
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown ? 'rotate-180' : ''}" />
|
||||
<span class="max-w-[150px] truncate"> Select Workspace </span>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 text-theme-text-muted transition-transform {showDropdown
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
transition:scale={{ duration: 200, start: 0.95 }}
|
||||
class="absolute z-50 {position === 'left-outside' ? 'left-0 top-full mt-2' : 'right-0 mt-2'} w-72 {position === 'left-outside' ? 'origin-top-left' : 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
class="absolute z-50 {position === 'left-outside'
|
||||
? 'left-0 top-full mt-2'
|
||||
: 'right-0 mt-2'} w-72 {position === 'left-outside'
|
||||
? 'origin-top-left'
|
||||
: 'origin-top-right'} rounded-lg border border-theme-border bg-theme-surface shadow-xl"
|
||||
>
|
||||
<!-- Personal Workspace Section -->
|
||||
{#if workspacesState.personalWorkspace}
|
||||
|
|
@ -114,17 +124,17 @@
|
|||
Personal Workspace
|
||||
</div>
|
||||
<button
|
||||
onclick={() => workspacesState.personalWorkspace && switchToWorkspace(workspacesState.personalWorkspace.id)}
|
||||
onclick={() =>
|
||||
workspacesState.personalWorkspace &&
|
||||
switchToWorkspace(workspacesState.personalWorkspace.id)}
|
||||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<User class="h-5 w-5 text-theme-text-muted" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getWorkspaceDisplayName(workspacesState.personalWorkspace)}
|
||||
</div>
|
||||
<div class="text-xs text-theme-text-muted">
|
||||
Your personal workspace
|
||||
</div>
|
||||
<div class="text-xs text-theme-text-muted">Your personal workspace</div>
|
||||
</div>
|
||||
{#if activeWorkspaceId === workspacesState.personalWorkspace.id}
|
||||
<Check class="h-4 w-4 text-theme-primary" />
|
||||
|
|
@ -132,7 +142,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- Team Workspaces Section -->
|
||||
{#if workspacesState.teamWorkspaces && workspacesState.teamWorkspaces.length > 0}
|
||||
<div class="border-b border-theme-border p-2">
|
||||
|
|
@ -145,7 +155,7 @@
|
|||
class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<Users class="h-5 w-5 text-purple-500" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-theme-text">
|
||||
{getWorkspaceDisplayName(workspace)}
|
||||
</div>
|
||||
|
|
@ -164,32 +174,29 @@
|
|||
{:else}
|
||||
<!-- Empty State for Team Workspaces -->
|
||||
<div class="border-b border-theme-border p-4">
|
||||
<p class="text-center text-xs text-theme-text-muted">
|
||||
No team workspaces yet
|
||||
</p>
|
||||
<p class="text-center text-xs text-theme-text-muted">No team workspaces yet</p>
|
||||
<p class="mt-1 text-center text-xs text-theme-text-muted">
|
||||
Create or join a team workspace to collaborate
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- Create Workspace Button -->
|
||||
<div class="border-t border-theme-border p-2">
|
||||
<button
|
||||
onclick={createWorkspace}
|
||||
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all hover:bg-theme-primary/10"
|
||||
class="hover:bg-theme-primary/10 flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-all"
|
||||
>
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded-full bg-theme-primary/10">
|
||||
<div class="bg-theme-primary/10 flex h-5 w-5 items-center justify-center rounded-full">
|
||||
<Plus class="h-3.5 w-3.5 text-theme-primary" />
|
||||
</div>
|
||||
<span class="text-theme-text">Create Workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom styles if needed */
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,31 @@
|
|||
<script lang="ts">
|
||||
import type { BlogPostWithMeta } from '../../../content/config';
|
||||
|
||||
|
||||
// Svelte 5: Props mit $props()
|
||||
let {
|
||||
let {
|
||||
post,
|
||||
featured = false,
|
||||
viewMode = 'cards'
|
||||
viewMode = 'cards',
|
||||
} = $props<{
|
||||
post: BlogPostWithMeta;
|
||||
featured?: boolean;
|
||||
viewMode?: 'cards' | 'list';
|
||||
}>();
|
||||
|
||||
|
||||
// Svelte 5: $state für Hover-State
|
||||
let isHovered = $state(false);
|
||||
|
||||
|
||||
// Svelte 5: $derived für berechnete Werte
|
||||
let formattedDate = $derived(
|
||||
new Date(post.date).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})
|
||||
);
|
||||
|
||||
let readingTimeText = $derived(
|
||||
`${post.readingTime} Min. Lesezeit`
|
||||
);
|
||||
|
||||
|
||||
let readingTimeText = $derived(`${post.readingTime} Min. Lesezeit`);
|
||||
|
||||
let cardClasses = $derived(() => {
|
||||
if (viewMode === 'list') {
|
||||
return 'flex gap-4 p-4';
|
||||
|
|
@ -36,83 +34,92 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative overflow-hidden rounded-xl border border-theme-border bg-theme-surface transition-all hover:shadow-lg hover:border-theme-accent {cardClasses()} {featured ? 'ring-2 ring-theme-primary' : ''}"
|
||||
onmouseenter={() => isHovered = true}
|
||||
onmouseleave={() => isHovered = false}
|
||||
<article
|
||||
class="group relative overflow-hidden rounded-xl border border-theme-border bg-theme-surface transition-all hover:border-theme-accent hover:shadow-lg {cardClasses()} {featured
|
||||
? 'ring-2 ring-theme-primary'
|
||||
: ''}"
|
||||
onmouseenter={() => (isHovered = true)}
|
||||
onmouseleave={() => (isHovered = false)}
|
||||
>
|
||||
{#if post.image && viewMode === 'cards'}
|
||||
<div class="relative h-48 w-full overflow-hidden bg-gradient-to-br from-theme-primary/5 to-theme-accent/5">
|
||||
<img
|
||||
src={post.image}
|
||||
<div
|
||||
class="from-theme-primary/5 to-theme-accent/5 relative h-48 w-full overflow-hidden bg-gradient-to-br"
|
||||
>
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if featured}
|
||||
<div class="absolute top-3 left-3">
|
||||
<span class="inline-flex items-center rounded-full bg-theme-primary px-3 py-1 text-xs font-semibold text-white shadow-lg">
|
||||
<div class="absolute left-3 top-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-theme-primary px-3 py-1 text-xs font-semibold text-white shadow-lg"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if post.image && viewMode === 'list'}
|
||||
<div class="relative h-32 w-48 flex-shrink-0 overflow-hidden rounded-lg bg-gradient-to-br from-theme-primary/5 to-theme-accent/5">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
class="from-theme-primary/5 to-theme-accent/5 relative h-32 w-48 flex-shrink-0 overflow-hidden rounded-lg bg-gradient-to-br"
|
||||
>
|
||||
<img src={post.image} alt={post.title} class="h-full w-full object-cover" loading="lazy" />
|
||||
{#if featured}
|
||||
<div class="absolute top-2 left-2">
|
||||
<span class="inline-flex items-center rounded-full bg-theme-primary px-2 py-0.5 text-xs font-semibold text-white">
|
||||
<div class="absolute left-2 top-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-theme-primary px-2 py-0.5 text-xs font-semibold text-white"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="flex flex-1 flex-col p-6">
|
||||
{#if featured && !post.image}
|
||||
<span class="mb-2 inline-block rounded-full bg-theme-primary/10 px-3 py-1 text-xs font-semibold text-theme-primary">
|
||||
<span
|
||||
class="bg-theme-primary/10 mb-2 inline-block rounded-full px-3 py-1 text-xs font-semibold text-theme-primary"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<h3 class="mb-2 text-lg font-semibold text-theme-text line-clamp-2">
|
||||
<a
|
||||
href="/blog/{post.slug}"
|
||||
class="transition-colors hover:text-theme-primary"
|
||||
>
|
||||
|
||||
<h3 class="mb-2 line-clamp-2 text-lg font-semibold text-theme-text">
|
||||
<a href="/blog/{post.slug}" class="transition-colors hover:text-theme-primary">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="mb-4 text-sm text-theme-text-muted line-clamp-2">
|
||||
|
||||
<p class="mb-4 line-clamp-2 text-sm text-theme-text-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
|
||||
<div class="mt-auto flex items-center justify-between text-xs text-theme-text-muted">
|
||||
<time datetime={post.date.toISOString()}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{readingTimeText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{#if post.tags.length > 0}
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
{#each post.tags.slice(0, 3) as tag}
|
||||
<a
|
||||
<a
|
||||
href="/blog?tag={tag}"
|
||||
class="inline-flex items-center rounded-full border border-theme-border bg-theme-background px-2 py-0.5 text-xs text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
|
||||
>
|
||||
|
|
@ -136,4 +143,4 @@
|
|||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
layout = {},
|
||||
animations = {},
|
||||
className = '',
|
||||
children
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
// Generate CSS classes based on variant
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
hero: 'bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-lg',
|
||||
minimal: 'bg-transparent border-none',
|
||||
glass: 'bg-white/20 backdrop-blur-md border border-white/30',
|
||||
gradient: 'bg-gradient-to-br from-indigo-50 to-blue-50 border border-indigo-200'
|
||||
gradient: 'bg-gradient-to-br from-indigo-50 to-blue-50 border border-indigo-200',
|
||||
};
|
||||
return classes[variant] || classes.default;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
|
||||
let { card, onSave, onCancel }: Props = $props();
|
||||
|
||||
let editingCard = $state<Card>({
|
||||
let editingCard = $state<Card>({
|
||||
...card,
|
||||
metadata: card.metadata || {},
|
||||
constraints: card.constraints || {}
|
||||
constraints: card.constraints || {},
|
||||
});
|
||||
let activeTab = $state<'config' | 'metadata' | 'preview'>('config');
|
||||
let validationErrors = $state<string[]>([]);
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
const modes = [
|
||||
{ value: 'beginner', label: 'Beginner', description: 'Visual modules' },
|
||||
{ value: 'advanced', label: 'Advanced', description: 'HTML templates' },
|
||||
{ value: 'expert', label: 'Expert', description: 'Custom HTML/CSS' }
|
||||
{ value: 'expert', label: 'Expert', description: 'Custom HTML/CSS' },
|
||||
];
|
||||
|
||||
// Validate card on changes
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
newConfig = {
|
||||
mode: 'beginner',
|
||||
modules: [],
|
||||
layout: { columns: 1, gap: '1rem', padding: '1.5rem' }
|
||||
layout: { columns: 1, gap: '1rem', padding: '1.5rem' },
|
||||
};
|
||||
break;
|
||||
case 'advanced':
|
||||
|
|
@ -58,14 +58,14 @@
|
|||
'<div class="card-content">\n <h2>{{title}}</h2>\n <p>{{content}}</p>\n</div>',
|
||||
css: '',
|
||||
variables: [],
|
||||
values: {}
|
||||
values: {},
|
||||
};
|
||||
break;
|
||||
case 'expert':
|
||||
newConfig = {
|
||||
mode: 'expert',
|
||||
html: '<div class="card-content">\n <h2>Title</h2>\n <p>Content</p>\n</div>',
|
||||
css: '.card-content { padding: 1.5rem; }'
|
||||
css: '.card-content { padding: 1.5rem; }',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
|
||||
editingCard = {
|
||||
...editingCard,
|
||||
config: newConfig
|
||||
config: newConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +95,7 @@
|
|||
id: `module_${Date.now()}`,
|
||||
type,
|
||||
props: {},
|
||||
order: editingCard.config.modules.length
|
||||
order: editingCard.config.modules.length,
|
||||
};
|
||||
|
||||
editingCard.config.modules = [...editingCard.config.modules, newModule];
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
onEdit = () => {},
|
||||
onDelete = () => {},
|
||||
showMetadata = false,
|
||||
userCardCustomization
|
||||
userCardCustomization,
|
||||
}: Props = $props();
|
||||
|
||||
// Determine variant classes and user customization styles
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
hero: 'bg-gradient-to-br from-blue-500 to-purple-600 text-white',
|
||||
minimal: 'bg-transparent border-0',
|
||||
glass: 'bg-white/10 backdrop-blur-lg border border-white/20',
|
||||
gradient: 'bg-gradient-to-br from-blue-50 to-purple-50 border border-purple-200'
|
||||
gradient: 'bg-gradient-to-br from-blue-50 to-purple-50 border border-purple-200',
|
||||
};
|
||||
return classes[card.variant || 'default'] || classes.default;
|
||||
});
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
// Generate CSS custom properties for user card customization
|
||||
let cardCustomStyles = $derived(() => {
|
||||
if (!userCardCustomization) return '';
|
||||
|
||||
|
||||
const styles = [];
|
||||
if (userCardCustomization.cardBackgroundColor) {
|
||||
styles.push(`--card-bg: ${userCardCustomization.cardBackgroundColor}`);
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
if (userCardCustomization.cardTextColor) {
|
||||
styles.push(`--card-text: ${userCardCustomization.cardTextColor}`);
|
||||
}
|
||||
|
||||
|
||||
return styles.join('; ');
|
||||
});
|
||||
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
|
||||
<div
|
||||
class="card-renderer rounded-xl shadow-sm transition-all {variantClasses()} {className}"
|
||||
style="{cardCustomStyles()}"
|
||||
style={cardCustomStyles()}
|
||||
data-card-id={card.id}
|
||||
data-card-mode={card.config.mode}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
layout = { columns: 1, gap: '1rem', padding: '1.5rem' },
|
||||
animations = {},
|
||||
className = '',
|
||||
userCardCustomization
|
||||
userCardCustomization,
|
||||
}: Props = $props();
|
||||
|
||||
// Module component map
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
stats: StatsModule,
|
||||
actions: ActionsModule,
|
||||
footer: FooterModule,
|
||||
links: LinksModule
|
||||
links: LinksModule,
|
||||
};
|
||||
|
||||
// Sort modules by order - create a copy to avoid mutation
|
||||
|
|
@ -60,11 +60,10 @@
|
|||
// Generate CSS variables from theme and user customization
|
||||
let themeStyles = $derived(() => {
|
||||
const styles = [];
|
||||
|
||||
|
||||
// Add theme colors
|
||||
if (theme?.colors) {
|
||||
styles.push(...Object.entries(theme.colors)
|
||||
.map(([key, value]) => `--${key}: ${value}`));
|
||||
styles.push(...Object.entries(theme.colors).map(([key, value]) => `--${key}: ${value}`));
|
||||
}
|
||||
|
||||
// Add user card customization (takes priority)
|
||||
|
|
@ -93,7 +92,7 @@
|
|||
const classes = {
|
||||
fade: 'animate-fade-in',
|
||||
slide: 'animate-slide-up',
|
||||
scale: 'animate-scale-in'
|
||||
scale: 'animate-scale-in',
|
||||
};
|
||||
|
||||
return classes[animations.entrance] || '';
|
||||
|
|
@ -135,7 +134,7 @@
|
|||
function handleModuleEvent(moduleId: string, event: string, data: any) {
|
||||
moduleEventBus.emit(`module:${event}`, {
|
||||
moduleId,
|
||||
data
|
||||
data,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
onDuplicate,
|
||||
onToggleVisibility,
|
||||
onToggleProfileDisplay,
|
||||
onDelete
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
// Generate dropdown items based on card state
|
||||
|
|
@ -43,28 +43,29 @@
|
|||
label: 'Duplicate',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>',
|
||||
color: '#2563eb',
|
||||
action: () => onDuplicate(card)
|
||||
action: () => onDuplicate(card),
|
||||
},
|
||||
{
|
||||
divider: true
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: card.metadata?.is_active !== false ? 'Hide Card' : 'Show Card',
|
||||
icon: card.metadata?.is_active !== false
|
||||
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
|
||||
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
|
||||
icon:
|
||||
card.metadata?.is_active !== false
|
||||
? '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>'
|
||||
: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>',
|
||||
color: '#ea580c',
|
||||
action: () => onToggleVisibility(card)
|
||||
action: () => onToggleVisibility(card),
|
||||
},
|
||||
{
|
||||
divider: true
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: '<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>',
|
||||
color: '#dc2626',
|
||||
action: () => card.id && onDelete(card.id)
|
||||
}
|
||||
action: () => card.id && onDelete(card.id),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
@ -82,13 +83,15 @@
|
|||
>
|
||||
<!-- Card Number Badge -->
|
||||
<div
|
||||
class="absolute -top-2 -left-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-theme-primary text-sm font-bold text-white"
|
||||
class="absolute -left-2 -top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-theme-primary text-sm font-bold text-white"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<!-- Card Preview -->
|
||||
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-colors hover:bg-theme-surface-hover">
|
||||
<div
|
||||
class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-lg transition-colors hover:bg-theme-surface-hover"
|
||||
>
|
||||
<!-- Card Info -->
|
||||
<div class="mb-3">
|
||||
<h3 class="font-semibold text-theme-text">
|
||||
|
|
@ -124,34 +127,23 @@
|
|||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => onEdit(card)}
|
||||
class="flex-1 rounded bg-theme-primary/10 px-3 py-1.5 text-sm font-medium text-theme-primary transition hover:bg-theme-primary/20"
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded px-3 py-1.5 text-sm font-medium text-theme-primary transition"
|
||||
>
|
||||
Edit Card
|
||||
</button>
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
buttonText="Actions"
|
||||
size="sm"
|
||||
/>
|
||||
<Dropdown items={dropdownItems} buttonText="Actions" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag Handle -->
|
||||
<div
|
||||
class="absolute top-4 right-4 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<div class="absolute right-4 top-4 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 8h16M4 16h16"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,40 +19,40 @@
|
|||
className = '',
|
||||
showMetadata = false,
|
||||
compact = false,
|
||||
userCardCustomization
|
||||
userCardCustomization,
|
||||
}: Props = $props();
|
||||
|
||||
// Safe rendering function that won't break on errors
|
||||
function renderCard(card: Card) {
|
||||
try {
|
||||
if (!card?.config) return null;
|
||||
|
||||
|
||||
// Handle modular cards (beginner mode)
|
||||
if (card.config.mode === 'beginner' && card.config.modules) {
|
||||
return {
|
||||
type: 'modular',
|
||||
modules: card.config.modules || []
|
||||
modules: card.config.modules || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Handle template cards (advanced mode)
|
||||
if (card.config.mode === 'advanced' && card.config.template) {
|
||||
return {
|
||||
type: 'template',
|
||||
template: card.config.template,
|
||||
values: card.config.values || {}
|
||||
values: card.config.values || {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Handle custom HTML cards (expert mode)
|
||||
if (card.config.mode === 'expert') {
|
||||
return {
|
||||
type: 'custom',
|
||||
html: card.config.html || '',
|
||||
css: card.config.css || ''
|
||||
css: card.config.css || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error rendering card:', error);
|
||||
|
|
@ -61,13 +61,13 @@
|
|||
}
|
||||
|
||||
let cardData = $derived(renderCard(card));
|
||||
let headerModule = $derived(cardData?.modules?.find(m => m.type === 'header'));
|
||||
let linksModule = $derived(cardData?.modules?.find(m => m.type === 'links'));
|
||||
let headerModule = $derived(cardData?.modules?.find((m) => m.type === 'header'));
|
||||
let linksModule = $derived(cardData?.modules?.find((m) => m.type === 'links'));
|
||||
|
||||
// Generate CSS custom properties for user card customization
|
||||
let cardCustomStyles = $derived(() => {
|
||||
if (!userCardCustomization) return '';
|
||||
|
||||
|
||||
const styles = [];
|
||||
if (userCardCustomization.cardBackgroundColor) {
|
||||
styles.push(`--card-bg: ${userCardCustomization.cardBackgroundColor}`);
|
||||
|
|
@ -81,12 +81,17 @@
|
|||
if (userCardCustomization.cardTextColor) {
|
||||
styles.push(`--card-text: ${userCardCustomization.cardTextColor}`);
|
||||
}
|
||||
|
||||
|
||||
return styles.join('; ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="safe-card-renderer rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden {className}" class:p-3={compact} class:p-6={!compact} style="{cardCustomStyles()}">
|
||||
<div
|
||||
class="safe-card-renderer overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm {className}"
|
||||
class:p-3={compact}
|
||||
class:p-6={!compact}
|
||||
style={cardCustomStyles()}
|
||||
>
|
||||
{#if cardData?.type === 'modular'}
|
||||
<!-- Modular card rendering -->
|
||||
<div class="space-y-4">
|
||||
|
|
@ -96,9 +101,9 @@
|
|||
<!-- Avatar -->
|
||||
{#if headerModule.props?.avatar}
|
||||
<div class="mx-auto mb-3 h-16 w-16 overflow-hidden rounded-full bg-gray-100">
|
||||
<img
|
||||
src={headerModule.props.avatar}
|
||||
alt="Avatar"
|
||||
<img
|
||||
src={headerModule.props.avatar}
|
||||
alt="Avatar"
|
||||
class="h-full w-full object-cover"
|
||||
onerror={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
|
|
@ -107,12 +112,16 @@
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<div class="hidden h-full w-full items-center justify-center bg-gray-200 text-lg font-bold text-gray-600">
|
||||
<div
|
||||
class="hidden h-full w-full items-center justify-center bg-gray-200 text-lg font-bold text-gray-600"
|
||||
>
|
||||
{(headerModule.props?.title || 'U')[0].toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{:else if headerModule.props?.title}
|
||||
<div class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-lg font-bold text-white">
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-lg font-bold text-white"
|
||||
>
|
||||
{headerModule.props.title[0].toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -136,7 +145,9 @@
|
|||
<span class="text-lg">{link.icon}</span>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium text-gray-900">{link.title || link.original_url}</div>
|
||||
<div class="truncate font-medium text-gray-900">
|
||||
{link.title || link.original_url}
|
||||
</div>
|
||||
{#if link.description}
|
||||
<div class="truncate text-sm text-gray-600">{link.description}</div>
|
||||
{/if}
|
||||
|
|
@ -163,10 +174,17 @@
|
|||
<div class="text-center">
|
||||
<div class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{card.title || card.metadata?.name || 'Unnamed Card'}</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{card.title || card.metadata?.name || 'Unnamed Card'}
|
||||
</h3>
|
||||
{#if card.subtitle}
|
||||
<p class="mt-1 text-sm text-gray-600">{card.subtitle}</p>
|
||||
{/if}
|
||||
|
|
@ -213,4 +231,4 @@
|
|||
.safe-card-renderer :global(.text-gray-900) {
|
||||
color: var(--card-text, #0f172a);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
...module,
|
||||
props: {
|
||||
...module.props,
|
||||
[key]: value
|
||||
}
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
stats: '📊',
|
||||
actions: '⚡',
|
||||
footer: '📌',
|
||||
custom: '🎨'
|
||||
custom: '🎨',
|
||||
};
|
||||
return icons[type] || '📦';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
template = $bindable(),
|
||||
css = $bindable(),
|
||||
variables = $bindable(),
|
||||
values = $bindable()
|
||||
values = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
// Extract variables when template changes
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
const layoutClasses = {
|
||||
horizontal: 'flex flex-wrap gap-2',
|
||||
vertical: 'flex flex-col gap-2',
|
||||
grid: 'grid grid-cols-2 gap-2'
|
||||
grid: 'grid grid-cols-2 gap-2',
|
||||
};
|
||||
|
||||
const alignmentClasses = {
|
||||
left: 'justify-start',
|
||||
center: 'justify-center',
|
||||
right: 'justify-end',
|
||||
between: 'justify-between'
|
||||
between: 'justify-between',
|
||||
};
|
||||
|
||||
return `${layoutClasses[layout]} ${layout === 'horizontal' ? alignmentClasses[alignment] : ''}`;
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
primary: 'bg-theme-primary text-theme-background hover:bg-theme-primary-hover',
|
||||
secondary: 'bg-theme-surface-hover text-theme-text hover:bg-theme-border',
|
||||
ghost: 'text-theme-text hover:bg-theme-surface-hover',
|
||||
link: 'text-theme-accent hover:text-theme-accent-hover underline-offset-4 hover:underline'
|
||||
link: 'text-theme-accent hover:text-theme-accent-hover underline-offset-4 hover:underline',
|
||||
};
|
||||
return `${classes[variant] || classes.primary} rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
html = '',
|
||||
items = [],
|
||||
truncate = false,
|
||||
maxLines = 3
|
||||
maxLines = 3,
|
||||
}: ContentModuleProps = $props();
|
||||
|
||||
let truncateClass = $derived(truncate ? `line-clamp-${maxLines}` : '');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
avatarAlt = '',
|
||||
badge = '',
|
||||
icon = '',
|
||||
actions = []
|
||||
actions = [],
|
||||
}: HeaderModuleProps = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
{#if avatar}
|
||||
<img src={avatar} alt={avatarAlt || title} class="h-12 w-12 rounded-full object-cover" />
|
||||
{:else if icon}
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-theme-primary/10">
|
||||
<div class="bg-theme-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<span class="text-2xl">{icon}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@
|
|||
showIcon = true,
|
||||
target = '_blank',
|
||||
buttonVariant = 'secondary',
|
||||
gap = 'md'
|
||||
gap = 'md',
|
||||
}: LinksModuleProps = $props();
|
||||
|
||||
let containerClass = $derived(() => {
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2'
|
||||
2: 'grid-cols-2',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-3',
|
||||
lg: 'gap-4'
|
||||
lg: 'gap-4',
|
||||
};
|
||||
|
||||
return `grid ${columnClasses[columns] || 'grid-cols-1'} ${gapClasses[gap] || 'gap-3'}`;
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
'bg-theme-surface hover:bg-theme-surface-hover text-theme-text border border-theme-border',
|
||||
ghost: 'text-theme-text hover:bg-theme-surface-hover',
|
||||
outline:
|
||||
'border-2 border-theme-primary text-theme-primary hover:bg-theme-primary hover:text-theme-background'
|
||||
'border-2 border-theme-primary text-theme-primary hover:bg-theme-primary hover:text-theme-background',
|
||||
};
|
||||
return classes[variant] || classes.secondary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
qrSize = 200,
|
||||
qrColor = 'black',
|
||||
icon = '',
|
||||
iconSize = '3rem'
|
||||
iconSize = '3rem',
|
||||
}: MediaModuleProps = $props();
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
const classes = {
|
||||
grid: 'grid grid-cols-2 gap-4',
|
||||
list: 'space-y-3',
|
||||
compact: 'flex flex-wrap gap-4'
|
||||
compact: 'flex flex-wrap gap-4',
|
||||
};
|
||||
return classes[layout] || classes.grid;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { GDPRManager, acceptAllCookies, acceptNecessaryOnly, type GDPRConsent } from '$lib/gdpr/compliance';
|
||||
import {
|
||||
GDPRManager,
|
||||
acceptAllCookies,
|
||||
acceptNecessaryOnly,
|
||||
type GDPRConsent,
|
||||
} from '$lib/gdpr/compliance';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
// State
|
||||
|
|
@ -11,22 +16,22 @@
|
|||
necessary: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
preferences: false
|
||||
preferences: false,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Prüfe ob Banner gezeigt werden muss
|
||||
showBanner = GDPRManager.needsConsent();
|
||||
consent = GDPRManager.getConsent();
|
||||
|
||||
|
||||
// Event Listener für Consent-Updates
|
||||
const handleConsentUpdate = (event: CustomEvent) => {
|
||||
consent = event.detail;
|
||||
showBanner = false;
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('gdpr:consent-updated', handleConsentUpdate as EventListener);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('gdpr:consent-updated', handleConsentUpdate as EventListener);
|
||||
};
|
||||
|
|
@ -57,45 +62,58 @@
|
|||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 shadow-xl"
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||
transition:slide={{ duration: 300 }}
|
||||
>
|
||||
<div class="max-w-7xl mx-auto p-4 md:p-6">
|
||||
<div class="mx-auto max-w-7xl p-4 md:p-6">
|
||||
{#if !showDetails}
|
||||
<!-- Basis Cookie Banner -->
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Cookie Icon -->
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Text -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Cookies & Datenschutz
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Wir verwenden Cookies und ähnliche Technologien, um Ihnen die bestmögliche Erfahrung zu bieten.
|
||||
Einige sind technisch notwendig, andere helfen uns die Website zu verbessern und zu analysieren.
|
||||
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
Wir verwenden Cookies und ähnliche Technologien, um Ihnen die bestmögliche
|
||||
Erfahrung zu bieten. Einige sind technisch notwendig, andere helfen uns die
|
||||
Website zu verbessern und zu analysieren.
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Links -->
|
||||
<div class="mt-2 flex items-center space-x-4 text-xs">
|
||||
<a href="/datenschutz" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<a href="/datenschutz" class="text-blue-600 hover:underline dark:text-blue-400">
|
||||
Datenschutzerklärung
|
||||
</a>
|
||||
<a href="/impressum" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<a href="/impressum" class="text-blue-600 hover:underline dark:text-blue-400">
|
||||
Impressum
|
||||
</a>
|
||||
<button
|
||||
<button
|
||||
onclick={toggleDetails}
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
class="text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
|
|
@ -105,28 +123,27 @@
|
|||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
|
||||
<button
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row md:w-auto">
|
||||
<button
|
||||
onclick={handleAcceptNecessary}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Nur notwendige
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onclick={toggleDetails}
|
||||
class="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-600 dark:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
class="rounded-lg border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
Anpassen
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onclick={handleAcceptAll}
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Detaillierte Cookie-Einstellungen -->
|
||||
<div class="space-y-6">
|
||||
|
|
@ -135,13 +152,18 @@
|
|||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Cookie-Einstellungen
|
||||
</h3>
|
||||
<button
|
||||
<button
|
||||
onclick={toggleDetails}
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
class="p-2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -149,20 +171,18 @@
|
|||
<!-- Cookie Categories -->
|
||||
<div class="grid gap-4">
|
||||
<!-- Notwendige Cookies -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
Notwendige Cookies
|
||||
</h4>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">Notwendige Cookies</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Technisch erforderlich für die Grundfunktionen der Website
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500 mr-2">Immer aktiv</span>
|
||||
<div class="w-10 h-6 bg-green-600 rounded-full relative">
|
||||
<div class="w-4 h-4 bg-white rounded-full absolute top-1 right-1"></div>
|
||||
<span class="mr-2 text-sm text-gray-500">Immer aktiv</span>
|
||||
<div class="relative h-6 w-10 rounded-full bg-green-600">
|
||||
<div class="absolute right-1 top-1 h-4 w-4 rounded-full bg-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -172,22 +192,28 @@
|
|||
</div>
|
||||
|
||||
<!-- Analytics Cookies -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
Analytics Cookies
|
||||
</h4>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">Analytics Cookies</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Helfen uns die Website zu verbessern
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onclick={() => handleCustomChange('analytics', !customConsent.analytics)}
|
||||
class="relative"
|
||||
>
|
||||
<div class="w-10 h-6 rounded-full transition-colors {customConsent.analytics ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
|
||||
<div class="w-4 h-4 bg-white rounded-full absolute top-1 transition-transform {customConsent.analytics ? 'translate-x-4' : 'translate-x-1'}"></div>
|
||||
<div
|
||||
class="h-6 w-10 rounded-full transition-colors {customConsent.analytics
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.analytics
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-1'}"
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -197,22 +223,28 @@
|
|||
</div>
|
||||
|
||||
<!-- Marketing Cookies -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
Marketing Cookies
|
||||
</h4>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">Marketing Cookies</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Für personalisierte Inhalte und Werbung
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onclick={() => handleCustomChange('marketing', !customConsent.marketing)}
|
||||
class="relative"
|
||||
>
|
||||
<div class="w-10 h-6 rounded-full transition-colors {customConsent.marketing ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
|
||||
<div class="w-4 h-4 bg-white rounded-full absolute top-1 transition-transform {customConsent.marketing ? 'translate-x-4' : 'translate-x-1'}"></div>
|
||||
<div
|
||||
class="h-6 w-10 rounded-full transition-colors {customConsent.marketing
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.marketing
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-1'}"
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -222,22 +254,28 @@
|
|||
</div>
|
||||
|
||||
<!-- Preferences Cookies -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
Präferenz Cookies
|
||||
</h4>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">Präferenz Cookies</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Speichern Ihre persönlichen Einstellungen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onclick={() => handleCustomChange('preferences', !customConsent.preferences)}
|
||||
class="relative"
|
||||
>
|
||||
<div class="w-10 h-6 rounded-full transition-colors {customConsent.preferences ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}">
|
||||
<div class="w-4 h-4 bg-white rounded-full absolute top-1 transition-transform {customConsent.preferences ? 'translate-x-4' : 'translate-x-1'}"></div>
|
||||
<div
|
||||
class="h-6 w-10 rounded-full transition-colors {customConsent.preferences
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white transition-transform {customConsent.preferences
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-1'}"
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -248,22 +286,24 @@
|
|||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t border-gray-200 pt-4 dark:border-gray-700 sm:flex-row"
|
||||
>
|
||||
<button
|
||||
onclick={handleAcceptNecessary}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
class="flex-1 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Nur notwendige Cookies
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onclick={handleSaveCustom}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Auswahl speichern
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
onclick={handleAcceptAll}
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||
class="flex-1 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
|
|
@ -279,10 +319,10 @@
|
|||
.toggle-switch {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,49 @@
|
|||
<script lang="ts">
|
||||
import type { BlogPostWithMeta } from '../../../content/config';
|
||||
|
||||
|
||||
let { posts = [] } = $props<{ posts?: BlogPostWithMeta[] }>();
|
||||
|
||||
|
||||
let formattedPosts = $derived(
|
||||
posts.slice(0, 3).map(post => ({
|
||||
posts.slice(0, 3).map((post) => ({
|
||||
...post,
|
||||
formattedDate: new Date(post.date).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
day: 'numeric',
|
||||
}),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="py-16 bg-theme-surface">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-4">
|
||||
Insights & Wissen
|
||||
</h2>
|
||||
<p class="text-lg text-theme-text-muted max-w-2xl mx-auto">
|
||||
Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für erfolgreiches Link-Management.
|
||||
<section class="bg-theme-surface py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-12 text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-theme-text">Insights & Wissen</h2>
|
||||
<p class="mx-auto max-w-2xl text-lg text-theme-text-muted">
|
||||
Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für
|
||||
erfolgreiches Link-Management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{#if formattedPosts.length > 0}
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-8">
|
||||
<div class="mb-8 grid gap-8 md:grid-cols-3">
|
||||
{#each formattedPosts as post}
|
||||
<article class="bg-theme-background rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<article
|
||||
class="overflow-hidden rounded-lg bg-theme-background shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
{#if post.image}
|
||||
<img
|
||||
src={post.image}
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
class="w-full h-48 object-cover"
|
||||
class="h-48 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-48 bg-gradient-to-br from-blue-500 to-purple-600"></div>
|
||||
<div class="h-48 w-full bg-gradient-to-br from-blue-500 to-purple-600"></div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span class="text-xs text-theme-text-muted">
|
||||
{post.formattedDate}
|
||||
</span>
|
||||
|
|
@ -51,24 +52,29 @@
|
|||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-2">
|
||||
<a href="/blog/{post.slug}" class="hover:text-theme-primary transition">
|
||||
|
||||
<h3 class="mb-2 text-xl font-semibold text-theme-text">
|
||||
<a href="/blog/{post.slug}" class="transition hover:text-theme-primary">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="text-theme-text-muted mb-4 line-clamp-2">
|
||||
|
||||
<p class="mb-4 line-clamp-2 text-theme-text-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<a
|
||||
|
||||
<a
|
||||
href="/blog/{post.slug}"
|
||||
class="text-theme-primary hover:text-theme-primary-hover font-medium inline-flex items-center gap-1"
|
||||
class="inline-flex items-center gap-1 font-medium text-theme-primary hover:text-theme-primary-hover"
|
||||
>
|
||||
Weiterlesen
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -76,21 +82,26 @@
|
|||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-theme-text-muted mb-4">
|
||||
<div class="py-12 text-center">
|
||||
<p class="mb-4 text-theme-text-muted">
|
||||
Bald verfügbar: Spannende Artikel über URL-Optimierung und digitales Marketing.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
<a
|
||||
href="/blog"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-theme-primary text-white rounded-lg hover:bg-theme-primary-hover transition"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 text-white transition hover:bg-theme-primary-hover"
|
||||
>
|
||||
Alle Artikel ansehen
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
<svg 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="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -104,4 +115,4 @@
|
|||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let selectedFeature = $state<'links' | 'cards' | 'analytics' | 'qr' | 'team' | 'templates'>('links');
|
||||
|
||||
let selectedFeature = $state<'links' | 'cards' | 'analytics' | 'qr' | 'team' | 'templates'>(
|
||||
'links'
|
||||
);
|
||||
let animationKey = $state(0);
|
||||
|
||||
|
||||
$effect(() => {
|
||||
// Trigger re-animation when feature changes
|
||||
animationKey++;
|
||||
|
|
@ -25,126 +27,258 @@
|
|||
<!-- Feature Navigation -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
onclick={() => selectedFeature = 'links'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'links'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'links')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'links'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'links' ? 'bg-white/20' : 'bg-theme-primary/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'links' ? 'text-white' : 'text-theme-primary'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
|
||||
'links'
|
||||
? 'bg-white/20'
|
||||
: 'bg-theme-primary/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'links' ? 'text-white' : 'text-theme-primary'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'links' ? 'text-white' : 'text-theme-text'}">
|
||||
<h3
|
||||
class="font-semibold {selectedFeature === 'links' ? 'text-white' : 'text-theme-text'}"
|
||||
>
|
||||
Smart Link Management
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'links' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'links'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
Kurze URLs mit erweiterten Features
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => selectedFeature = 'cards'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'cards'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'cards')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'cards'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'cards' ? 'bg-white/20' : 'bg-purple-600/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'cards' ? 'text-white' : 'text-purple-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
|
||||
'cards'
|
||||
? 'bg-white/20'
|
||||
: 'bg-purple-600/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'cards' ? 'text-white' : 'text-purple-600'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'cards' ? 'text-white' : 'text-theme-text'}">
|
||||
<h3
|
||||
class="font-semibold {selectedFeature === 'cards' ? 'text-white' : 'text-theme-text'}"
|
||||
>
|
||||
Profilkarten Builder
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'cards' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'cards'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
3-Stufen Builder mit Live-Preview
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => selectedFeature = 'analytics'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'analytics'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'analytics')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'analytics'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'analytics' ? 'bg-white/20' : 'bg-blue-600/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'analytics' ? 'text-white' : 'text-blue-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
|
||||
'analytics'
|
||||
? 'bg-white/20'
|
||||
: 'bg-blue-600/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'analytics' ? 'text-white' : 'text-blue-600'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'analytics' ? 'text-white' : 'text-theme-text'}">
|
||||
<h3
|
||||
class="font-semibold {selectedFeature === 'analytics'
|
||||
? 'text-white'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
Professionelle Analytics
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'analytics' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'analytics'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
Echtzeit-Tracking und Insights
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => selectedFeature = 'qr'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'qr'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'qr')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'qr'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'qr' ? 'bg-white/20' : 'bg-green-600/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'qr' ? 'text-white' : 'text-green-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'qr'
|
||||
? 'bg-white/20'
|
||||
: 'bg-green-600/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'qr' ? 'text-white' : 'text-green-600'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'qr' ? 'text-white' : 'text-theme-text'}">
|
||||
QR-Code Generator
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'qr' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'qr'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
Anpassbare Designs und Farben
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => selectedFeature = 'team'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'team'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'team')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'team'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'team' ? 'bg-white/20' : 'bg-indigo-600/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'team' ? 'text-white' : 'text-indigo-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'team'
|
||||
? 'bg-white/20'
|
||||
: 'bg-indigo-600/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'team' ? 'text-white' : 'text-indigo-600'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'team' ? 'text-white' : 'text-theme-text'}">
|
||||
<h3
|
||||
class="font-semibold {selectedFeature === 'team' ? 'text-white' : 'text-theme-text'}"
|
||||
>
|
||||
Team Kollaboration
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'team' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'team'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
Workspaces und Berechtigungen
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => selectedFeature = 'templates'}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature === 'templates'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (selectedFeature = 'templates')}
|
||||
class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
|
||||
'templates'
|
||||
? 'bg-theme-primary text-white shadow-lg'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature === 'templates' ? 'bg-white/20' : 'bg-pink-600/10'}">
|
||||
<svg class="h-6 w-6 {selectedFeature === 'templates' ? 'text-white' : 'text-pink-600'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg {selectedFeature ===
|
||||
'templates'
|
||||
? 'bg-white/20'
|
||||
: 'bg-pink-600/10'}"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 {selectedFeature === 'templates' ? 'text-white' : 'text-pink-600'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold {selectedFeature === 'templates' ? 'text-white' : 'text-theme-text'}">
|
||||
<h3
|
||||
class="font-semibold {selectedFeature === 'templates'
|
||||
? 'text-white'
|
||||
: 'text-theme-text'}"
|
||||
>
|
||||
Template Store
|
||||
</h3>
|
||||
<p class="mt-1 text-sm {selectedFeature === 'templates' ? 'text-white/80' : 'text-theme-text-muted'}">
|
||||
<p
|
||||
class="mt-1 text-sm {selectedFeature === 'templates'
|
||||
? 'text-white/80'
|
||||
: 'text-theme-text-muted'}"
|
||||
>
|
||||
Vorgefertigte Designs und Layouts
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -183,7 +317,7 @@
|
|||
<span class="text-sm text-theme-text">Bulk-Operationen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 rounded-lg bg-theme-primary/10 p-3">
|
||||
<div class="bg-theme-primary/10 mt-6 rounded-lg p-3">
|
||||
<p class="text-xs text-theme-primary">
|
||||
Beispiel: ulo.ad/produkt-launch → 500 Clicks, läuft in 7 Tagen ab
|
||||
</p>
|
||||
|
|
@ -195,17 +329,27 @@
|
|||
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
|
||||
<h4 class="mb-4 text-lg font-semibold text-theme-text">3-Stufen Builder</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-green-500 bg-green-50 p-3 dark:bg-green-900/20">
|
||||
<div
|
||||
class="rounded-lg border border-green-500 bg-green-50 p-3 dark:bg-green-900/20"
|
||||
>
|
||||
<p class="font-medium text-green-700 dark:text-green-400">👶 Anfänger</p>
|
||||
<p class="mt-1 text-sm text-green-600 dark:text-green-300">Einfache Vorlagen, schnell anpassbar</p>
|
||||
<p class="mt-1 text-sm text-green-600 dark:text-green-300">
|
||||
Einfache Vorlagen, schnell anpassbar
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-blue-500 bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="font-medium text-blue-700 dark:text-blue-400">💪 Fortgeschritten</p>
|
||||
<p class="mt-1 text-sm text-blue-600 dark:text-blue-300">Drag & Drop Module, mehr Kontrolle</p>
|
||||
<p class="mt-1 text-sm text-blue-600 dark:text-blue-300">
|
||||
Drag & Drop Module, mehr Kontrolle
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<div
|
||||
class="rounded-lg border border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
|
||||
>
|
||||
<p class="font-medium text-purple-700 dark:text-purple-400">🚀 Experte</p>
|
||||
<p class="mt-1 text-sm text-purple-600 dark:text-purple-300">Volle Freiheit, eigener Code möglich</p>
|
||||
<p class="mt-1 text-sm text-purple-600 dark:text-purple-300">
|
||||
Volle Freiheit, eigener Code möglich
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,13 +361,13 @@
|
|||
<div class="space-y-4">
|
||||
<!-- Mini chart visualization -->
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="h-16 w-8 rounded bg-theme-primary/20"></div>
|
||||
<div class="h-24 w-8 rounded bg-theme-primary/40"></div>
|
||||
<div class="h-32 w-8 rounded bg-theme-primary/60"></div>
|
||||
<div class="h-28 w-8 rounded bg-theme-primary/80"></div>
|
||||
<div class="bg-theme-primary/20 h-16 w-8 rounded"></div>
|
||||
<div class="bg-theme-primary/40 h-24 w-8 rounded"></div>
|
||||
<div class="bg-theme-primary/60 h-32 w-8 rounded"></div>
|
||||
<div class="bg-theme-primary/80 h-28 w-8 rounded"></div>
|
||||
<div class="h-36 w-8 rounded bg-theme-primary"></div>
|
||||
<div class="h-30 w-8 rounded bg-theme-primary/90"></div>
|
||||
<div class="h-26 w-8 rounded bg-theme-primary/70"></div>
|
||||
<div class="h-30 bg-theme-primary/90 w-8 rounded"></div>
|
||||
<div class="h-26 bg-theme-primary/70 w-8 rounded"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-theme-surface p-3">
|
||||
|
|
@ -260,19 +404,25 @@
|
|||
<div class="flex justify-center">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2">
|
||||
<div
|
||||
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2"
|
||||
>
|
||||
<div class="h-full w-full rounded bg-black"></div>
|
||||
</div>
|
||||
<p class="text-xs text-theme-text">Schwarz</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-black p-2">
|
||||
<div
|
||||
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-black p-2"
|
||||
>
|
||||
<div class="h-full w-full rounded bg-white"></div>
|
||||
</div>
|
||||
<p class="text-xs text-theme-text">Weiß</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2">
|
||||
<div
|
||||
class="mb-2 flex h-24 w-24 items-center justify-center rounded-lg bg-white p-2"
|
||||
>
|
||||
<div class="h-full w-full rounded bg-yellow-500"></div>
|
||||
</div>
|
||||
<p class="text-xs text-theme-text">Gold</p>
|
||||
|
|
@ -282,9 +432,15 @@
|
|||
<div class="mt-4 space-y-2">
|
||||
<p class="text-sm font-medium text-theme-text">Formate:</p>
|
||||
<div class="flex gap-2">
|
||||
<span class="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">PNG</span>
|
||||
<span class="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">SVG</span>
|
||||
<span class="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">JPG</span>
|
||||
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
|
||||
>PNG</span
|
||||
>
|
||||
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
|
||||
>SVG</span
|
||||
>
|
||||
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary"
|
||||
>JPG</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -296,13 +452,16 @@
|
|||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-8 w-8 rounded-full bg-theme-primary/20"></div>
|
||||
<div class="bg-theme-primary/20 h-8 w-8 rounded-full"></div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme-text">Max Mustermann</p>
|
||||
<p class="text-xs text-theme-text-muted">Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-400">Full Access</span>
|
||||
<span
|
||||
class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 dark:bg-green-900/20 dark:text-green-400"
|
||||
>Full Access</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -312,7 +471,10 @@
|
|||
<p class="text-xs text-theme-text-muted">Editor</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">Edit Links</span>
|
||||
<span
|
||||
class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
|
||||
>Edit Links</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg bg-theme-surface p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
@ -322,7 +484,10 @@
|
|||
<p class="text-xs text-theme-text-muted">Viewer</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-900/20 dark:text-gray-400">View Only</span>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-900/20 dark:text-gray-400"
|
||||
>View Only</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -332,24 +497,34 @@
|
|||
<div class="rounded-xl border border-theme-border bg-theme-surface p-6 shadow-xl">
|
||||
<h4 class="mb-4 text-lg font-semibold text-theme-text">Template Gallery</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-theme-border bg-gradient-to-br from-pink-500/10 to-purple-500/10 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-theme-border bg-gradient-to-br from-pink-500/10 to-purple-500/10 p-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-theme-text">Creator Pro</p>
|
||||
<div class="h-20 rounded bg-white/50"></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme-border bg-gradient-to-br from-blue-500/10 to-cyan-500/10 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-theme-border bg-gradient-to-br from-blue-500/10 to-cyan-500/10 p-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-theme-text">Business</p>
|
||||
<div class="h-20 rounded bg-white/50"></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme-border bg-gradient-to-br from-green-500/10 to-emerald-500/10 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-theme-border bg-gradient-to-br from-green-500/10 to-emerald-500/10 p-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-theme-text">Restaurant</p>
|
||||
<div class="h-20 rounded bg-white/50"></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-theme-border bg-gradient-to-br from-indigo-500/10 to-purple-500/10 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-theme-border bg-gradient-to-br from-indigo-500/10 to-purple-500/10 p-4"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium text-theme-text">Portfolio</p>
|
||||
<div class="h-20 rounded bg-white/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mt-4 w-full rounded-lg bg-theme-primary/10 py-2 text-sm font-medium text-theme-primary hover:bg-theme-primary/20">
|
||||
<button
|
||||
class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-4 w-full rounded-lg py-2 text-sm font-medium text-theme-primary"
|
||||
>
|
||||
Alle Templates ansehen →
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -375,4 +550,4 @@
|
|||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@
|
|||
let inputUrl = $state('');
|
||||
</script>
|
||||
|
||||
<section class="relative overflow-hidden bg-gradient-to-br from-theme-primary/5 via-theme-background to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<section
|
||||
class="from-theme-primary/5 relative overflow-hidden bg-gradient-to-br via-theme-background to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-theme-primary/10 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl"></div>
|
||||
<div
|
||||
class="bg-theme-primary/10 absolute left-1/2 top-0 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 h-96 w-96 translate-x-1/3 translate-y-1/3 rounded-full bg-purple-600/10 blur-3xl"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
|
|
@ -20,19 +26,39 @@
|
|||
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-theme-text-muted">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
<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" />
|
||||
<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
|
||||
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>
|
||||
|
|
@ -41,25 +67,27 @@
|
|||
<!-- Main headline -->
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-theme-text sm:text-5xl lg:text-6xl">
|
||||
More than links.
|
||||
<span class="bg-gradient-to-r from-theme-primary to-purple-600 bg-clip-text text-transparent">
|
||||
<span
|
||||
class="bg-gradient-to-r from-theme-primary to-purple-600 bg-clip-text text-transparent"
|
||||
>
|
||||
Your digital identity.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="mx-auto mb-8 max-w-2xl text-lg text-theme-text-muted sm:text-xl">
|
||||
Der einzige Link-Shortener mit integriertem Profile-Builder.
|
||||
Erstelle kurze Links, beeindruckende Profilkarten und manage alles im Team.
|
||||
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
|
||||
<a
|
||||
href="#url-form"
|
||||
class="rounded-lg bg-theme-primary px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-theme-primary-hover hover:shadow-xl"
|
||||
>
|
||||
Kostenlos starten →
|
||||
</a>
|
||||
<a
|
||||
<a
|
||||
href="#features"
|
||||
class="rounded-lg border-2 border-theme-border bg-theme-surface px-8 py-3 font-semibold text-theme-text transition hover:border-theme-primary hover:shadow-lg"
|
||||
>
|
||||
|
|
@ -79,7 +107,7 @@
|
|||
isSubmitting = false;
|
||||
};
|
||||
}}
|
||||
class="flex flex-col gap-3 rounded-xl border border-theme-border bg-theme-surface/80 p-4 backdrop-blur sm:flex-row sm:p-2"
|
||||
class="bg-theme-surface/80 flex flex-col gap-3 rounded-xl border border-theme-border p-4 backdrop-blur sm:flex-row sm:p-2"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
|
|
@ -96,8 +124,19 @@
|
|||
>
|
||||
{#if isSubmitting}
|
||||
<svg class="mx-auto h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
Kürzen →
|
||||
|
|
@ -113,52 +152,77 @@
|
|||
<!-- Visual preview -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Link shortening preview -->
|
||||
<div class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-theme-primary/10">
|
||||
<svg class="h-6 w-6 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
<div
|
||||
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="bg-theme-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<svg
|
||||
class="h-6 w-6 text-theme-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-theme-text">Smart Links</h3>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
|
||||
</p>
|
||||
<div class="mt-4 text-xs text-theme-primary group-hover:underline">
|
||||
Mehr erfahren →
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-theme-primary group-hover:underline">Mehr erfahren →</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile cards preview -->
|
||||
<div class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
|
||||
<div
|
||||
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-600/10">
|
||||
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-theme-text">Profile Cards</h3>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Beeindruckende Profilseiten mit Drag & Drop Builder
|
||||
</p>
|
||||
<div class="mt-4 text-xs text-purple-600 group-hover:underline">
|
||||
Templates ansehen →
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-purple-600 group-hover:underline">Templates ansehen →</div>
|
||||
</div>
|
||||
|
||||
<!-- Team collaboration preview -->
|
||||
<div class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
|
||||
<div
|
||||
class="group relative rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-600/10">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-theme-text">Team Workspace</h3>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Gemeinsam Links verwalten mit granularen Berechtigungen
|
||||
</p>
|
||||
<div class="mt-4 text-xs text-green-600 group-hover:underline">
|
||||
Für Teams →
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-green-600 group-hover:underline">Für Teams →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
let billingCycle = $state<'monthly' | 'yearly'>('monthly');
|
||||
let hoveredPlan = $state<string | null>(null);
|
||||
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
|
|
@ -13,16 +13,12 @@
|
|||
'Basis Analytics',
|
||||
'QR-Code Generator',
|
||||
'Link Anpassung',
|
||||
'Standard Support'
|
||||
],
|
||||
limitations: [
|
||||
'Limitierte Links',
|
||||
'Keine API',
|
||||
'Standard Support'
|
||||
'Standard Support',
|
||||
],
|
||||
limitations: ['Limitierte Links', 'Keine API', 'Standard Support'],
|
||||
cta: 'Kostenlos starten',
|
||||
highlighted: false,
|
||||
color: 'gray'
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
|
|
@ -36,12 +32,12 @@
|
|||
'Link Anpassung',
|
||||
'Priority Support',
|
||||
'Keine Werbung',
|
||||
'API Zugang'
|
||||
'API Zugang',
|
||||
],
|
||||
limitations: [],
|
||||
cta: 'Pro wählen',
|
||||
highlighted: false,
|
||||
color: 'theme-primary'
|
||||
color: 'theme-primary',
|
||||
},
|
||||
{
|
||||
id: 'pro-yearly',
|
||||
|
|
@ -55,13 +51,13 @@
|
|||
'Link Anpassung',
|
||||
'Priority Support',
|
||||
'Keine Werbung',
|
||||
'API Zugang'
|
||||
'API Zugang',
|
||||
],
|
||||
limitations: [],
|
||||
cta: 'Jährlich sparen',
|
||||
highlighted: true,
|
||||
color: 'purple',
|
||||
badge: 'Spare 20€/Jahr'
|
||||
badge: 'Spare 20€/Jahr',
|
||||
},
|
||||
{
|
||||
id: 'lifetime',
|
||||
|
|
@ -75,24 +71,24 @@
|
|||
'Alle zukünftigen Features',
|
||||
'Priority Support',
|
||||
'Early Access zu neuen Features',
|
||||
'API Zugang'
|
||||
'API Zugang',
|
||||
],
|
||||
limitations: [],
|
||||
cta: 'Lifetime sichern',
|
||||
highlighted: false,
|
||||
color: 'indigo',
|
||||
badge: 'Einmalig'
|
||||
}
|
||||
badge: 'Einmalig',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: price % 1 === 0 ? 0 : 2
|
||||
minimumFractionDigits: price % 1 === 0 ? 0 : 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
|
||||
function getYearlySavings(monthly: number, yearly: number): number {
|
||||
return Math.round(((monthly * 12 - yearly) / (monthly * 12)) * 100);
|
||||
}
|
||||
|
|
@ -107,25 +103,28 @@
|
|||
<p class="mx-auto mb-8 max-w-2xl text-lg text-theme-text-muted">
|
||||
Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Billing Toggle -->
|
||||
<div class="mb-12 inline-flex items-center rounded-lg bg-theme-surface p-1">
|
||||
<button
|
||||
onclick={() => billingCycle = 'monthly'}
|
||||
class="rounded-md px-6 py-2 text-sm font-medium transition {billingCycle === 'monthly'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'text-theme-text hover:text-theme-text/80'}"
|
||||
onclick={() => (billingCycle = 'monthly')}
|
||||
class="rounded-md px-6 py-2 text-sm font-medium transition {billingCycle === 'monthly'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:text-theme-text/80 text-theme-text'}"
|
||||
>
|
||||
Monatlich
|
||||
</button>
|
||||
<button
|
||||
onclick={() => billingCycle = 'yearly'}
|
||||
class="relative rounded-md px-6 py-2 text-sm font-medium transition {billingCycle === 'yearly'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'text-theme-text hover:text-theme-text/80'}"
|
||||
onclick={() => (billingCycle = 'yearly')}
|
||||
class="relative rounded-md px-6 py-2 text-sm font-medium transition {billingCycle ===
|
||||
'yearly'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:text-theme-text/80 text-theme-text'}"
|
||||
>
|
||||
Jährlich
|
||||
<span class="absolute -right-12 -top-2 rounded bg-green-500 px-2 py-0.5 text-xs text-white">
|
||||
<span
|
||||
class="absolute -right-12 -top-2 rounded bg-green-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
-17%
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -135,29 +134,33 @@
|
|||
<!-- Pricing Cards -->
|
||||
<div class="grid gap-8 lg:grid-cols-4">
|
||||
{#each plans as plan}
|
||||
<div
|
||||
class="relative rounded-xl border-2 transition-all duration-300 {plan.highlighted
|
||||
? 'border-theme-primary shadow-2xl scale-105'
|
||||
: 'border-theme-border hover:border-theme-primary/50 hover:shadow-xl'} bg-theme-surface"
|
||||
onmouseenter={() => hoveredPlan = plan.id}
|
||||
onmouseleave={() => hoveredPlan = null}
|
||||
<div
|
||||
class="relative rounded-xl border-2 transition-all duration-300 {plan.highlighted
|
||||
? 'scale-105 border-theme-primary shadow-2xl'
|
||||
: 'hover:border-theme-primary/50 border-theme-border hover:shadow-xl'} bg-theme-surface"
|
||||
onmouseenter={() => (hoveredPlan = plan.id)}
|
||||
onmouseleave={() => (hoveredPlan = null)}
|
||||
>
|
||||
{#if plan.badge}
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<span class="rounded-full bg-theme-primary px-4 py-1 text-xs font-semibold text-white">
|
||||
<span
|
||||
class="rounded-full bg-theme-primary px-4 py-1 text-xs font-semibold text-white"
|
||||
>
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="mb-2 text-xl font-bold text-theme-text">{plan.name}</h3>
|
||||
<p class="mb-4 text-sm text-theme-text-muted">{plan.description}</p>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline">
|
||||
<span class="text-4xl font-bold text-theme-text">
|
||||
{formatPrice(billingCycle === 'monthly' ? plan.price.monthly : plan.price.yearly / 12)}
|
||||
{formatPrice(
|
||||
billingCycle === 'monthly' ? plan.price.monthly : plan.price.yearly / 12
|
||||
)}
|
||||
</span>
|
||||
<span class="ml-2 text-theme-text-muted">/Monat</span>
|
||||
</div>
|
||||
|
|
@ -167,34 +170,54 @@
|
|||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mb-6 w-full rounded-lg py-3 font-semibold transition {plan.highlighted
|
||||
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
|
||||
: 'bg-theme-surface border-2 border-theme-border text-theme-text hover:border-theme-primary hover:bg-theme-primary/5'}"
|
||||
|
||||
<button
|
||||
class="mb-6 w-full rounded-lg py-3 font-semibold transition {plan.highlighted
|
||||
? 'bg-theme-primary text-white hover:bg-theme-primary-hover'
|
||||
: 'hover:bg-theme-primary/5 border-2 border-theme-border bg-theme-surface text-theme-text hover:border-theme-primary'}"
|
||||
>
|
||||
{plan.cta}
|
||||
</button>
|
||||
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-theme-text-muted">
|
||||
Inklusive:
|
||||
</p>
|
||||
{#each plan.features as feature}
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-theme-text">{feature}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
|
||||
{#if plan.limitations.length > 0}
|
||||
<div class="mt-4 border-t border-theme-border pt-4">
|
||||
{#each plan.limitations as limitation}
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-theme-text-muted">{limitation}</span>
|
||||
</div>
|
||||
|
|
@ -236,10 +259,7 @@
|
|||
<p class="mb-4 text-theme-text">
|
||||
Benötigst du eine maßgeschneiderte Lösung für dein Unternehmen?
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
class="inline-flex items-center gap-2 text-theme-primary hover:underline"
|
||||
>
|
||||
<a href="/contact" class="inline-flex items-center gap-2 text-theme-primary hover:underline">
|
||||
Kontaktiere uns für Enterprise-Lösungen
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
|
|
@ -247,4 +267,4 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -16,34 +16,34 @@
|
|||
<!-- Tab Navigation -->
|
||||
<div class="mb-8 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
onclick={() => activeTab = 'creators'}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'creators'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (activeTab = 'creators')}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'creators'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
📱 Creators & Influencer
|
||||
</button>
|
||||
<button
|
||||
onclick={() => activeTab = 'teams'}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'teams'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (activeTab = 'teams')}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'teams'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
💼 Teams & Agenturen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => activeTab = 'business'}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'business'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (activeTab = 'business')}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'business'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
🏢 KMU & Startups
|
||||
</button>
|
||||
<button
|
||||
onclick={() => activeTab = 'events'}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'events'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'bg-theme-surface text-theme-text hover:bg-theme-surface/80'}"
|
||||
onclick={() => (activeTab = 'events')}
|
||||
class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'events'
|
||||
? 'bg-theme-primary text-white'
|
||||
: 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
|
||||
>
|
||||
🎯 Events & Gastro
|
||||
</button>
|
||||
|
|
@ -54,59 +54,109 @@
|
|||
{#if activeTab === 'creators'}
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">
|
||||
Ein Link für alle deine Kanäle
|
||||
</h3>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">Ein Link für alle deine Kanäle</h3>
|
||||
<p class="mb-6 text-theme-text-muted">
|
||||
Perfekt für Instagram, TikTok und YouTube. Erstelle beeindruckende Link-in-Bio Seiten,
|
||||
tracke deine Klicks und verstehe deine Audience besser.
|
||||
</p>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Anpassbare Profilseiten mit deinem Branding</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">QR-Codes für Offline-zu-Online Verbindung</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Detaillierte Analytics zu Klicks und Herkunft</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Social Media Icons und Integrationen</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<a href="/register" class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover">
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
|
||||
>
|
||||
Jetzt starten
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-4 rounded-xl bg-gradient-to-r from-pink-500/20 to-purple-500/20 blur-xl"></div>
|
||||
<img
|
||||
src="/images/creator-mockup.png"
|
||||
alt="Creator Profile Preview"
|
||||
<div
|
||||
class="absolute -inset-4 rounded-xl bg-gradient-to-r from-pink-500/20 to-purple-500/20 blur-xl"
|
||||
></div>
|
||||
<img
|
||||
src="/images/creator-mockup.png"
|
||||
alt="Creator Profile Preview"
|
||||
class="relative rounded-xl shadow-2xl"
|
||||
onerror={() => {}}
|
||||
/>
|
||||
<!-- Fallback illustration if image doesn't exist -->
|
||||
<div class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-pink-500 to-purple-600 text-white">
|
||||
<div
|
||||
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-pink-500 to-purple-600 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">📱</div>
|
||||
<p class="text-xl font-bold">Creator Profile</p>
|
||||
|
|
@ -121,50 +171,98 @@
|
|||
{#if activeTab === 'teams'}
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">
|
||||
Gemeinsam mehr erreichen
|
||||
</h3>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">Gemeinsam mehr erreichen</h3>
|
||||
<p class="mb-6 text-theme-text-muted">
|
||||
Perfekte Kollaboration für Marketing-Teams und Agenturen. Verwaltet Links gemeinsam,
|
||||
teilt Analytics und arbeitet effizienter zusammen.
|
||||
</p>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Team-Workspaces mit granularen Berechtigungen</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Multi-Client Management für Agenturen</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Gemeinsame Analytics und Reporting</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Bulk-Operationen und CSV-Import</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<a href="/register" class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover">
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
|
||||
>
|
||||
Team Plan wählen
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white">
|
||||
<div
|
||||
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-600 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">💼</div>
|
||||
<p class="text-xl font-bold">Team Dashboard</p>
|
||||
|
|
@ -178,50 +276,98 @@
|
|||
{#if activeTab === 'business'}
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">
|
||||
Professionelles Link-Management
|
||||
</h3>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">Professionelles Link-Management</h3>
|
||||
<p class="mb-6 text-theme-text-muted">
|
||||
Die kostengünstige Alternative zu Enterprise-Lösungen. Perfekt für KMUs und Startups,
|
||||
die ihre digitale Präsenz professionell verwalten wollen.
|
||||
</p>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Custom Domains für deine Marke (coming soon)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">API-Zugang für Automatisierung</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Erweiterte Analytics und Exporte</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">DSGVO-konform und hosted in Germany</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<a href="/register" class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover">
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
|
||||
>
|
||||
Business Plan starten
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
|
||||
<div
|
||||
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">🏢</div>
|
||||
<p class="text-xl font-bold">Enterprise Ready</p>
|
||||
|
|
@ -235,50 +381,98 @@
|
|||
{#if activeTab === 'events'}
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">
|
||||
QR-Codes die funktionieren
|
||||
</h3>
|
||||
<h3 class="mb-4 text-2xl font-bold text-theme-text">QR-Codes die funktionieren</h3>
|
||||
<p class="mb-6 text-theme-text-muted">
|
||||
Ideal für Restaurants, Events und Veranstaltungen. Erstelle QR-Codes für Speisekarten,
|
||||
Event-Infos oder zeitlich begrenzte Aktionen.
|
||||
</p>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">QR-Codes in verschiedenen Farben und Formaten</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Zeitlich begrenzte Links für Aktionen</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Passwortgeschützte Inhalte für VIPs</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-theme-text">Echtzeit-Updates ohne QR-Code Neudruck</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<a href="/register" class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover">
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-6 py-3 font-medium text-white transition hover:bg-theme-primary-hover"
|
||||
>
|
||||
QR-Codes erstellen
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white">
|
||||
<div
|
||||
class="relative flex h-96 w-80 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-emerald-600 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">🎯</div>
|
||||
<p class="text-xl font-bold">Event QR-Codes</p>
|
||||
|
|
@ -290,4 +484,4 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -5,71 +5,75 @@
|
|||
name: 'Sarah M.',
|
||||
role: 'Content Creator',
|
||||
avatar: '👩🎨',
|
||||
content: 'Uload hat meine Social Media Präsenz komplett transformiert. Die Profilkarten sind genial und die Analytics helfen mir, meine Audience besser zu verstehen.',
|
||||
content:
|
||||
'Uload hat meine Social Media Präsenz komplett transformiert. Die Profilkarten sind genial und die Analytics helfen mir, meine Audience besser zu verstehen.',
|
||||
rating: 5,
|
||||
platform: 'Instagram'
|
||||
platform: 'Instagram',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Michael K.',
|
||||
role: 'Marketing Manager',
|
||||
avatar: '👨💼',
|
||||
content: 'Endlich ein Tool, das Link-Management und Team-Kollaboration perfekt vereint. Die API-Integration war super einfach.',
|
||||
content:
|
||||
'Endlich ein Tool, das Link-Management und Team-Kollaboration perfekt vereint. Die API-Integration war super einfach.',
|
||||
rating: 5,
|
||||
platform: 'LinkedIn'
|
||||
platform: 'LinkedIn',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Lisa T.',
|
||||
role: 'Restaurant-Inhaberin',
|
||||
avatar: '👩🍳',
|
||||
content: 'Die QR-Codes für unsere Speisekarte sind ein Game-Changer. Änderungen sind sofort live, ohne neue Codes drucken zu müssen.',
|
||||
content:
|
||||
'Die QR-Codes für unsere Speisekarte sind ein Game-Changer. Änderungen sind sofort live, ohne neue Codes drucken zu müssen.',
|
||||
rating: 5,
|
||||
platform: 'Google'
|
||||
platform: 'Google',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Tom S.',
|
||||
role: 'Freelance Designer',
|
||||
avatar: '🎨',
|
||||
content: 'Der Card-Builder ist intuitiv und die Templates sparen mir Stunden. Meine Kunden lieben die professionellen Profilseiten.',
|
||||
content:
|
||||
'Der Card-Builder ist intuitiv und die Templates sparen mir Stunden. Meine Kunden lieben die professionellen Profilseiten.',
|
||||
rating: 5,
|
||||
platform: 'Twitter'
|
||||
platform: 'Twitter',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Anna B.',
|
||||
role: 'Event Managerin',
|
||||
avatar: '🎭',
|
||||
content: 'Perfekt für Events! Zeitlich begrenzte Links und Passwortschutz für VIP-Bereiche. Genau was wir gebraucht haben.',
|
||||
content:
|
||||
'Perfekt für Events! Zeitlich begrenzte Links und Passwortschutz für VIP-Bereiche. Genau was wir gebraucht haben.',
|
||||
rating: 5,
|
||||
platform: 'Trustpilot'
|
||||
platform: 'Trustpilot',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'David R.',
|
||||
role: 'Startup Founder',
|
||||
avatar: '🚀',
|
||||
content: 'Preis-Leistung ist unschlagbar. Wir haben von Bitly gewechselt und sparen 70% bei mehr Features.',
|
||||
content:
|
||||
'Preis-Leistung ist unschlagbar. Wir haben von Bitly gewechselt und sparen 70% bei mehr Features.',
|
||||
rating: 5,
|
||||
platform: 'ProductHunt'
|
||||
}
|
||||
platform: 'ProductHunt',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ label: 'Beta Launch', value: '2024', icon: '🚀' },
|
||||
{ label: 'Made in', value: 'Germany', icon: '🇩🇪' },
|
||||
{ label: 'Support Response', value: '<2h', icon: '⚡' },
|
||||
{ label: 'Uptime', value: '99.9%', icon: '✅' }
|
||||
{ label: 'Uptime', value: '99.9%', icon: '✅' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="testimonials" class="bg-theme-surface/50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">
|
||||
Was Beta-Tester sagen
|
||||
</h2>
|
||||
<h2 class="mb-4 text-3xl font-bold text-theme-text sm:text-4xl">Was Beta-Tester sagen</h2>
|
||||
<p class="mx-auto mb-12 max-w-2xl text-lg text-theme-text-muted">
|
||||
Erste Stimmen aus unserem exklusiven Beta-Programm
|
||||
</p>
|
||||
|
|
@ -89,10 +93,14 @@
|
|||
<!-- Testimonials Grid -->
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
{#each testimonials as testimonial}
|
||||
<div class="group rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl">
|
||||
<div
|
||||
class="group rounded-xl border border-theme-border bg-theme-surface p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-theme-primary/10 text-2xl">
|
||||
<div
|
||||
class="bg-theme-primary/10 flex h-12 w-12 items-center justify-center rounded-full text-2xl"
|
||||
>
|
||||
{testimonial.avatar}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -100,19 +108,21 @@
|
|||
<p class="text-sm text-theme-text-muted">{testimonial.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded bg-theme-primary/10 px-2 py-1 text-xs text-theme-primary">
|
||||
<span class="bg-theme-primary/10 rounded px-2 py-1 text-xs text-theme-primary">
|
||||
{testimonial.platform}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-4 flex gap-1">
|
||||
{#each Array(testimonial.rating) as _}
|
||||
<svg class="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<p class="text-theme-text-muted">
|
||||
"{testimonial.content}"
|
||||
</p>
|
||||
|
|
@ -121,7 +131,9 @@
|
|||
</div>
|
||||
|
||||
<!-- Use Cases -->
|
||||
<div class="mt-16 rounded-xl border border-theme-border bg-gradient-to-r from-theme-primary/5 to-purple-600/5 p-8">
|
||||
<div
|
||||
class="from-theme-primary/5 mt-16 rounded-xl border border-theme-border bg-gradient-to-r to-purple-600/5 p-8"
|
||||
>
|
||||
<h3 class="mb-8 text-center text-2xl font-bold text-theme-text">
|
||||
Perfekt für diese Use Cases
|
||||
</h3>
|
||||
|
|
@ -137,8 +149,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem
|
||||
Drag & Drop Builder und tracke jeden Klick in Echtzeit.
|
||||
Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem Drag &
|
||||
Drop Builder und tracke jeden Klick in Echtzeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -153,8 +165,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte
|
||||
ohne neue Codes drucken zu müssen.
|
||||
QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte ohne neue
|
||||
Codes drucken zu müssen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -169,8 +181,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau,
|
||||
welche Kanäle am besten performen.
|
||||
Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau, welche
|
||||
Kanäle am besten performen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -185,8 +197,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-theme-text-muted">
|
||||
Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für
|
||||
exklusive Inhalte und VIP-Bereiche.
|
||||
Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für exklusive
|
||||
Inhalte und VIP-Bereiche.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -194,11 +206,9 @@
|
|||
|
||||
<!-- CTA -->
|
||||
<div class="mt-12 text-center">
|
||||
<p class="mb-6 text-lg text-theme-text">
|
||||
Sei einer der Ersten - starte jetzt kostenlos!
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
<p class="mb-6 text-lg text-theme-text">Sei einer der Ersten - starte jetzt kostenlos!</p>
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-theme-primary-hover hover:shadow-xl"
|
||||
>
|
||||
Beta-Zugang sichern
|
||||
|
|
@ -208,4 +218,4 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue