mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(news): migrate from archive to local-first + Hono architecture
- Move from apps-archived/ to apps/ - Delete NestJS API, Docker files, old docs, browser extension - Create Hono/Bun server with content extraction (Mozilla Readability) and AI feed API reading from mana-sync's sync_changes - Create local-first store (articles, categories) with guest seed data - Rewrite web app: Feed page, Saved articles with URL extraction, auth pages using shared-auth-ui, AuthGate with guest mode - Add news to shared-branding (app icon, mana-apps registry) - Add CLAUDE.md, dev scripts, root CLAUDE.md entry - 0 type errors on both server and web Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a48182677
commit
4d390be5af
574 changed files with 1385 additions and 100253 deletions
|
|
@ -56,6 +56,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **traces** | City exploration | Backend, Mobile |
|
||||
| **taktik** | Time tracking | Web |
|
||||
| **uload** | URL shortener & link management | Server, Web, Landing |
|
||||
| **news** | AI news reader & personal library | Server, Web, Landing |
|
||||
| **calc** | Calculator & converter | Web |
|
||||
| **playground** | LLM playground | Web |
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,187 +0,0 @@
|
|||
# News Hub
|
||||
|
||||
A unified news reading platform combining AI-curated news with personal article saving capabilities.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
news/
|
||||
├── apps/
|
||||
│ ├── mobile/ # React Native/Expo App
|
||||
│ └── api/ # NestJS Backend
|
||||
├── packages/
|
||||
│ ├── database/ # Drizzle ORM Schema
|
||||
│ ├── shared/ # Shared utilities
|
||||
│ └── browser-extension/ # Chrome Extension
|
||||
└── docker/ # PostgreSQL Docker setup
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| ------------ | --------------------------- |
|
||||
| **Database** | PostgreSQL 16 (Docker) |
|
||||
| **ORM** | Drizzle |
|
||||
| **Backend** | NestJS + Fastify |
|
||||
| **Auth** | Custom JWT Auth |
|
||||
| **Mobile** | React Native / Expo |
|
||||
| **State** | Zustand |
|
||||
| **Styling** | NativeWind (Tailwind) |
|
||||
| **Monorepo** | pnpm workspaces + Turborepo |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Docker Desktop
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Start PostgreSQL
|
||||
pnpm docker:up
|
||||
|
||||
# 3. Push database schema
|
||||
pnpm db:push
|
||||
|
||||
# 4. Start API server
|
||||
pnpm dev:api
|
||||
|
||||
# 5. Start mobile app (in another terminal)
|
||||
pnpm dev:mobile
|
||||
```
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Start all services
|
||||
pnpm dev:api # Start API only
|
||||
pnpm dev:mobile # Start mobile app only
|
||||
|
||||
# Database
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:generate # Generate migrations
|
||||
pnpm db:migrate # Run migrations
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
|
||||
# Docker
|
||||
pnpm docker:up # Start PostgreSQL
|
||||
pnpm docker:down # Stop PostgreSQL
|
||||
pnpm docker:logs # View logs
|
||||
|
||||
# Build
|
||||
pnpm build # Build all packages
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql://news:news_dev_password@localhost:5432/news_hub
|
||||
|
||||
# API
|
||||
API_PORT=3000
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# Better Auth Secret
|
||||
BETTER_AUTH_SECRET=your-secret-key
|
||||
|
||||
# Mobile App
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### News Feed (AI-Generated)
|
||||
|
||||
- **Feed**: Quick news updates with infinite scroll
|
||||
- **Summaries**: 4 daily summaries (morning, noon, evening, night)
|
||||
- **In-Depth**: Detailed analysis articles
|
||||
|
||||
### Personal Library (Read Later)
|
||||
|
||||
- Save articles from any URL
|
||||
- Browser extension for one-click saving
|
||||
- Content extraction with Readability
|
||||
- Archive and organize articles
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
|
||||
- `POST /auth/signup` - Create account
|
||||
- `POST /auth/signin` - Sign in
|
||||
- `POST /auth/signout` - Sign out
|
||||
- `GET /auth/session` - Get current session
|
||||
|
||||
### Articles
|
||||
|
||||
- `GET /articles` - Get AI articles (public)
|
||||
- `GET /articles/:id` - Get single article
|
||||
- `GET /articles/saved/list` - Get saved articles (auth required)
|
||||
- `POST /articles/:id/archive` - Archive article
|
||||
- `DELETE /articles/:id` - Delete article
|
||||
|
||||
### Content Extraction
|
||||
|
||||
- `POST /extract/save` - Save article from URL (auth required)
|
||||
- `POST /extract/preview` - Preview URL extraction (public)
|
||||
|
||||
### Categories
|
||||
|
||||
- `GET /categories` - Get all categories
|
||||
|
||||
### Users
|
||||
|
||||
- `GET /users/me` - Get current user
|
||||
- `PATCH /users/me` - Update profile
|
||||
- `PATCH /users/me/onboarding` - Complete onboarding
|
||||
|
||||
## Browser Extension
|
||||
|
||||
The browser extension is located in `packages/browser-extension/`.
|
||||
|
||||
### Installation (Development)
|
||||
|
||||
1. Go to `chrome://extensions/`
|
||||
2. Enable "Developer mode"
|
||||
3. Click "Load unpacked"
|
||||
4. Select the `packages/browser-extension` folder
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
- `users` - User accounts and preferences
|
||||
- `articles` - All articles (AI-generated and user-saved)
|
||||
- `categories` - Article categories
|
||||
- `user_article_interactions` - Reading progress, ratings, bookmarks
|
||||
- `sessions` - Auth sessions
|
||||
- `accounts` - Auth providers
|
||||
- `verifications` - Email verification tokens
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a new API endpoint
|
||||
|
||||
1. Create service in `apps/api/src/{module}/{module}.service.ts`
|
||||
2. Create controller in `apps/api/src/{module}/{module}.controller.ts`
|
||||
3. Add module to `app.module.ts`
|
||||
|
||||
### Adding a new database table
|
||||
|
||||
1. Create schema in `packages/database/src/schema/{table}.ts`
|
||||
2. Export from `packages/database/src/schema/index.ts`
|
||||
3. Run `pnpm db:push` to update database
|
||||
|
||||
## License
|
||||
|
||||
Private
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{
|
||||
"name": "@news/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-fastify": "^10.4.0",
|
||||
"@manacore/news-database": "workspace:*",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"postgres": "^3.4.5",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.2.0",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ArticlesModule } from './articles/articles.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ContentExtractionModule } from './content-extraction/content-extraction.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '../../.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
ArticlesModule,
|
||||
CategoriesModule,
|
||||
UsersModule,
|
||||
ContentExtractionModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
constructor(private articlesService: ArticlesService) {}
|
||||
|
||||
// Public: Get AI-generated articles
|
||||
@Get()
|
||||
async getArticles(
|
||||
@Query('type') type?: 'feed' | 'summary' | 'in_depth',
|
||||
@Query('categoryId') categoryId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string
|
||||
) {
|
||||
return this.articlesService.getAIArticles({
|
||||
type,
|
||||
categoryId,
|
||||
limit: limit ? parseInt(limit, 10) : 20,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Public: Get single article
|
||||
@Get(':id')
|
||||
async getArticle(@Param('id') id: string) {
|
||||
const article = await this.articlesService.getArticleById(id);
|
||||
if (!article) {
|
||||
return { error: 'Article not found' };
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
// Protected: Get user's saved articles
|
||||
@Get('saved/list')
|
||||
@UseGuards(AuthGuard)
|
||||
async getSavedArticles(
|
||||
@CurrentUser() user: User,
|
||||
@Query('includeArchived') includeArchived?: string
|
||||
) {
|
||||
return this.articlesService.getSavedArticles(user.id, includeArchived === 'true');
|
||||
}
|
||||
|
||||
// Protected: Archive article
|
||||
@Post(':id/archive')
|
||||
@UseGuards(AuthGuard)
|
||||
async archiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.archiveArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Protected: Unarchive article
|
||||
@Post(':id/unarchive')
|
||||
@UseGuards(AuthGuard)
|
||||
async unarchiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.unarchiveArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Protected: Delete article
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard)
|
||||
async deleteArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.deleteArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
exports: [ArticlesService],
|
||||
})
|
||||
export class ArticlesModule {}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, articles, Article, eq, and, desc } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
// Get AI-generated articles (feed, summary, in_depth)
|
||||
async getAIArticles(options: {
|
||||
type?: 'feed' | 'summary' | 'in_depth';
|
||||
categoryId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
const { type, categoryId, limit = 20, offset = 0 } = options;
|
||||
|
||||
const conditions = [eq(articles.sourceOrigin, 'ai')];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(articles.type, type));
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
conditions.push(eq(articles.categoryId, categoryId));
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(articles.publishedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
// Get user-saved articles
|
||||
async getSavedArticles(userId: string, includeArchived = false): Promise<Article[]> {
|
||||
const conditions = [eq(articles.sourceOrigin, 'user_saved'), eq(articles.userId, userId)];
|
||||
|
||||
if (!includeArchived) {
|
||||
conditions.push(eq(articles.isArchived, false));
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(articles.createdAt));
|
||||
}
|
||||
|
||||
// Get single article by ID
|
||||
async getArticleById(articleId: string): Promise<Article | null> {
|
||||
const [article] = await this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(eq(articles.id, articleId))
|
||||
.limit(1);
|
||||
|
||||
return article || null;
|
||||
}
|
||||
|
||||
// Create a saved article
|
||||
async createSavedArticle(data: {
|
||||
userId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
parsedContent: string;
|
||||
originalUrl: string;
|
||||
author?: string;
|
||||
imageUrl?: string;
|
||||
}): Promise<Article> {
|
||||
const wordCount = data.content.split(/\s+/).length;
|
||||
const readingTimeMinutes = Math.ceil(wordCount / 200);
|
||||
|
||||
const [article] = await this.database
|
||||
.insert(articles)
|
||||
.values({
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
userId: data.userId,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
parsedContent: data.parsedContent,
|
||||
originalUrl: data.originalUrl,
|
||||
author: data.author,
|
||||
imageUrl: data.imageUrl,
|
||||
wordCount,
|
||||
readingTimeMinutes,
|
||||
isArchived: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
// Archive an article
|
||||
async archiveArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.update(articles)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Unarchive an article
|
||||
async unarchiveArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.update(articles)
|
||||
.set({ isArchived: false, updatedAt: new Date() })
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an article
|
||||
async deleteArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.delete(articles)
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Controller, Post, Get, Body, Headers, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
class SignUpDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
class SignInDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('signup')
|
||||
async signUp(@Body() body: SignUpDto) {
|
||||
const result = await this.authService.signUp(body.email, body.password, body.name);
|
||||
return {
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
},
|
||||
token: result.token,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signin')
|
||||
async signIn(@Body() body: SignInDto) {
|
||||
const result = await this.authService.signIn(body.email, body.password);
|
||||
return {
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
},
|
||||
token: result.token,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signout')
|
||||
async signOut(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
await this.authService.signOut(token);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('session')
|
||||
async getSession(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const session = await this.authService.getSession(token);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { Injectable, Inject, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, users, sessions, accounts, eq, and, User } from '@manacore/news-database';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private database: Database,
|
||||
private configService: ConfigService
|
||||
) {}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, storedHash: string): boolean {
|
||||
const [salt, hash] = storedHash.split(':');
|
||||
const verifyHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return hash === verifyHash;
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
async signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
name?: string
|
||||
): Promise<{ user: User; token: string }> {
|
||||
// Check if user exists
|
||||
const existingUser = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
throw new ConflictException('User already exists');
|
||||
}
|
||||
|
||||
// Create user
|
||||
const [user] = await this.database
|
||||
.insert(users)
|
||||
.values({
|
||||
email: email.toLowerCase(),
|
||||
name: name || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create account with password
|
||||
await this.database.insert(accounts).values({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
accountId: email.toLowerCase(),
|
||||
password: this.hashPassword(password),
|
||||
});
|
||||
|
||||
// Create session
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await this.database.insert(sessions).values({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<{ user: User; token: string }> {
|
||||
// Find user
|
||||
const [user] = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Find account
|
||||
const [account] = await this.database
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, user.id), eq(accounts.providerId, 'credential')))
|
||||
.limit(1);
|
||||
|
||||
if (!account || !account.password) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!this.verifyPassword(password, account.password)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Create session
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await this.database.insert(sessions).values({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async signOut(token: string): Promise<void> {
|
||||
await this.database.delete(sessions).where(eq(sessions.token, token));
|
||||
}
|
||||
|
||||
async validateSession(token: string): Promise<{ user: User; session: any } | null> {
|
||||
const [session] = await this.database
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!session || new Date(session.expiresAt) < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
async getSession(token: string): Promise<{ user: User } | null> {
|
||||
const result = await this.validateSession(token);
|
||||
if (!result) return null;
|
||||
return { user: result.user };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
async getAllCategories() {
|
||||
return this.categoriesService.getAllCategories();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, categories, Category, asc } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
async getAllCategories(): Promise<Category[]> {
|
||||
return this.database.select().from(categories).orderBy(asc(categories.priority));
|
||||
}
|
||||
|
||||
async createCategory(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
priority?: number;
|
||||
}): Promise<Category> {
|
||||
const [category] = await this.database.insert(categories).values(data).returning();
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const session = await this.authService.validateSession(token);
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
request.user = session.user;
|
||||
request.session = session;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ContentExtractionService } from './content-extraction.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
import { IsUrl } from 'class-validator';
|
||||
|
||||
class ExtractUrlDto {
|
||||
@IsUrl()
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Controller('extract')
|
||||
export class ContentExtractionController {
|
||||
constructor(private contentExtractionService: ContentExtractionService) {}
|
||||
|
||||
// Protected: Save article from URL
|
||||
@Post('save')
|
||||
@UseGuards(AuthGuard)
|
||||
async saveFromUrl(@Body() body: ExtractUrlDto, @CurrentUser() user: User) {
|
||||
const article = await this.contentExtractionService.saveArticleFromUrl(user.id, body.url);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
article: {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
createdAt: article.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Public: Preview URL extraction (without saving)
|
||||
@Post('preview')
|
||||
async previewUrl(@Body() body: ExtractUrlDto) {
|
||||
const extracted = await this.contentExtractionService.extractFromUrl(body.url);
|
||||
|
||||
return {
|
||||
title: extracted.title,
|
||||
excerpt: extracted.excerpt,
|
||||
byline: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
contentLength: extracted.content.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContentExtractionController } from './content-extraction.controller';
|
||||
import { ContentExtractionService } from './content-extraction.service';
|
||||
import { ArticlesModule } from '../articles/articles.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ArticlesModule, AuthModule],
|
||||
controllers: [ContentExtractionController],
|
||||
providers: [ContentExtractionService],
|
||||
exports: [ContentExtractionService],
|
||||
})
|
||||
export class ContentExtractionModule {}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { ArticlesService } from '../articles/articles.service';
|
||||
|
||||
export interface ExtractedContent {
|
||||
title: string;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
excerpt?: string;
|
||||
byline?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentExtractionService {
|
||||
constructor(private articlesService: ArticlesService) {}
|
||||
|
||||
async extractFromUrl(url: string): Promise<ExtractedContent> {
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid URL');
|
||||
}
|
||||
|
||||
// Fetch the page
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(
|
||||
`Failed to fetch URL: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse with JSDOM
|
||||
const dom = new JSDOM(html, { url });
|
||||
const reader = new Readability(dom.window.document);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new BadRequestException('Could not extract article content from this page');
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title || 'Untitled',
|
||||
content: article.textContent || '',
|
||||
htmlContent: article.content || '',
|
||||
excerpt: article.excerpt,
|
||||
byline: article.byline,
|
||||
siteName: article.siteName,
|
||||
};
|
||||
}
|
||||
|
||||
async saveArticleFromUrl(userId: string, url: string) {
|
||||
const extracted = await this.extractFromUrl(url);
|
||||
|
||||
return this.articlesService.createSavedArticle({
|
||||
userId,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
parsedContent: extracted.htmlContent,
|
||||
originalUrl: url,
|
||||
author: extracted.byline,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createDb } from '@manacore/news-database';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const databaseUrl =
|
||||
configService.get<string>('DATABASE_URL') ||
|
||||
'postgresql://news:news_dev_password@localhost:5434/news_hub';
|
||||
|
||||
console.log('Connecting to database:', databaseUrl.replace(/:[^:@]+@/, ':****@'));
|
||||
|
||||
return createDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ logger: true })
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:8081', // Expo web
|
||||
'http://localhost:19006', // Expo web alt
|
||||
'http://localhost:3000', // API itself (for testing)
|
||||
/^exp:\/\/.*/, // Expo Go
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.API_PORT || 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
import { IsOptional, IsString, IsArray, IsEnum, IsBoolean } from 'class-validator';
|
||||
|
||||
class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
preferredCategories?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
blockedSources?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['slow', 'normal', 'fast'])
|
||||
readingSpeed?: 'slow' | 'normal' | 'fast';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notificationSettings?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async getCurrentUser(@CurrentUser() user: User) {
|
||||
return this.usersService.getUserById(user.id);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async updateCurrentUser(@CurrentUser() user: User, @Body() body: UpdateUserDto) {
|
||||
return this.usersService.updateUser(user.id, body);
|
||||
}
|
||||
|
||||
@Patch('me/onboarding')
|
||||
@UseGuards(AuthGuard)
|
||||
async completeOnboarding(@CurrentUser() user: User) {
|
||||
await this.usersService.completeOnboarding(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, users, User, eq } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
async getUserById(userId: string): Promise<User | null> {
|
||||
const [user] = await this.database.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
userId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
preferredCategories?: string[];
|
||||
blockedSources?: string[];
|
||||
readingSpeed?: 'slow' | 'normal' | 'fast';
|
||||
notificationSettings?: string;
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
): Promise<User> {
|
||||
const [user] = await this.database
|
||||
.update(users)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async completeOnboarding(userId: string): Promise<void> {
|
||||
await this.database
|
||||
.update(users)
|
||||
.set({
|
||||
onboardingCompleted: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// News Hub Purple/Indigo Theme
|
||||
primary: {
|
||||
DEFAULT: '#6366f1',
|
||||
hover: '#818cf8',
|
||||
glow: 'rgba(99, 102, 241, 0.3)'
|
||||
},
|
||||
background: {
|
||||
page: '#0f0f1a',
|
||||
card: '#1a1a2e',
|
||||
'card-hover': '#252542'
|
||||
},
|
||||
text: {
|
||||
primary: '#f9fafb',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#6b7280'
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#252542',
|
||||
hover: '#3a3a5c'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"name": "@news/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"marked": "^17.0.0"
|
||||
}
|
||||
}
|
||||
33
apps-archived/news/apps/web/src/app.d.ts
vendored
33
apps-archived/news/apps/web/src/app.d.ts
vendored
|
|
@ -1,33 +0,0 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
|
||||
interface NewsUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NewsSession {
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
session: NewsSession | null;
|
||||
user: NewsUser | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: NewsSession | null;
|
||||
user: NewsUser | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Feedback Service Instance for News Web App
|
||||
*/
|
||||
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
|
||||
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: MANA_AUTH_URL,
|
||||
appId: 'news',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_URL = env.PUBLIC_NEWS_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
token?: string
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: errorText || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
signup: (email: string, password: string, name?: string) =>
|
||||
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
}),
|
||||
|
||||
logout: (token: string) => apiRequest('/auth/logout', { method: 'POST' }, token),
|
||||
|
||||
me: (token: string) => apiRequest<App.Locals['user']>('/auth/me', {}, token),
|
||||
};
|
||||
|
||||
// Articles endpoints
|
||||
export const articlesApi = {
|
||||
getArticles: (
|
||||
params?: { type?: string; categoryId?: string; limit?: number; offset?: number },
|
||||
token?: string
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.categoryId) searchParams.set('categoryId', params.categoryId);
|
||||
if (params?.limit) searchParams.set('limit', params.limit.toString());
|
||||
if (params?.offset) searchParams.set('offset', params.offset.toString());
|
||||
const query = searchParams.toString();
|
||||
return apiRequest<any[]>(`/articles${query ? `?${query}` : ''}`, {}, token);
|
||||
},
|
||||
|
||||
getArticle: (id: string, token?: string) => apiRequest<any>(`/articles/${id}`, {}, token),
|
||||
|
||||
getSavedArticles: (token: string) => apiRequest<any[]>('/articles/saved/list', {}, token),
|
||||
|
||||
archiveArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}/archive`, { method: 'POST' }, token),
|
||||
|
||||
unarchiveArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}/unarchive`, { method: 'POST' }, token),
|
||||
|
||||
deleteArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}`, { method: 'DELETE' }, token),
|
||||
};
|
||||
|
||||
// Categories endpoints
|
||||
export const categoriesApi = {
|
||||
getCategories: (token?: string) => apiRequest<any[]>('/categories', {}, token),
|
||||
};
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { authApi } from '$lib/services/api';
|
||||
|
||||
class AuthStore {
|
||||
user = $state<App.Locals['user']>(null);
|
||||
session = $state<App.Locals['session']>(null);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
get isAuthenticated() {
|
||||
return !!this.session && !!this.user;
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { data, error } = await authApi.login(email, password);
|
||||
|
||||
if (error) {
|
||||
this.error = error;
|
||||
this.loading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
|
||||
this.user = data.user;
|
||||
// Store token in cookie/localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `news_session=${data.token}; path=/; max-age=604800`; // 7 days
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async signup(email: string, password: string, name?: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { data, error } = await authApi.signup(email, password, name);
|
||||
|
||||
if (error) {
|
||||
this.error = error;
|
||||
this.loading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
|
||||
this.user = data.user;
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `news_session=${data.token}; path=/; max-age=604800`;
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.session?.token) {
|
||||
await authApi.logout(this.session.token);
|
||||
}
|
||||
|
||||
this.session = null;
|
||||
this.user = null;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = 'news_session=; path=/; max-age=0';
|
||||
}
|
||||
}
|
||||
|
||||
setSession(session: App.Locals['session'], user: App.Locals['user']) {
|
||||
this.session = session;
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem } from '@manacore/shared-ui';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('news');
|
||||
|
||||
// User email for dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
// Navigation items for News
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/feed', label: 'Feed', icon: 'rss' },
|
||||
{ href: '/summaries', label: 'Zusammenfassungen', icon: 'document' },
|
||||
{ href: '/in-depth', label: 'In-Depth', icon: 'book' },
|
||||
{ href: '/saved', label: 'Gespeichert', icon: 'bookmark' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
];
|
||||
|
||||
let loading = $state(true);
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
let isDark = $state(false);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = ['/feed', '/summaries', '/in-depth', '/saved', '/settings'];
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= 5) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('news-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('news-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
isDark = !isDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('news-dark-mode', String(isDark));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
goto('/auth/login');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Restore nav mode from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const savedSidebar = localStorage.getItem('news-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
}
|
||||
const savedCollapsed = localStorage.getItem('news-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
}
|
||||
const savedDark = localStorage.getItem('news-dark-mode');
|
||||
if (savedDark === 'true') {
|
||||
isDark = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="News"
|
||||
homeRoute="/feed"
|
||||
onLogout={handleLogout}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/subscription"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
>
|
||||
{#snippet logo()}
|
||||
<span class="text-xl">📰</span>
|
||||
<span class="pill-label font-bold">News</span>
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<main
|
||||
class="main-content flex-1 transition-all duration-300 {isCollapsed
|
||||
? ''
|
||||
: isSidebarMode
|
||||
? 'pl-[180px]'
|
||||
: 'pt-20'}"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { AppsPage } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Alle Apps - News</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="apps-page-wrapper">
|
||||
<AppsPage currentAppId="news" locale="de" title="Alle Apps" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.apps-page-wrapper {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'feed', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feed - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Feed</h1>
|
||||
<p class="text-text-secondary mt-1">Aktuelle Nachrichten im Überblick</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<svg
|
||||
class="w-16 h-16 text-text-muted mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-text-secondary">Noch keine Artikel vorhanden</p>
|
||||
<p class="text-text-muted text-sm mt-1">
|
||||
Artikel werden automatisch generiert und erscheinen hier
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-2">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-text-muted">
|
||||
{#if article.category}
|
||||
<span class="px-2 py-1 bg-primary/10 text-primary rounded">
|
||||
{article.category.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if article.createdAt}
|
||||
<span>{new Date(article.createdAt).toLocaleDateString('de-DE')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<FeedbackPage
|
||||
{feedbackService}
|
||||
appName="ManaNews"
|
||||
currentUserId={authStore.user?.id}
|
||||
/>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'in_depth', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>In-Depth - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">In-Depth Artikel</h1>
|
||||
<p class="text-text-secondary mt-1">Ausführliche Analysen zu wichtigen Themen</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-text-secondary">Noch keine In-Depth Artikel vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-6 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-bold text-xl hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-3">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-4 text-sm text-text-muted">
|
||||
<span>5-15 Min. Lesezeit</span>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.session?.token) {
|
||||
error = 'Nicht angemeldet';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: apiError } = await articlesApi.getSavedArticles(authStore.session.token);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gespeicherte Artikel - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Gespeicherte Artikel</h1>
|
||||
<p class="text-text-secondary mt-1">Deine gespeicherten Artikel zum späteren Lesen</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<svg
|
||||
class="w-16 h-16 text-text-muted mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-text-secondary">Noch keine Artikel gespeichert</p>
|
||||
<p class="text-text-muted text-sm mt-1">
|
||||
Speichere Artikel mit der Browser-Extension oder aus dem Feed
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-2">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'summary', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Zusammenfassungen - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Tägliche Zusammenfassungen</h1>
|
||||
<p class="text-text-secondary mt-1">
|
||||
Die wichtigsten Nachrichten des Tages kompakt zusammengefasst
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-text-secondary">Noch keine Zusammenfassungen vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-3">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
// Initialize auth store with server data
|
||||
$effect(() => {
|
||||
if (data.session && data.user) {
|
||||
authStore.setSession(data.session, data.user);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background-page text-text-primary">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/feed');
|
||||
} else {
|
||||
goto('/auth/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="animate-pulse">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await authStore.login(email, password);
|
||||
if (success) {
|
||||
goto('/feed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary mx-auto mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold">News Hub</h1>
|
||||
<p class="text-text-secondary mt-2">Anmelden</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if authStore.error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{authStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authStore.loading}
|
||||
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{authStore.loading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-text-secondary mt-6">
|
||||
Noch kein Konto?
|
||||
<a href="/auth/register" class="text-primary hover:underline">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await authStore.signup(email, password, name || undefined);
|
||||
if (success) {
|
||||
goto('/feed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary mx-auto mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold">News Hub</h1>
|
||||
<p class="text-text-secondary mt-2">Konto erstellen</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if authStore.error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{authStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium mb-2">Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authStore.loading}
|
||||
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{authStore.loading ? 'Wird registriert...' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-text-secondary mt-6">
|
||||
Bereits ein Konto?
|
||||
<a href="/auth/login" class="text-primary hover:underline">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'marked',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: news-hub-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: news
|
||||
POSTGRES_PASSWORD: news_dev_password
|
||||
POSTGRES_DB: news_hub
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U news -d news_hub"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: news-hub-pgadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@local.dev
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- Grants
|
||||
GRANT ALL PRIVILEGES ON DATABASE news_hub TO news;
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
# Kokon Browser Extension
|
||||
|
||||
Eine Chrome/Firefox Browser-Erweiterung für die Kokon Read-Later App.
|
||||
|
||||
## Features
|
||||
|
||||
- **Ein-Klick Speichern**: Speichere jeden Artikel mit einem Klick
|
||||
- **Automatische Content-Extraktion**: Nutzt die gleiche Mozilla Readability Engine wie die App
|
||||
- **Session-Synchronisation**: Automatische Anmeldeerkennung mit der Web-App
|
||||
- **Elegantes Design**: Moderne, responsive Benutzeroberfläche
|
||||
- **Fehlerbehandlung**: Intelligente Fehlerbehandlung und Benutzerführung
|
||||
|
||||
## Installation (Development)
|
||||
|
||||
### Chrome/Edge
|
||||
|
||||
1. Öffne `chrome://extensions/`
|
||||
2. Aktiviere "Entwicklermodus" (Developer mode)
|
||||
3. Klicke "Ungepackte Erweiterung laden" (Load unpacked)
|
||||
4. Wähle den `browser-extension` Ordner aus
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Öffne `about:debugging`
|
||||
2. Klicke "Dieses Firefox" (This Firefox)
|
||||
3. Klicke "Temporäres Add-on laden" (Load Temporary Add-on)
|
||||
4. Wähle die `manifest.json` Datei aus
|
||||
|
||||
## Verwendung
|
||||
|
||||
1. **Erste Einrichtung**:
|
||||
- Installiere die Erweiterung
|
||||
- Logge dich in der Kokon Web-App ein (wird automatisch geöffnet)
|
||||
|
||||
2. **Artikel speichern**:
|
||||
- Navigiere zu einem beliebigen Artikel im Web
|
||||
- Klicke auf das Kokon-Symbol in der Browser-Toolbar
|
||||
- Klicke "Save Article"
|
||||
- Der Artikel wird automatisch verarbeitet und in deiner Kokon-Liste gespeichert
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Architektur
|
||||
|
||||
- **Manifest V3**: Moderne Chrome Extension API
|
||||
- **Service Worker**: Background-Verarbeitung für Session-Management
|
||||
- **Popup Interface**: Elegant gestaltetes Popup mit Echtzeit-Feedback
|
||||
- **Chrome Storage API**: Synchronisation mit Web-App-Sessions
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- **Minimale Berechtigungen**: Nur `activeTab` und `storage`
|
||||
- **HTTPS Only**: Sichere Kommunikation mit Supabase
|
||||
- **Token-basierte Auth**: Nutzt bestehende Supabase-Session
|
||||
- **Domain-Validierung**: Verhindert Speichern von Browser-internen Seiten
|
||||
|
||||
### Integration
|
||||
|
||||
- Nutzt die gleiche `save-article` Edge Function wie die App
|
||||
- Teilt sich die Session mit der Web-App über Chrome Storage
|
||||
- Automatische Token-Erneuerung und Logout-Erkennung
|
||||
|
||||
## Datei-Struktur
|
||||
|
||||
```
|
||||
browser-extension/
|
||||
├── manifest.json # Extension-Konfiguration (Manifest V3)
|
||||
├── popup.html # Popup-Interface HTML
|
||||
├── popup.js # Popup-Logik und API-Calls
|
||||
├── background.js # Service Worker für Background-Tasks
|
||||
├── icons/ # Extension-Icons (TODO: Icons hinzufügen)
|
||||
│ ├── icon-16.png
|
||||
│ ├── icon-32.png
|
||||
│ ├── icon-48.png
|
||||
│ └── icon-128.png
|
||||
└── README.md # Diese Datei
|
||||
```
|
||||
|
||||
## TODO: Icons
|
||||
|
||||
Die Extension benötigt noch Icons in verschiedenen Größen:
|
||||
|
||||
- 16x16px (Toolbar)
|
||||
- 32x32px (Extension-Management)
|
||||
- 48x48px (Extension-Management)
|
||||
- 128x128px (Chrome Web Store)
|
||||
|
||||
Icons sollten das Kokon-Logo (🥥) oder ein ähnliches Design verwenden.
|
||||
|
||||
## Chrome Web Store Deployment
|
||||
|
||||
Für die Veröffentlichung im Chrome Web Store:
|
||||
|
||||
1. **Icons hinzufügen** (siehe TODO oben)
|
||||
2. **Version bumpen** in `manifest.json`
|
||||
3. **Extension packen**:
|
||||
```bash
|
||||
zip -r kokon-extension.zip browser-extension/
|
||||
```
|
||||
4. **Chrome Developer Dashboard**: Upload auf [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
|
||||
|
||||
## Firefox Add-ons Deployment
|
||||
|
||||
Für Mozilla Add-ons:
|
||||
|
||||
1. **Firefox-spezifische Anpassungen** (falls nötig)
|
||||
2. **Signierung** über [Mozilla Add-on Developer Hub](https://addons.mozilla.org/developers/)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Testing
|
||||
|
||||
1. Lade die Extension im Entwicklermodus
|
||||
2. Öffne eine beliebige Webseite
|
||||
3. Teste das Popup und die Save-Funktionalität
|
||||
4. Überprüfe die Browser-Konsole für Fehler
|
||||
|
||||
### Debugging
|
||||
|
||||
- **Popup debuggen**: Rechtsklick auf Extension-Icon → "Inspect popup"
|
||||
- **Background Script**: In `chrome://extensions/` → "Inspect views: background page"
|
||||
- **Storage prüfen**: Chrome DevTools → Application → Storage → Extension
|
||||
|
||||
## Kompatibilität
|
||||
|
||||
- **Chrome**: Version 88+ (Manifest V3 Support)
|
||||
- **Edge**: Version 88+ (Chromium-basiert)
|
||||
- **Firefox**: Version 109+ (Manifest V3 Support)
|
||||
- **Safari**: Benötigt Anpassungen für Safari Web Extensions
|
||||
|
||||
## Lizenz
|
||||
|
||||
Teil des Kokon-Projekts - siehe Haupt-Repository für Lizenzdetails.
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
// Background service worker for Kokon Browser Extension
|
||||
|
||||
// Installation handler
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
console.log('Kokon extension installed');
|
||||
|
||||
// Optionally open the web app on first install
|
||||
chrome.tabs.create({
|
||||
url: 'http://localhost:8081', // Local Expo web development server
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle extension icon click (this is mainly handled by the popup, but kept for completeness)
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
// This won't fire if popup.html is defined in manifest, but keeping for fallback
|
||||
console.log('Extension icon clicked for tab:', tab.url);
|
||||
});
|
||||
|
||||
// Listen for messages from content scripts (if needed in the future)
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
console.log('Background received message:', request);
|
||||
|
||||
// Handle any background tasks here
|
||||
if (request.action === 'saveArticle') {
|
||||
// This could be used for context menu integration in the future
|
||||
console.log('Save article request for:', request.url);
|
||||
}
|
||||
|
||||
return true; // Keep message channel open for async response
|
||||
});
|
||||
|
||||
// Sync storage with web app (for session management)
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === 'local') {
|
||||
console.log('Storage changed:', changes);
|
||||
|
||||
// Monitor auth state changes
|
||||
if (changes['supabase.auth.token']) {
|
||||
const newToken = changes['supabase.auth.token'].newValue;
|
||||
if (newToken) {
|
||||
console.log('User logged in');
|
||||
// Could update badge or perform other actions
|
||||
} else {
|
||||
console.log('User logged out');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle context menu (optional future feature)
|
||||
// chrome.contextMenus.create({
|
||||
// id: "saveToKokon",
|
||||
// title: "Save to Kokon",
|
||||
// contexts: ["page", "link"]
|
||||
// });
|
||||
|
||||
// chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
// if (info.menuItemId === "saveToKokon") {
|
||||
// const url = info.linkUrl || tab.url;
|
||||
// // Handle saving the article
|
||||
// }
|
||||
// });
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
// Content script to sync localStorage with Chrome storage
|
||||
console.log('🥥 Kokon content script loaded on:', window.location.href);
|
||||
|
||||
// Function to sync localStorage to Chrome storage
|
||||
function syncToChrome(key, value) {
|
||||
if (chrome && chrome.storage) {
|
||||
chrome.storage.local
|
||||
.set({ [key]: value })
|
||||
.then(() => {
|
||||
console.log('Content script: Successfully synced to Chrome storage:', key);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Content script: Failed to sync to Chrome storage:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to sync removal from localStorage to Chrome storage
|
||||
function removeFromChrome(key) {
|
||||
if (chrome && chrome.storage) {
|
||||
chrome.storage.local
|
||||
.remove([key])
|
||||
.then(() => {
|
||||
console.log('Content script: Successfully removed from Chrome storage:', key);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Content script: Failed to remove from Chrome storage:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for localStorage changes and sync to Chrome storage
|
||||
function setupStorageSync() {
|
||||
console.log('🥥 Setting up storage sync...');
|
||||
|
||||
// The actual Supabase auth token key
|
||||
const SUPABASE_AUTH_KEY = 'sb-hepsjdbvpkumaoabbycd-auth-token';
|
||||
|
||||
// Override localStorage.setItem to sync
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function (key, value) {
|
||||
console.log('🥥 localStorage.setItem called:', key);
|
||||
originalSetItem.call(this, key, value);
|
||||
if (key === SUPABASE_AUTH_KEY) {
|
||||
console.log('🥥 Detected supabase token change, syncing...');
|
||||
// Store with standardized key for extension
|
||||
syncToChrome('supabase.auth.token', value);
|
||||
}
|
||||
};
|
||||
|
||||
// Override localStorage.removeItem to sync
|
||||
const originalRemoveItem = localStorage.removeItem;
|
||||
localStorage.removeItem = function (key) {
|
||||
console.log('🥥 localStorage.removeItem called:', key);
|
||||
originalRemoveItem.call(this, key);
|
||||
if (key === SUPABASE_AUTH_KEY) {
|
||||
console.log('🥥 Detected supabase token removal, syncing...');
|
||||
removeFromChrome('supabase.auth.token');
|
||||
}
|
||||
};
|
||||
|
||||
// Check for existing token on page load
|
||||
const existingToken = localStorage.getItem(SUPABASE_AUTH_KEY);
|
||||
console.log('🥥 Checking for existing token:', existingToken ? 'Found' : 'Not found');
|
||||
if (existingToken) {
|
||||
console.log('🥥 Found existing token, syncing...');
|
||||
syncToChrome('supabase.auth.token', existingToken);
|
||||
}
|
||||
|
||||
// Also check all localStorage keys
|
||||
console.log('🥥 All localStorage keys:', Object.keys(localStorage));
|
||||
}
|
||||
|
||||
// Set up the sync when the page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupStorageSync);
|
||||
} else {
|
||||
setupStorageSync();
|
||||
}
|
||||
|
||||
// Also listen for storage events (in case other tabs make changes)
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'sb-hepsjdbvpkumaoabbycd-auth-token') {
|
||||
console.log('🥥 Storage event detected for supabase token');
|
||||
if (e.newValue) {
|
||||
syncToChrome('supabase.auth.token', e.newValue);
|
||||
} else {
|
||||
removeFromChrome('supabase.auth.token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Debug Extension Storage</title>
|
||||
<style>
|
||||
body { font-family: Arial; padding: 20px; }
|
||||
.section { margin: 20px 0; }
|
||||
button { padding: 10px; margin: 5px; }
|
||||
pre { background: #f5f5f5; padding: 10px; border-radius: 5px; max-height: 400px; overflow-y: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Kokon Extension Debug</h1>
|
||||
|
||||
<div class="section">
|
||||
<button id="checkBtn">Check Chrome Storage</button>
|
||||
<button id="clearBtn">Clear Chrome Storage</button>
|
||||
<button id="testBtn">Set Test Data</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Chrome Storage Contents:</h3>
|
||||
<pre id="storageContent">Loading...</pre>
|
||||
</div>
|
||||
|
||||
<script src="debug.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// Debug script for Extension Storage
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get(null);
|
||||
document.getElementById('storageContent').textContent = JSON.stringify(result, null, 2);
|
||||
console.log('Chrome Storage contents:', result);
|
||||
} catch (error) {
|
||||
document.getElementById('storageContent').textContent = 'Error: ' + error.message;
|
||||
console.error('Error checking storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearStorage() {
|
||||
try {
|
||||
await chrome.storage.local.clear();
|
||||
document.getElementById('storageContent').textContent = 'Storage cleared';
|
||||
console.log('Chrome Storage cleared');
|
||||
} catch (error) {
|
||||
console.error('Error clearing storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setTestData() {
|
||||
try {
|
||||
const testSession = {
|
||||
access_token: 'test-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
refresh_token: 'test-refresh',
|
||||
};
|
||||
|
||||
await chrome.storage.local.set({
|
||||
'supabase.auth.token': JSON.stringify(testSession),
|
||||
});
|
||||
|
||||
document.getElementById('storageContent').textContent = 'Test data set';
|
||||
console.log('Test data set in Chrome Storage');
|
||||
} catch (error) {
|
||||
console.error('Error setting test data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('checkBtn').addEventListener('click', checkStorage);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearStorage);
|
||||
document.getElementById('testBtn').addEventListener('click', setTestData);
|
||||
|
||||
// Auto-check on load
|
||||
checkStorage();
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "News Hub - Save Article",
|
||||
"version": "1.0.0",
|
||||
"description": "Save articles from any website to your News Hub library",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"host_permissions": ["http://localhost:3000/*"],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Save to News Hub"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://localhost:*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>News Hub - Save Article</title>
|
||||
<style>
|
||||
body {
|
||||
width: 350px;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.page-url {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.save-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background: #475569;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.login-notice {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">NEWS HUB</div>
|
||||
<div class="subtitle">Save Article to Library</div>
|
||||
|
||||
<div id="loginNotice" class="login-notice" style="display: none;">
|
||||
<div>Please log in to News Hub first:</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<a href="#" id="loginLink" class="login-link">Open News Hub App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="currentPage" class="current-page">
|
||||
<div class="page-title" id="pageTitle">Loading page info...</div>
|
||||
<div class="page-url" id="pageUrl"></div>
|
||||
</div>
|
||||
|
||||
<button id="saveButton" class="save-button" disabled>
|
||||
<span id="buttonText">Ready to Save</span>
|
||||
</button>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
// Browser Extension Popup Script for News Hub
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
const pageUrl = document.getElementById('pageUrl');
|
||||
const saveButton = document.getElementById('saveButton');
|
||||
const buttonText = document.getElementById('buttonText');
|
||||
const status = document.getElementById('status');
|
||||
const loginNotice = document.getElementById('loginNotice');
|
||||
const loginLink = document.getElementById('loginLink');
|
||||
|
||||
// API Configuration
|
||||
const API_URL = 'http://localhost:3000';
|
||||
const APP_URL = 'http://localhost:8081';
|
||||
|
||||
let currentTab = null;
|
||||
let authToken = null;
|
||||
|
||||
// Get current tab info
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
currentTab = tab;
|
||||
|
||||
pageTitle.textContent = tab.title || 'Untitled Page';
|
||||
pageUrl.textContent = tab.url;
|
||||
} catch (error) {
|
||||
console.error('Error getting tab info:', error);
|
||||
pageTitle.textContent = 'Error loading page info';
|
||||
status.textContent = 'Failed to get page information';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in by looking for stored token in Chrome storage
|
||||
try {
|
||||
const result = await chrome.storage.local.get(['news_hub_auth_token']);
|
||||
authToken = result['news_hub_auth_token'];
|
||||
|
||||
console.log('Checking Chrome storage for token...', authToken ? 'Found' : 'Not found');
|
||||
|
||||
if (authToken) {
|
||||
// Verify token is still valid by calling session endpoint
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/session`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
saveButton.disabled = false;
|
||||
loginNotice.style.display = 'none';
|
||||
// Auto-save article immediately
|
||||
saveArticle();
|
||||
} else {
|
||||
// Token is invalid
|
||||
await chrome.storage.local.remove(['news_hub_auth_token']);
|
||||
authToken = null;
|
||||
showLoginNotice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
showLoginNotice();
|
||||
}
|
||||
} else {
|
||||
showLoginNotice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking login status:', error);
|
||||
showLoginNotice();
|
||||
}
|
||||
|
||||
function showLoginNotice() {
|
||||
loginNotice.style.display = 'block';
|
||||
saveButton.disabled = true;
|
||||
status.textContent = 'Please log in to News Hub first';
|
||||
status.className = 'status error';
|
||||
}
|
||||
|
||||
// Handle login link click
|
||||
loginLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.tabs.create({ url: APP_URL });
|
||||
window.close();
|
||||
});
|
||||
|
||||
// Save article function
|
||||
async function saveArticle() {
|
||||
if (!currentTab || !authToken) {
|
||||
status.textContent = 'Please log in first';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
const url = currentTab.url;
|
||||
if (
|
||||
!url ||
|
||||
url.startsWith('chrome://') ||
|
||||
url.startsWith('chrome-extension://') ||
|
||||
url.startsWith('about:')
|
||||
) {
|
||||
status.textContent = 'Cannot save this type of page';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
saveButton.disabled = true;
|
||||
buttonText.innerHTML = '<span class="loading"></span>Saving...';
|
||||
status.textContent = 'Saving article...';
|
||||
status.className = 'status';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/extract/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ url: url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
status.textContent = 'Article saved!';
|
||||
status.className = 'status success';
|
||||
|
||||
// Show success for a moment, then close
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to save article');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving article:', error);
|
||||
|
||||
let errorMessage = 'Failed to save article';
|
||||
if (error.message.includes('fetch') || error.message.includes('NetworkError')) {
|
||||
errorMessage = 'Network error - is the API running?';
|
||||
} else if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
||||
errorMessage = 'Session expired - please log in again';
|
||||
showLoginNotice();
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
status.textContent = errorMessage;
|
||||
status.className = 'status error';
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveButton.disabled = authToken ? false : true;
|
||||
buttonText.textContent = 'Try Again';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle save button click (manual save if auto-save failed)
|
||||
saveButton.addEventListener('click', saveArticle);
|
||||
|
||||
// Listen for storage changes (if user logs in/out in another tab)
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === 'local' && changes['news_hub_auth_token']) {
|
||||
const newValue = changes['news_hub_auth_token'].newValue;
|
||||
|
||||
if (newValue) {
|
||||
authToken = newValue;
|
||||
saveButton.disabled = false;
|
||||
loginNotice.style.display = 'none';
|
||||
status.textContent = '';
|
||||
status.className = 'status';
|
||||
} else {
|
||||
authToken = null;
|
||||
showLoginNotice();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
!README.md
|
||||
!DEPLOYMENT.md
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
test-results
|
||||
e2e
|
||||
tests
|
||||
*.test.*
|
||||
*.spec.*
|
||||
playwright.config.*
|
||||
vitest.config.*
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
backend
|
||||
pb_hooks
|
||||
pb_migrations
|
||||
pocketbase
|
||||
mcp-servers
|
||||
*.sql.gz
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# SvelteKit Configuration
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
NODE_ENV=production
|
||||
PUBLIC_APP_URL=https://ulo.ad
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Development: Use local Docker container
|
||||
DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
|
||||
# Production: Use your Coolify/Hetzner PostgreSQL container
|
||||
# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
|
||||
|
||||
# File Storage (Cloudflare R2)
|
||||
R2_ACCOUNT_ID=your_cloudflare_account_id
|
||||
R2_ACCESS_KEY_ID=your_r2_access_key
|
||||
R2_SECRET_ACCESS_KEY=your_r2_secret_key
|
||||
R2_BUCKET_AVATARS=uload-avatars
|
||||
R2_BUCKET_QR=uload-qr-codes
|
||||
R2_PUBLIC_URL=https://files.ulo.ad
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_your_resend_api_key
|
||||
RESEND_FROM_EMAIL=noreply@ulo.ad
|
||||
|
||||
# Umami Analytics (optional)
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# External Auth (to be implemented)
|
||||
# AUTH_PROVIDER_CLIENT_ID=
|
||||
# AUTH_PROVIDER_CLIENT_SECRET=
|
||||
|
||||
# Coolify specific (if needed)
|
||||
# These will be set automatically by Coolify
|
||||
# COOLIFY_URL=
|
||||
# COOLIFY_TOKEN=
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# SvelteKit Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
ORIGIN=https://your-domain.com
|
||||
PUBLIC_POCKETBASE_URL=https://your-domain.com/api
|
||||
|
||||
# PocketBase Admin Credentials
|
||||
# These will be used to create the admin on first startup
|
||||
POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
|
||||
POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
|
||||
|
||||
# Umami Analytics
|
||||
# Replace with your actual Umami instance and website ID
|
||||
PUBLIC_UMAMI_URL=https://your-umami-instance.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
|
||||
|
||||
# Optional: Additional Configuration
|
||||
# BODY_SIZE_LIMIT=512kb
|
||||
# PROTOCOL_HEADER=x-forwarded-proto
|
||||
# HOST_HEADER=x-forwarded-host
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Stripe Configuration
|
||||
# Copy this to .env.local or add to your .env file
|
||||
|
||||
# Stripe API Keys (get from https://dashboard.stripe.com/test/apikeys)
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
|
||||
STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
|
||||
|
||||
# Stripe Product & Price IDs (will be created automatically by Claude)
|
||||
STRIPE_PRODUCT_PRO=prod_xxx
|
||||
STRIPE_PRICE_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_YEARLY=price_xxx
|
||||
|
||||
# Stripe Webhook Secret (from webhook endpoint in dashboard)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# App URL for redirects
|
||||
PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
|
||||
43
apps-archived/uload/.gitignore
vendored
43
apps-archived/uload/.gitignore
vendored
|
|
@ -1,43 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Test results
|
||||
test-results
|
||||
|
||||
# Build output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# MCP Configuration with credentials
|
||||
.mcp.json
|
||||
.mcp.json-dev
|
||||
|
||||
# PocketBase
|
||||
backend/pocketbase
|
||||
backend/pb_data/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
uLoad is a URL shortener and link management platform built with SvelteKit and PocketBase.
|
||||
|
||||
**Live:** https://ulo.ad
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
uload/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web application
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── routes/ # SvelteKit pages
|
||||
│ │ └── lib/ # Components, services, utilities
|
||||
│ ├── static/ # Static assets
|
||||
│ └── e2e/ # End-to-end tests
|
||||
├── backend/ # PocketBase configuration
|
||||
│ ├── pb_migrations/ # Database migrations
|
||||
│ └── pb_schema.json # Schema definition
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
All commands should be run from `uload/apps/web/`:
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm run dev # Start development server (http://localhost:5173)
|
||||
pnpm run preview # Preview production build locally
|
||||
```
|
||||
|
||||
### Build & Deploy
|
||||
|
||||
```bash
|
||||
pnpm run build # Create production build
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run format # Auto-format code with Prettier
|
||||
pnpm run lint # Run ESLint and Prettier checks
|
||||
pnpm run check # Run Svelte type checking
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pnpm run test # Run all tests (unit + e2e)
|
||||
pnpm run test:unit # Run unit tests with Vitest
|
||||
pnpm run test:e2e # Run end-to-end tests with Playwright
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
pnpm run db:generate # Generate Drizzle migrations
|
||||
pnpm run db:migrate # Run migrations
|
||||
pnpm run db:push # Push schema changes
|
||||
pnpm run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: SvelteKit v2.22 with Svelte 5.0
|
||||
- **Backend**: PocketBase (embedded SQLite)
|
||||
- **Database**: PostgreSQL via Drizzle ORM + Redis for caching
|
||||
- **Styling**: Tailwind CSS v4.0
|
||||
- **Testing**: Vitest + Playwright
|
||||
- **Payments**: Stripe
|
||||
- **Email**: Resend
|
||||
- **Storage**: Cloudflare R2
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Svelte 5 Runes Mode
|
||||
|
||||
- **NEVER use `$:` reactive statements** - use `$derived` instead
|
||||
- **NEVER use `let` for reactive values** - use `$state` for reactive state
|
||||
- **For side effects** - use `$effect` instead of `$:` statements
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Svelte 5 runes
|
||||
let headerModule = $derived(card.config.modules?.find((m) => m.type === 'header'));
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
console.log('Count changed:', count);
|
||||
});
|
||||
```
|
||||
|
||||
### PocketBase Usage
|
||||
|
||||
In server-side code (`+page.server.ts`, `+server.ts`):
|
||||
|
||||
- **ALWAYS use `locals.pb`** from the request context
|
||||
- The imported `pb` is for client-side only
|
||||
|
||||
```typescript
|
||||
// Server-side
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const items = await locals.pb.collection('items').getList();
|
||||
};
|
||||
|
||||
// Client-side
|
||||
import { pb } from '$lib/pocketbase';
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `R2_*` - Cloudflare R2 storage credentials
|
||||
- `RESEND_API_KEY` - Email service
|
||||
- `STRIPE_*` - Payment processing (see `.env.stripe.example`)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes for strings
|
||||
- 100 character line width
|
||||
- Prettier auto-sorts Tailwind classes
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# =============================================================================
|
||||
# uload Web Application Dockerfile
|
||||
# Multi-stage build for production deployment with Coolify
|
||||
#
|
||||
# IMPORTANT: This Dockerfile must be built from the MONOREPO ROOT, not from uload/
|
||||
# docker build -f uload/Dockerfile -t uload-web .
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
# Copy the uload web app
|
||||
COPY uload/apps/web/ ./uload/apps/web/
|
||||
|
||||
# Copy required shared packages
|
||||
COPY packages/shared-auth-ui/ ./packages/shared-auth-ui/
|
||||
COPY packages/shared-branding/ ./packages/shared-branding/
|
||||
|
||||
# Install dependencies with flat structure for Docker compatibility
|
||||
RUN pnpm install --filter @uload/web... --shamefully-hoist
|
||||
|
||||
# Build the app
|
||||
WORKDIR /app/uload/apps/web
|
||||
|
||||
# Note: RESEND_API_KEY is needed at build time for SvelteKit prerendering
|
||||
ENV RESEND_API_KEY=build_placeholder
|
||||
RUN pnpm build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Production Runner
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
# Security: Run as non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 sveltekit
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built app from the correct path
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/build ./build
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/package.json ./
|
||||
|
||||
# Copy hoisted node_modules from root (contains all deps with flat structure)
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Node server
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
# uLoad - URL Shortener & Link Management
|
||||
|
||||
A modern URL shortener and link management platform built with SvelteKit and PocketBase.
|
||||
|
||||
## 🚀 Production
|
||||
|
||||
**Live:** https://ulo.ad
|
||||
**Admin:** https://ulo.ad/_/
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
- **Frontend:** SvelteKit 2.0 + Svelte 5
|
||||
- **Backend:** PocketBase (embedded)
|
||||
- **Styling:** Tailwind CSS 4.0
|
||||
- **Deployment:** Docker + Coolify on Hetzner VPS
|
||||
- **Database:** SQLite (via PocketBase)
|
||||
|
||||
## 📦 Features
|
||||
|
||||
- URL shortening with custom codes
|
||||
- QR code generation
|
||||
- Click analytics
|
||||
- User profiles (e.g., ulo.ad/p/username)
|
||||
- Link management dashboard
|
||||
- Real-time statistics
|
||||
|
||||
## 🏃 Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Start with PocketBase backend
|
||||
npm run dev:all
|
||||
|
||||
# Run tests
|
||||
npm run test
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
```
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build and run locally
|
||||
docker-compose up --build
|
||||
|
||||
# Access at:
|
||||
# Frontend: http://localhost:3000
|
||||
# PocketBase: http://localhost:8090
|
||||
```
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
- [Deployment Guide](./DEPLOYMENT.md) - Complete Docker Compose deployment instructions
|
||||
- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
|
||||
- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
|
||||
- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
ORIGIN=https://ulo.ad
|
||||
PUBLIC_POCKETBASE_URL=https://ulo.ad/api
|
||||
POCKETBASE_ADMIN_EMAIL=admin@example.com
|
||||
POCKETBASE_ADMIN_PASSWORD=secure_password
|
||||
```
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
uload/
|
||||
├── src/ # SvelteKit application
|
||||
│ ├── routes/ # Pages and API routes
|
||||
│ ├── lib/ # Components and utilities
|
||||
│ └── app.html # HTML template
|
||||
├── backend/ # PocketBase configuration
|
||||
│ ├── pb_schema.json # Database schema
|
||||
│ └── init-pocketbase.sh # Setup script
|
||||
├── build/ # Production build output
|
||||
├── static/ # Static assets
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Local development
|
||||
├── supervisord.conf # Process management
|
||||
└── CLAUDE.md # AI assistant context
|
||||
```
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
The application is deployed on Hetzner VPS using Coolify with automatic deployments on push to main branch.
|
||||
|
||||
```bash
|
||||
# Commit and push to deploy
|
||||
git add .
|
||||
git commit -m "Update"
|
||||
git push origin main
|
||||
# Coolify automatically deploys
|
||||
```
|
||||
|
||||
### Manual Deployment Steps:
|
||||
|
||||
1. Set DNS A record to `91.99.221.179`
|
||||
2. Add domain in Coolify
|
||||
3. Update environment variables
|
||||
4. Enable SSL certificate
|
||||
5. Deploy application
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
- **Health Check:** https://ulo.ad/health
|
||||
- **Admin Panel:** https://ulo.ad/_/
|
||||
- **Server:** Hetzner CX21 (2 vCPU, 4GB RAM)
|
||||
- **Uptime:** 99.9% SLA
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- HTTPS enforced
|
||||
- Environment-based configuration
|
||||
- Secure admin authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Regular security updates
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
Common issues and solutions are documented in [DEPLOYMENT_LESSONS_LEARNED.md](./DEPLOYMENT_LESSONS_LEARNED.md)
|
||||
|
||||
For support, check:
|
||||
|
||||
- Application logs in Coolify
|
||||
- Health endpoint status
|
||||
- PocketBase admin panel
|
||||
|
||||
## 📄 License
|
||||
|
||||
Private - Memoro AI © 2024
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3003
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
|
||||
# Redis (for caching)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-uload-app-id
|
||||
MANA_SERVICE_KEY=
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Short URL base (for generating short links)
|
||||
SHORT_URL_BASE=https://ulo.ad
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy workspace packages
|
||||
COPY packages/uload-database ./packages/uload-database
|
||||
|
||||
# Copy backend source
|
||||
COPY uload/apps/backend ./uload/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the database package first
|
||||
WORKDIR /app/packages/uload-database
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/uload/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/node_modules ./node_modules
|
||||
|
||||
# Copy database package (needed at runtime)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/dist ./node_modules/@manacore/uload-database/dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/package.json ./node_modules/@manacore/uload-database/
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
|
||||
# Start with dumb-init
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main"]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ManaCoreModule } from '@mana-core/nestjs-integration';
|
||||
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { LinkRepository } from './database/repositories/link.repository';
|
||||
import { ClickRepository } from './database/repositories/click.repository';
|
||||
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { RedirectController } from './controllers/redirect.controller';
|
||||
import { LinksController } from './controllers/links.controller';
|
||||
import { AnalyticsController } from './controllers/analytics.controller';
|
||||
|
||||
import { LinksService } from './services/links.service';
|
||||
import { RedirectService } from './services/redirect.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Context-Local Storage for request-scoped data
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true, generateId: true },
|
||||
}),
|
||||
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: true,
|
||||
abortEarly: false,
|
||||
},
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
|
||||
// Mana Core Authentication
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL')!,
|
||||
appId: configService.get<string>('APP_ID')!,
|
||||
serviceKey: configService.get<string>('MANA_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}) as any,
|
||||
|
||||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [HealthController, RedirectController, LinksController, AnalyticsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
LinkRepository,
|
||||
ClickRepository,
|
||||
// Services
|
||||
LinksService,
|
||||
RedirectService,
|
||||
AnalyticsService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Add custom middleware here if needed
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import * as Joi from 'joi';
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
// Server
|
||||
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: Joi.string().uri().required(),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: Joi.string().default('localhost'),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||
|
||||
// Mana Core Auth
|
||||
MANA_SERVICE_URL: Joi.string().uri().required(),
|
||||
APP_ID: Joi.string().uuid().required(),
|
||||
MANA_SERVICE_KEY: Joi.string().allow('').optional(),
|
||||
|
||||
// Frontend
|
||||
FRONTEND_URL: Joi.string().uri().optional(),
|
||||
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
});
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
import { LinksService } from '../services/links.service';
|
||||
|
||||
@Controller('api/analytics')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly linksService: LinksService
|
||||
) {}
|
||||
|
||||
@Get('links/:linkId')
|
||||
async getLinkAnalytics(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('from') fromDate?: string,
|
||||
@Query('to') toDate?: string
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const stats = await this.analyticsService.getStats(
|
||||
linkId,
|
||||
fromDate ? new Date(fromDate) : undefined,
|
||||
toDate ? new Date(toDate) : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
shortCode: link.shortCode,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('links/:linkId/clicks')
|
||||
async getLinkClicks(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('limit') limit: number = 100
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const { clicks, total } = await this.analyticsService.getRecentClicks(linkId, limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
clicks: clicks.map((click) => ({
|
||||
...click,
|
||||
ipHash: undefined, // Don't expose IP hash
|
||||
})),
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: any) {
|
||||
const userId = user.sub;
|
||||
const totalLinks = await this.linksService.getLinkCount(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalLinks,
|
||||
// Add more overview stats as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthCheckService, HealthCheck, HealthCheckResult } from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'live',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { LinksService } from '../services/links.service';
|
||||
import type { CreateLinkDto, UpdateLinkDto } from '../services/links.service';
|
||||
|
||||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
constructor(private readonly linksService: LinksService) {}
|
||||
|
||||
@Get()
|
||||
async getLinks(
|
||||
@CurrentUser() user: any,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
@Query('search') search?: string,
|
||||
@Query('isActive') isActive?: boolean
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const { items, total } = await this.linksService.getLinks(userId, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
links: items.map((link) => ({
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined, // Never send password to client
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.getLinkById(id, userId);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.createLink(userId, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLink(@CurrentUser() user: any, @Param('id') id: string, @Body() dto: UpdateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.updateLink(id, userId, dto);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const deleted = await this.linksService.deleteLink(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { Controller, Get, Post, Param, Body, Req, Res, HttpStatus, Query } from '@nestjs/common';
|
||||
import { Response, Request } from 'express';
|
||||
import { RedirectService } from '../services/redirect.service';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
|
||||
@Controller()
|
||||
export class RedirectController {
|
||||
constructor(
|
||||
private readonly redirectService: RedirectService,
|
||||
private readonly analyticsService: AnalyticsService
|
||||
) {}
|
||||
|
||||
@Get(':code')
|
||||
async redirect(
|
||||
@Param('code') code: string,
|
||||
@Query('utm_source') utmSource: string,
|
||||
@Query('utm_medium') utmMedium: string,
|
||||
@Query('utm_campaign') utmCampaign: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Skip for API and health routes
|
||||
if (code === 'v1' || code === 'health') {
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.redirectService.getRedirect(code);
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'not_found':
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'Link not found',
|
||||
});
|
||||
|
||||
case 'expired':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has expired',
|
||||
});
|
||||
|
||||
case 'inactive':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link is no longer active',
|
||||
});
|
||||
|
||||
case 'max_clicks':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has reached its maximum clicks',
|
||||
});
|
||||
|
||||
case 'password_required':
|
||||
return response.status(HttpStatus.OK).json({
|
||||
success: false,
|
||||
passwordRequired: true,
|
||||
linkId: result.linkId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Record click asynchronously (don't wait)
|
||||
this.analyticsService
|
||||
.recordClick(result.linkId!, {
|
||||
userAgent: request.headers['user-agent'] || '',
|
||||
referer: request.headers['referer'] || '',
|
||||
ip: request.ip,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
})
|
||||
.catch((err) => console.error('Failed to record click:', err));
|
||||
|
||||
// Perform redirect
|
||||
return response.redirect(302, result.targetUrl!);
|
||||
}
|
||||
|
||||
@Post(':code/unlock')
|
||||
async unlockLink(
|
||||
@Param('code') code: string,
|
||||
@Body('password') password: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const result = await this.redirectService.verifyPassword(code, password);
|
||||
|
||||
if (!result.success) {
|
||||
return response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { getDb, closeDb } from '@manacore/uload-database';
|
||||
import type { Database } from '@manacore/uload-database';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => {
|
||||
const logger = new Logger('DatabaseModule');
|
||||
logger.log('Initializing database connection');
|
||||
return getDb();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN } from '../database.module';
|
||||
import type { Database } from '../database.module';
|
||||
import { clicks, eq, desc, sql, and, gte, lte } from '@manacore/uload-database';
|
||||
import type { Click, NewClick } from '@manacore/uload-database';
|
||||
|
||||
export interface ClickStats {
|
||||
totalClicks: number;
|
||||
uniqueVisitors: number;
|
||||
topCountries: { country: string; count: number }[];
|
||||
topBrowsers: { browser: string; count: number }[];
|
||||
topDevices: { deviceType: string; count: number }[];
|
||||
clicksByDay: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClickRepository {
|
||||
private readonly logger = new Logger(ClickRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async create(data: NewClick): Promise<Click> {
|
||||
const result = await this.db.insert(clicks).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findByLinkId(
|
||||
linkId: string,
|
||||
options: { limit?: number; offset?: number } = {}
|
||||
): Promise<Click[]> {
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
return this.db
|
||||
.select()
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.orderBy(desc(clicks.clickedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async countByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
|
||||
const conditions = [eq(clicks.linkId, linkId)];
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(gte(clicks.clickedAt, fromDate));
|
||||
}
|
||||
if (toDate) {
|
||||
conditions.push(lte(clicks.clickedAt, toDate));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Total clicks
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Unique visitors (by IP hash)
|
||||
const uniqueResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${clicks.ipHash})::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Top countries
|
||||
const countriesResult = await this.db
|
||||
.select({
|
||||
country: clicks.country,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.country)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top browsers
|
||||
const browsersResult = await this.db
|
||||
.select({
|
||||
browser: clicks.browser,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.browser)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top devices
|
||||
const devicesResult = await this.db
|
||||
.select({
|
||||
deviceType: clicks.deviceType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.deviceType)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Clicks by day (last 30 days)
|
||||
const clicksByDayResult = await this.db
|
||||
.select({
|
||||
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date::text`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
totalClicks: totalResult[0]?.count || 0,
|
||||
uniqueVisitors: uniqueResult[0]?.count || 0,
|
||||
topCountries: countriesResult.map((r) => ({
|
||||
country: r.country || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topBrowsers: browsersResult.map((r) => ({
|
||||
browser: r.browser || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topDevices: devicesResult.map((r) => ({
|
||||
deviceType: r.deviceType || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
clicksByDay: clicksByDayResult.map((r) => ({
|
||||
date: r.date,
|
||||
count: r.count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.returning({ id: clicks.id });
|
||||
return result.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { LinkRepository, type ListLinksOptions } from './link.repository';
|
||||
export { ClickRepository, type ClickStats } from './click.repository';
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN } from '../database.module';
|
||||
import type { Database } from '../database.module';
|
||||
import { links, eq, and, desc, sql, or, ilike } from '@manacore/uload-database';
|
||||
import type { Link, NewLink } from '@manacore/uload-database';
|
||||
|
||||
export interface ListLinksOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkRepository {
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByShortCode(shortCode: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Link | null> {
|
||||
const result = await this.db.select().from(links).where(eq(links.id, id)).limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
options: ListLinksOptions = {}
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
const { page = 1, limit = 20, search, isActive } = options;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(links.userId, userId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(links.title, `%${search}%`),
|
||||
ilike(links.originalUrl, `%${search}%`),
|
||||
ilike(links.shortCode, `%${search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(eq(links.isActive, isActive));
|
||||
}
|
||||
|
||||
const [countResult, items] = await Promise.all([
|
||||
this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(and(...conditions)),
|
||||
this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(links.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: countResult[0]?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: NewLink): Promise<Link> {
|
||||
this.logger.debug(`Creating link: ${data.shortCode}`);
|
||||
const result = await this.db.insert(links).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Omit<NewLink, 'id' | 'userId' | 'createdAt'>>
|
||||
): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.update(links)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning({ id: links.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async incrementClickCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(links)
|
||||
.set({ clickCount: sql`${links.clickCount} + 1` })
|
||||
.where(eq(links.id, id));
|
||||
}
|
||||
|
||||
async isShortCodeAvailable(shortCode: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ id: links.id })
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(eq(links.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Global prefix for API routes (except health and redirect)
|
||||
app.setGlobalPrefix('v1', {
|
||||
exclude: ['health', 'health/(.*)', ':code'],
|
||||
});
|
||||
|
||||
const port = configService.get('PORT') || 3003;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`ULOAD Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as UAParser from 'ua-parser-js';
|
||||
import { ClickRepository } from '../database/repositories';
|
||||
import type { ClickStats } from '../database/repositories';
|
||||
import { RedirectService } from './redirect.service';
|
||||
import type { NewClick } from '@manacore/uload-database';
|
||||
|
||||
export interface RecordClickData {
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
ip?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickRepository: ClickRepository,
|
||||
private readonly redirectService: RedirectService
|
||||
) {}
|
||||
|
||||
async recordClick(linkId: string, data: RecordClickData): Promise<void> {
|
||||
try {
|
||||
// Parse user agent
|
||||
const parser = new UAParser.UAParser(data.userAgent);
|
||||
const browser = parser.getBrowser();
|
||||
const os = parser.getOS();
|
||||
const device = parser.getDevice();
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = data.ip ? this.hashIp(data.ip) : null;
|
||||
|
||||
// Determine device type
|
||||
let deviceType = 'desktop';
|
||||
if (device.type === 'mobile') {
|
||||
deviceType = 'mobile';
|
||||
} else if (device.type === 'tablet') {
|
||||
deviceType = 'tablet';
|
||||
}
|
||||
|
||||
const clickData: NewClick = {
|
||||
linkId,
|
||||
ipHash,
|
||||
userAgent: data.userAgent,
|
||||
referer: data.referer,
|
||||
browser: browser.name || 'Unknown',
|
||||
deviceType,
|
||||
os: os.name || 'Unknown',
|
||||
// TODO: Geo lookup from IP
|
||||
country: null,
|
||||
city: null,
|
||||
utmSource: data.utmSource,
|
||||
utmMedium: data.utmMedium,
|
||||
utmCampaign: data.utmCampaign,
|
||||
};
|
||||
|
||||
await this.clickRepository.create(clickData);
|
||||
|
||||
// Increment click count on the link
|
||||
await this.redirectService.incrementClickCount(linkId);
|
||||
|
||||
this.logger.debug(`Recorded click for link ${linkId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to record click for link ${linkId}:`, error);
|
||||
// Don't throw - click recording should not block redirect
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(linkId: string, fromDate?: Date, toDate?: Date): Promise<ClickStats> {
|
||||
return this.clickRepository.getStats(linkId, fromDate, toDate);
|
||||
}
|
||||
|
||||
async getRecentClicks(
|
||||
linkId: string,
|
||||
limit: number = 100
|
||||
): Promise<{ clicks: any[]; total: number }> {
|
||||
const [clicks, total] = await Promise.all([
|
||||
this.clickRepository.findByLinkId(linkId, { limit }),
|
||||
this.clickRepository.countByLinkId(linkId),
|
||||
]);
|
||||
|
||||
return { clicks, total };
|
||||
}
|
||||
|
||||
private hashIp(ip: string): string {
|
||||
// Simple hash for privacy - in production use a proper hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
const char = ip.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LinkRepository } from '../database/repositories';
|
||||
import type { ListLinksOptions } from '../database/repositories';
|
||||
import type { Link, NewLink } from '@manacore/uload-database';
|
||||
|
||||
export interface CreateLinkDto {
|
||||
originalUrl: string;
|
||||
customCode?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
isActive?: boolean;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinksService {
|
||||
private readonly logger = new Logger(LinksService.name);
|
||||
private readonly shortUrlBase: string;
|
||||
|
||||
constructor(
|
||||
private readonly linkRepository: LinkRepository,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad');
|
||||
}
|
||||
|
||||
async createLink(userId: string, dto: CreateLinkDto): Promise<Link> {
|
||||
// Generate or validate short code
|
||||
let shortCode = dto.customCode;
|
||||
|
||||
if (shortCode) {
|
||||
// Validate custom code format
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) {
|
||||
throw new BadRequestException(
|
||||
'Custom code can only contain letters, numbers, hyphens and underscores'
|
||||
);
|
||||
}
|
||||
|
||||
// Check if custom code is available
|
||||
const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode);
|
||||
if (!isAvailable) {
|
||||
throw new BadRequestException('This custom code is already taken');
|
||||
}
|
||||
} else {
|
||||
// Generate random short code
|
||||
shortCode = nanoid(7);
|
||||
|
||||
// Make sure it's unique (very unlikely to collide, but check anyway)
|
||||
let attempts = 0;
|
||||
while (!(await this.linkRepository.isShortCodeAvailable(shortCode)) && attempts < 5) {
|
||||
shortCode = nanoid(7);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
const newLink: NewLink = {
|
||||
shortCode,
|
||||
customCode: dto.customCode,
|
||||
originalUrl: dto.originalUrl,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
userId,
|
||||
password: dto.password, // TODO: Hash password if provided
|
||||
maxClicks: dto.maxClicks,
|
||||
expiresAt: dto.expiresAt,
|
||||
tags: dto.tags,
|
||||
utmSource: dto.utmSource,
|
||||
utmMedium: dto.utmMedium,
|
||||
utmCampaign: dto.utmCampaign,
|
||||
workspaceId: dto.workspaceId,
|
||||
};
|
||||
|
||||
const link = await this.linkRepository.create(newLink);
|
||||
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async updateLink(id: string, userId: string, dto: UpdateLinkDto): Promise<Link | null> {
|
||||
const link = await this.linkRepository.update(id, userId, dto);
|
||||
|
||||
if (link) {
|
||||
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async deleteLink(id: string, userId: string): Promise<boolean> {
|
||||
const deleted = await this.linkRepository.delete(id, userId);
|
||||
|
||||
if (deleted) {
|
||||
this.logger.log(`Deleted link ${id} for user ${userId}`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getLinkById(id: string, userId: string): Promise<Link | null> {
|
||||
return this.linkRepository.findByIdAndUserId(id, userId);
|
||||
}
|
||||
|
||||
async getLinks(
|
||||
userId: string,
|
||||
options: ListLinksOptions
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
return this.linkRepository.findByUserId(userId, options);
|
||||
}
|
||||
|
||||
async getLinkCount(userId: string): Promise<number> {
|
||||
return this.linkRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LinkRepository } from '../database/repositories';
|
||||
import type { Link } from '@manacore/uload-database';
|
||||
|
||||
export interface RedirectResult {
|
||||
success: boolean;
|
||||
targetUrl?: string;
|
||||
linkId?: string;
|
||||
error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedirectService {
|
||||
private readonly logger = new Logger(RedirectService.name);
|
||||
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// Check if link is active
|
||||
if (!link.isActive) {
|
||||
return { success: false, error: 'inactive', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if link has expired
|
||||
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
|
||||
return { success: false, error: 'expired', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check max clicks
|
||||
if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) {
|
||||
return { success: false, error: 'max_clicks', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if password protected
|
||||
if (link.password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(shortCode: string, password: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// TODO: Compare hashed passwords
|
||||
if (link.password !== password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"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,20 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://ulo.ad',
|
||||
integrations: [
|
||||
tailwind(),
|
||||
mdx(),
|
||||
sitemap()
|
||||
],
|
||||
i18n: {
|
||||
defaultLocale: 'de',
|
||||
locales: ['de', 'en'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
---
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
produkt: [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/#pricing', label: 'Preise' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
],
|
||||
unternehmen: [{ href: '/about', label: 'Über uns' }],
|
||||
rechtliches: [
|
||||
{ href: '/datenschutz', label: 'Datenschutz' },
|
||||
{ href: '/impressum', label: 'Impressum' },
|
||||
{ href: '/agb', label: 'AGB' },
|
||||
{ href: '/sicherheit', label: 'Sicherheit' },
|
||||
],
|
||||
};
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<footer class="bg-gray-900 text-gray-300">
|
||||
<div class="container-custom py-12 md:py-16">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-2 md:col-span-1">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-white">uLoad</span>
|
||||
</a>
|
||||
<p class="text-sm text-gray-400 mb-4">
|
||||
Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und
|
||||
analysieren Sie Klicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Produkt -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.produkt.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Unternehmen -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Unternehmen</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.unternehmen.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rechtliches -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.rechtliches.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="border-t border-gray-800 mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-sm text-gray-400">
|
||||
© {currentYear} uLoad. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={`${appUrl}/login`}
|
||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
App öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
---
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<section
|
||||
class="relative overflow-hidden bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-primary-500/10 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<!-- Trust badges -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
></path>
|
||||
</svg>
|
||||
DSGVO-konform
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Blitzschnell
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
100% Sicher
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main headline -->
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
More than links.
|
||||
<span class="bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Your digital identity.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="mx-auto mb-8 max-w-2xl text-lg text-gray-600 sm:text-xl">
|
||||
Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
|
||||
beeindruckende Profilkarten und manage alles im Team.
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700 hover:shadow-xl"
|
||||
>
|
||||
Kostenlos starten →
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="rounded-lg border-2 border-gray-200 bg-white px-8 py-3 font-semibold text-gray-900 transition hover:border-primary-500 hover:shadow-lg"
|
||||
>
|
||||
Features entdecken
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Shortener teaser -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white/80 p-4 backdrop-blur sm:flex-row sm:p-2"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Deine lange URL hier einfügen..."
|
||||
disabled
|
||||
class="flex-1 rounded-lg border-0 bg-transparent px-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none sm:py-2"
|
||||
/>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition hover:bg-primary-700 sm:py-2 text-center"
|
||||
>
|
||||
Kürzen →
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual preview -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Link shortening preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Smart Links</h3>
|
||||
<p class="text-sm text-gray-600">Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz</p>
|
||||
<a
|
||||
href="/features"
|
||||
class="mt-4 inline-block text-xs text-primary-600 group-hover:underline"
|
||||
>
|
||||
Mehr erfahren →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Profile cards preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Profile Cards</h3>
|
||||
<p class="text-sm text-gray-600">Beeindruckende Profilseiten mit Drag & Drop Builder</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-purple-600 group-hover:underline">
|
||||
Templates ansehen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Team collaboration preview -->
|
||||
<div
|
||||
class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Team Workspace</h3>
|
||||
<p class="text-sm text-gray-600">Gemeinsam Links verwalten mit granularen Berechtigungen</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-green-600 group-hover:underline">
|
||||
Für Teams →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/about', label: 'Über uns' },
|
||||
];
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
|
||||
<nav class="container-custom">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">uLoad</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href={`${appUrl}/login`} class="text-gray-600 hover:text-gray-900 font-medium">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary"> Kostenlos starten </a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="md:hidden p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label="Menü öffnen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a href={link.href} class="text-gray-600 hover:text-gray-900 font-medium py-2">
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div class="flex flex-col gap-2 pt-4 border-t border-gray-100">
|
||||
<a href={`${appUrl}/login`} class="btn-secondary text-center"> Anmelden </a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary text-center"> Kostenlos starten </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
---
|
||||
title: Der ultimative Link-Tracking Guide für 2024
|
||||
description: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
|
||||
pubDate: 2024-01-20
|
||||
author: Till Schneider
|
||||
tags: [tracking, analytics, dsgvo, marketing]
|
||||
---
|
||||
|
||||
Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern.
|
||||
|
||||
## Was ist Link-Tracking?
|
||||
|
||||
Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
|
||||
|
||||
- Woher kommen Ihre Besucher?
|
||||
- Welche Kampagnen funktionieren?
|
||||
- Wie hoch ist Ihre Conversion-Rate?
|
||||
- Welche Inhalte performen am besten?
|
||||
|
||||
## Die wichtigsten Metriken
|
||||
|
||||
### 1. Click-Through-Rate (CTR)
|
||||
|
||||
Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
|
||||
|
||||
### 2. Conversion Rate
|
||||
|
||||
Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen.
|
||||
|
||||
### 3. Bounce Rate
|
||||
|
||||
Wie viele Nutzer verlassen Ihre Seite sofort wieder?
|
||||
|
||||
### 4. Geographic Distribution
|
||||
|
||||
Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
|
||||
|
||||
## UTM-Parameter richtig einsetzen
|
||||
|
||||
UTM-Parameter sind der Standard für Campaign-Tracking:
|
||||
|
||||
```
|
||||
https://ulo.ad/angebot
|
||||
?utm_source=newsletter
|
||||
&utm_medium=email
|
||||
&utm_campaign=winter-sale
|
||||
```
|
||||
|
||||
### Die 5 UTM-Parameter
|
||||
|
||||
1. **utm_source**: Woher kommt der Traffic?
|
||||
2. **utm_medium**: Welches Medium?
|
||||
3. **utm_campaign**: Welche Kampagne?
|
||||
4. **utm_content**: Welcher spezifische Link?
|
||||
5. **utm_term**: Welches Keyword?
|
||||
|
||||
## DSGVO-konformes Tracking
|
||||
|
||||
### Was ist erlaubt?
|
||||
|
||||
✅ **Anonymisierte Daten**
|
||||
|
||||
- Gerätetyp
|
||||
- Browser
|
||||
- Ungefährer Standort
|
||||
- Referrer
|
||||
|
||||
### Was braucht Zustimmung?
|
||||
|
||||
❌ **Personenbezogene Daten**
|
||||
|
||||
- Vollständige IP-Adressen
|
||||
- Device Fingerprinting
|
||||
- Cross-Site Tracking
|
||||
|
||||
## Best Practices für Link-Tracking
|
||||
|
||||
### 1. Konsistente Namenskonvention
|
||||
|
||||
Entwickeln Sie ein einheitliches Schema für Ihre Kampagnen.
|
||||
|
||||
### 2. Dokumentation führen
|
||||
|
||||
Erstellen Sie eine Tracking-Tabelle für alle Kampagnen.
|
||||
|
||||
### 3. Regelmäßige Bereinigung
|
||||
|
||||
Löschen Sie alte, inaktive Links regelmäßig.
|
||||
|
||||
## Fazit
|
||||
|
||||
Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern.
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
|
||||
description: 42% weniger Klicks bei langen URLs – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter.
|
||||
pubDate: 2024-01-15
|
||||
author: Till Schneider
|
||||
tags: [urls, psychology, conversion, marketing]
|
||||
---
|
||||
|
||||
**42% weniger Klicks bei langen URLs** – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können.
|
||||
|
||||
## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen
|
||||
|
||||
Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link – nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance.
|
||||
|
||||
### Die Spam-Alarm-Reaktion unseres Gehirns
|
||||
|
||||
Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen.
|
||||
|
||||
Vergleichen Sie diese beiden URLs:
|
||||
|
||||
**Lange URL (schlecht):**
|
||||
|
||||
```
|
||||
https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024
|
||||
```
|
||||
|
||||
**Kurze URL (gut):**
|
||||
|
||||
```
|
||||
https://ulo.ad/summer-sale
|
||||
```
|
||||
|
||||
### Mobile Nutzer: Die vergessene Mehrheit
|
||||
|
||||
In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen.
|
||||
|
||||
## Die Wissenschaft dahinter: Cognitive Load Theory
|
||||
|
||||
Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
|
||||
|
||||
## Die vier Säulen des Link-Vertrauens
|
||||
|
||||
1. **Erkennbare Domain (60% Wichtigkeit)** - Menschen wollen wissen, wo sie landen werden
|
||||
2. **Keine kryptischen Zeichen (25% Wichtigkeit)** - Zufällige Zahlen-Buchstaben-Kombinationen schrecken ab
|
||||
3. **Optimale Länge (10% Wichtigkeit)** - Die magische Grenze liegt bei etwa 50 Zeichen
|
||||
4. **HTTPS-Verschlüsselung (5% Wichtigkeit)** - Ein Hygienefaktor
|
||||
|
||||
## Praktische Optimierungsstrategien
|
||||
|
||||
### 1. Sprechende URLs verwenden
|
||||
|
||||
❌ **Schlecht:** `ulo.ad/p47829`
|
||||
✅ **Gut:** `ulo.ad/sommer-sale`
|
||||
|
||||
### 2. Die 50-Zeichen-Regel
|
||||
|
||||
Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
|
||||
|
||||
- Kurz genug für Twitter/X
|
||||
- Lesbar auf Mobilgeräten
|
||||
- Merkbar für Nutzer
|
||||
|
||||
### 3. A/B-Testing ist Ihr Freund
|
||||
|
||||
Testen Sie verschiedene URL-Varianten und messen Sie die Performance.
|
||||
|
||||
## Fazit: Die Macht der Kürze
|
||||
|
||||
Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen.
|
||||
|
||||
### Die wichtigsten Takeaways
|
||||
|
||||
1. **42% weniger Klicks** bei URLs über 100 Zeichen
|
||||
2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit
|
||||
3. **50 Zeichen** ist die magische Grenze
|
||||
4. **Sprechende URLs** performen 39% besser
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blogCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
author: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
blog: blogCollection,
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'uLoad - Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.',
|
||||
ogImage = '/og-image.png',
|
||||
} = Astro.props;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<title>{title} | uLoad</title>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<Navigation />
|
||||
<main class="flex-grow">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
const { title, description, lastUpdated } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<header class="mb-12">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
{lastUpdated && <p class="text-gray-500">Zuletzt aktualisiert: {lastUpdated}</p>}
|
||||
</header>
|
||||
|
||||
<div class="prose prose-lg prose-gray max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const stats = [
|
||||
{ value: '10K+', label: 'Aktive Nutzer' },
|
||||
{ value: '500K+', label: 'Erstellte Links' },
|
||||
{ value: '2M+', label: 'Klicks verfolgt' },
|
||||
{ value: '99.9%', label: 'Uptime' },
|
||||
];
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Einfachheit',
|
||||
description:
|
||||
'Wir glauben, dass professionelle Tools nicht kompliziert sein müssen. uLoad ist intuitiv und sofort einsatzbereit.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Datenschutz',
|
||||
description:
|
||||
'Ihre Daten gehören Ihnen. Wir sind DSGVO-konform und speichern nur was wirklich notwendig ist.',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Performance',
|
||||
description:
|
||||
'Schnelle Links bedeuten bessere Nutzererfahrung. Unsere Infrastruktur ist auf Geschwindigkeit optimiert.',
|
||||
},
|
||||
{
|
||||
icon: '💪',
|
||||
title: 'Zuverlässigkeit',
|
||||
description:
|
||||
'Mit 99.9% Uptime können Sie sich auf uLoad verlassen - für jede Kampagne, jedes Projekt.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Über uns"
|
||||
description="Erfahren Sie mehr über uLoad - den intelligenten URL-Shortener für Profis."
|
||||
>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Links die verbinden
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
uLoad wurde entwickelt um Link-Management einfach, sicher und effektiv zu machen. Für
|
||||
Einzelpersonen, Teams und Unternehmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="bg-primary-600 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-white">{stat.value}</div>
|
||||
<div class="mt-1 text-primary-100">{stat.label}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Story -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Unsere Geschichte</h2>
|
||||
<div class="prose prose-lg mx-auto text-gray-600">
|
||||
<p>
|
||||
uLoad entstand aus einer einfachen Frustration: Bestehende URL-Shortener waren entweder zu
|
||||
kompliziert, zu teuer oder boten nicht die Features die moderne Teams brauchen.
|
||||
</p>
|
||||
<p>
|
||||
Wir wollten einen Service schaffen, der sowohl für Einsteiger als auch für Power-User
|
||||
funktioniert. Ein Tool das mit Ihren Anforderungen wächst - von der ersten verkürzten URL
|
||||
bis zum Enterprise-Einsatz.
|
||||
</p>
|
||||
<p>
|
||||
Heute nutzen tausende Nutzer uLoad täglich für ihre Marketing-Kampagnen,
|
||||
Social-Media-Posts und geschäftliche Kommunikation. Und wir arbeiten jeden Tag daran,
|
||||
uLoad noch besser zu machen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values -->
|
||||
<section class="bg-gray-50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">Unsere Werte</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{
|
||||
values.map((value) => (
|
||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
||||
<div class="mb-4 text-4xl">{value.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{value.title}</h3>
|
||||
<p class="text-sm text-gray-600">{value.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-gray-900">Werden Sie Teil der uLoad Community</h2>
|
||||
<p class="mb-8 text-lg text-gray-600">Schließen Sie sich tausenden zufriedenen Nutzern an.</p>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Allgemeine Geschäftsbedingungen" lastUpdated="Januar 2024">
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
<p>
|
||||
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Verträge zwischen uLoad und dem
|
||||
Nutzer über die Nutzung der auf der Website ulo.ad angebotenen Dienste.
|
||||
</p>
|
||||
|
||||
<h2>§ 2 Leistungsbeschreibung</h2>
|
||||
<p>
|
||||
uLoad bietet einen URL-Verkürzungsdienst sowie ergänzende Dienste wie Analytics,
|
||||
QR-Code-Generierung und Team-Workspaces an. Der genaue Leistungsumfang ergibt sich aus der
|
||||
jeweiligen Produktbeschreibung zum Zeitpunkt der Bestellung.
|
||||
</p>
|
||||
|
||||
<h2>§ 3 Registrierung und Nutzerkonto</h2>
|
||||
<p>
|
||||
Für die Nutzung bestimmter Funktionen ist eine Registrierung erforderlich. Der Nutzer
|
||||
verpflichtet sich, wahrheitsgemäße Angaben zu machen und diese aktuell zu halten. Der Nutzer ist
|
||||
für die Geheimhaltung seiner Zugangsdaten verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>§ 4 Nutzungsregeln</h2>
|
||||
<p>
|
||||
Der Nutzer verpflichtet sich, den Dienst nicht für rechtswidrige Zwecke zu nutzen. Insbesondere
|
||||
ist es untersagt:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Links zu illegalen Inhalten zu erstellen</li>
|
||||
<li>Spam oder Phishing-Links zu verbreiten</li>
|
||||
<li>Die Dienste für automatisierte Massenanfragen zu missbrauchen</li>
|
||||
<li>Andere Nutzer zu belästigen oder zu täuschen</li>
|
||||
</ul>
|
||||
|
||||
<h2>§ 5 Preise und Zahlung</h2>
|
||||
<p>
|
||||
Die Nutzung der Basisfunktionen ist kostenlos. Für erweiterte Funktionen können kostenpflichtige
|
||||
Abonnements abgeschlossen werden. Alle Preise verstehen sich inklusive der gesetzlichen
|
||||
Mehrwertsteuer.
|
||||
</p>
|
||||
|
||||
<h2>§ 6 Kündigung</h2>
|
||||
<p>
|
||||
Kostenlose Konten können jederzeit gelöscht werden. Kostenpflichtige Abonnements können zum Ende
|
||||
der jeweiligen Abrechnungsperiode gekündigt werden.
|
||||
</p>
|
||||
|
||||
<h2>§ 7 Haftung</h2>
|
||||
<p>
|
||||
uLoad haftet nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen.
|
||||
Die Haftung für leichte Fahrlässigkeit ist ausgeschlossen, soweit nicht wesentliche
|
||||
Vertragspflichten verletzt wurden.
|
||||
</p>
|
||||
|
||||
<h2>§ 8 Datenschutz</h2>
|
||||
<p>
|
||||
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung und den
|
||||
geltenden Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>§ 9 Änderungen der AGB</h2>
|
||||
<p>
|
||||
uLoad behält sich vor, diese AGB jederzeit zu ändern. Änderungen werden dem Nutzer rechtzeitig
|
||||
mitgeteilt. Mit der weiteren Nutzung des Dienstes nach Inkrafttreten der Änderungen erklärt sich
|
||||
der Nutzer mit diesen einverstanden.
|
||||
</p>
|
||||
|
||||
<h2>§ 10 Schlussbestimmungen</h2>
|
||||
<p>
|
||||
Es gilt das Recht der Bundesrepublik Deutschland. Sollten einzelne Bestimmungen dieser AGB
|
||||
unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = { post: CollectionEntry<'blog'> };
|
||||
const { post } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-12">
|
||||
<a
|
||||
href="/blog"
|
||||
class="inline-flex items-center gap-2 text-sm text-primary-600 hover:underline mb-6"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Zurück zum Blog
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl mb-4">
|
||||
{post.data.title}
|
||||
</h1>
|
||||
<div class="flex items-center gap-4 text-gray-500">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{
|
||||
post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="prose prose-lg prose-gray max-w-none prose-headings:font-bold prose-a:text-primary-600 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-16 pt-8 border-t border-gray-200">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<a href="/blog" class="text-primary-600 hover:underline"> ← Alle Artikel </a>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-6 py-2 font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt uLoad testen
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const posts = (await getCollection('blog')).sort(
|
||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
|
||||
);
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Blog"
|
||||
description="Tipps, Tricks und Best Practices rund um Link-Management, URL-Verkürzung und digitales Marketing."
|
||||
>
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">Blog</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Tipps, Tricks und Best Practices rund um Link-Management und digitales Marketing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<article class="group rounded-xl border border-gray-200 bg-white overflow-hidden transition hover:shadow-xl">
|
||||
<a href={`/blog/${post.slug}`} class="block">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p class="text-gray-600 line-clamp-3">{post.data.description}</p>
|
||||
{post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Datenschutzerklärung" lastUpdated="Januar 2024">
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
|
||||
<h3>Allgemeine Hinweise</h3>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
|
||||
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit
|
||||
denen Sie persönlich identifiziert werden können.
|
||||
</p>
|
||||
|
||||
<h3>Datenerfassung auf dieser Website</h3>
|
||||
<p>
|
||||
<strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong><br />
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten
|
||||
können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
|
||||
<h3>Wie erfassen wir Ihre Daten?</h3>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich
|
||||
z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
|
||||
</p>
|
||||
<p>
|
||||
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das
|
||||
sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des
|
||||
Seitenaufrufs).
|
||||
</p>
|
||||
|
||||
<h2>2. Hosting</h2>
|
||||
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:</p>
|
||||
<p>
|
||||
Die Server befinden sich in Deutschland und unterliegen den strengen deutschen
|
||||
Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
|
||||
|
||||
<h3>Datenschutz</h3>
|
||||
<p>
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln
|
||||
Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
|
||||
Datenschutzvorschriften sowie dieser Datenschutzerklärung.
|
||||
</p>
|
||||
|
||||
<h3>Hinweis zur verantwortlichen Stelle</h3>
|
||||
<p>
|
||||
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist im Impressum
|
||||
genannt.
|
||||
</p>
|
||||
|
||||
<h2>4. Datenerfassung auf dieser Website</h2>
|
||||
|
||||
<h3>Cookies</h3>
|
||||
<p>
|
||||
Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Datenpakete und
|
||||
richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer
|
||||
einer Sitzung (Session-Cookies) oder dauerhaft (permanente Cookies) auf Ihrem Endgerät
|
||||
gespeichert.
|
||||
</p>
|
||||
|
||||
<h3>Server-Log-Dateien</h3>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
|
||||
Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse (anonymisiert)</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer
|
||||
gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die
|
||||
Berichtigung oder Löschung dieser Daten zu verlangen.
|
||||
</p>
|
||||
|
||||
<h2>6. Kontakt</h2>
|
||||
<p>
|
||||
Bei Fragen zum Datenschutz können Sie sich jederzeit an uns wenden. Die Kontaktdaten finden Sie
|
||||
im Impressum.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
|
||||
const featureCategories = [
|
||||
{
|
||||
title: 'Link Management',
|
||||
features: [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'URL-Verkürzung',
|
||||
description:
|
||||
'Verwandeln Sie lange URLs in kurze, merkbare Links. Perfekt für Social Media, E-Mails und gedruckte Materialien.',
|
||||
},
|
||||
{
|
||||
icon: '✏️',
|
||||
title: 'Custom Short Codes',
|
||||
description:
|
||||
'Erstellen Sie personalisierte Kurz-URLs wie ulo.ad/mein-link für bessere Wiedererkennung.',
|
||||
},
|
||||
{
|
||||
icon: '📅',
|
||||
title: 'Ablaufdatum',
|
||||
description:
|
||||
'Setzen Sie automatische Ablaufdaten für zeitlich begrenzte Aktionen und Kampagnen.',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Passwortschutz',
|
||||
description: 'Schützen Sie sensible Links mit Passwörtern für zusätzliche Sicherheit.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Analytics & Tracking',
|
||||
features: [
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Klick-Tracking',
|
||||
description: 'Verfolgen Sie jeden Klick in Echtzeit mit detaillierten Statistiken.',
|
||||
},
|
||||
{
|
||||
icon: '🌍',
|
||||
title: 'Geografische Daten',
|
||||
description: 'Sehen Sie woher Ihre Besucher kommen mit Länder- und Städte-Aufschlüsselung.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Geräte-Analyse',
|
||||
description:
|
||||
'Erfahren Sie welche Geräte, Browser und Betriebssysteme Ihre Nutzer verwenden.',
|
||||
},
|
||||
{
|
||||
icon: '📈',
|
||||
title: 'Referrer-Tracking',
|
||||
description:
|
||||
'Identifizieren Sie die Quellen Ihres Traffics für bessere Marketing-Entscheidungen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'QR-Codes',
|
||||
features: [
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Anpassbare Designs',
|
||||
description: 'Erstellen Sie QR-Codes in Ihren Markenfarben für konsistentes Branding.',
|
||||
},
|
||||
{
|
||||
icon: '📐',
|
||||
title: 'Multiple Formate',
|
||||
description: 'Download in PNG, SVG oder PDF für verschiedene Anwendungsfälle.',
|
||||
},
|
||||
{
|
||||
icon: '⬇️',
|
||||
title: 'Hochauflösend',
|
||||
description: 'Druckqualität bis zu 4000x4000 Pixel für großformatige Medien.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Team & Kollaboration',
|
||||
features: [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description: 'Erstellen Sie gemeinsame Arbeitsbereiche für Ihr Team oder Ihre Kunden.',
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
title: 'Rollenbasierte Rechte',
|
||||
description: 'Definieren Sie wer Links erstellen, bearbeiten oder nur ansehen darf.',
|
||||
},
|
||||
{
|
||||
icon: '🏷️',
|
||||
title: 'Tag-System',
|
||||
description: 'Organisieren Sie Links mit Tags für bessere Übersicht in großen Teams.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Features"
|
||||
description="Entdecken Sie alle Features von uLoad - URL-Verkürzung, Analytics, QR-Codes und Team-Kollaboration."
|
||||
>
|
||||
<!-- Hero -->
|
||||
<section
|
||||
class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Features die den Unterschied machen
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Von einfacher URL-Verkürzung bis hin zu detaillierten Analytics – uLoad bietet alles was
|
||||
Profis brauchen.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Categories -->
|
||||
{
|
||||
featureCategories.map((category, idx) => (
|
||||
<section
|
||||
class:list={['px-4 py-16 sm:px-6 lg:px-8', idx % 2 === 1 ? 'bg-gray-50' : 'bg-white']}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">{category.title}</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{category.features.map((feature) => (
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-lg">
|
||||
<div class="mb-4 text-4xl">{feature.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{feature.title}</h3>
|
||||
<p class="text-sm text-gray-600">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="bg-primary-600 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-white">Bereit loszulegen?</h2>
|
||||
<p class="mb-8 text-lg text-primary-100">
|
||||
Starten Sie kostenlos und entdecken Sie alle Features selbst.
|
||||
</p>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition hover:bg-gray-100"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Impressum">
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
|
||||
<p>
|
||||
<strong>uLoad</strong><br />
|
||||
[Ihr Name / Firmenname]<br />
|
||||
[Straße und Hausnummer]<br />
|
||||
[PLZ Ort]<br />
|
||||
Deutschland
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>E-Mail: kontakt@ulo.ad</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>
|
||||
[Ihr Name]<br />
|
||||
[Adresse wie oben]
|
||||
</p>
|
||||
|
||||
<h2>EU-Streitschlichtung</h2>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener"
|
||||
>https://ec.europa.eu/consumers/odr/</a
|
||||
>
|
||||
</p>
|
||||
<p>Unsere E-Mail-Adresse finden Sie oben im Impressum.</p>
|
||||
|
||||
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
|
||||
Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
|
||||
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch
|
||||
nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
|
||||
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss
|
||||
haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die
|
||||
Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
|
||||
verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
|
||||
jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import HeroSection from '../components/HeroSection.astro';
|
||||
|
||||
// Shared components
|
||||
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
|
||||
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
|
||||
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
|
||||
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
|
||||
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'Smart Links',
|
||||
description:
|
||||
'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description:
|
||||
'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'QR-Code Generator',
|
||||
description:
|
||||
'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.',
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Profile Cards',
|
||||
description:
|
||||
'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.',
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description:
|
||||
'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.',
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: 'API & Integrationen',
|
||||
description:
|
||||
'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Link einfügen',
|
||||
description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
|
||||
image: '/screenshots/paste.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Anpassen',
|
||||
description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
|
||||
image: '/screenshots/customize.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Teilen & Tracken',
|
||||
description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
|
||||
image: '/screenshots/share.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Links pro Monat', included: true },
|
||||
{ text: 'Basis Analytics', included: true },
|
||||
{ text: 'QR-Code Generator', included: true },
|
||||
{ text: 'Link Anpassung', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: false },
|
||||
{ text: 'Team Features', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: `${appUrl}/register`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '4,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Freelancer & Creators',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Passwortschutz', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro wählen',
|
||||
href: `${appUrl}/register?plan=pro`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro Jährlich',
|
||||
price: '3,33',
|
||||
period: '/Monat',
|
||||
description: 'Spare 20€ pro Jahr',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Unbegrenzte Links', included: true },
|
||||
{ text: 'Erweiterte Analytics', included: true },
|
||||
{ text: 'Custom QR Codes', included: true },
|
||||
{ text: 'API Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Jährlich sparen',
|
||||
href: `${appUrl}/register?plan=pro-yearly`,
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Spare 20€',
|
||||
},
|
||||
{
|
||||
name: 'Lifetime',
|
||||
price: '129,99',
|
||||
period: 'einmalig',
|
||||
description: 'Einmal zahlen, für immer nutzen',
|
||||
features: [
|
||||
{ text: 'Alle Pro Features', included: true },
|
||||
{ text: 'Lebenslanger Zugang', included: true },
|
||||
{ text: 'Alle zukünftigen Features', included: true },
|
||||
{ text: 'Early Access', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Keine Abo-Gebühren', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Lifetime sichern',
|
||||
href: `${appUrl}/register?plan=lifetime`,
|
||||
},
|
||||
badge: 'Einmalig',
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie lange bleiben meine Links aktiv?',
|
||||
answer:
|
||||
'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine eigene Domain verwenden?',
|
||||
answer:
|
||||
'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).',
|
||||
},
|
||||
{
|
||||
question: 'Wie funktionieren die Analytics?',
|
||||
answer:
|
||||
'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.',
|
||||
},
|
||||
{
|
||||
question: 'Was sind Profile Cards?',
|
||||
answer:
|
||||
'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.',
|
||||
},
|
||||
{
|
||||
question: 'Gibt es eine API?',
|
||||
answer:
|
||||
'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer:
|
||||
'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Intelligenter URL-Shortener">
|
||||
<HeroSection />
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du für professionelles Link-Management brauchst"
|
||||
subtitle="Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration - uLoad bietet alle Features die du brauchst."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
/>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum perfekten Link"
|
||||
subtitle="So einfach funktioniert uLoad"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Transparente Preise, keine versteckten Kosten"
|
||||
subtitle="Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar."
|
||||
plans={pricingPlans}
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über uLoad wissen musst"
|
||||
faqs={faqs}
|
||||
class="bg-gray-50"
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="cta"
|
||||
title="Bereit für smarte Links?"
|
||||
subtitle="Starte jetzt kostenlos und erlebe, wie einfach professionelles Link-Management sein kann."
|
||||
primaryCta={{ text: 'Kostenlos starten', href: `${appUrl}/register` }}
|
||||
secondaryCta={{ text: 'Features entdecken', href: '/features' }}
|
||||
variant="default"
|
||||
/>
|
||||
</BaseLayout>
|
||||
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