chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -1,12 +0,0 @@
# Database
DATABASE_URL=postgresql://news:news_dev_password@localhost:5432/news_hub
# API
API_PORT=3000
API_URL=http://localhost:3000
# Better Auth
BETTER_AUTH_SECRET=your-super-secret-key-change-in-production
# Mobile App
EXPO_PUBLIC_API_URL=http://localhost:3000

52
apps/news/.gitignore vendored
View file

@ -1,52 +0,0 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
.next/
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage/
# Expo
.expo/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Native builds
apps/mobile/ios/
apps/mobile/android/
# Database
packages/database/drizzle/
# Misc
*.tgz
.cache/

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]
}

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://news.manacore.app',
integrations: [
tailwind(),
sitemap()
]
});

View file

@ -1,26 +0,0 @@
{
"name": "@news/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
}
}

View file

@ -1,94 +0,0 @@
---
const footerLinks = {
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ href: '/imprint', label: 'Impressum' },
],
};
const currentYear = new Date().getFullYear();
---
<footer class="bg-background-card border-t border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="/" class="flex items-center gap-2 mb-4">
<svg
class="w-8 h-8 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"
></path>
</svg>
<span class="font-bold text-xl text-text-primary">News Hub</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
KI-kuratierte Nachrichten, personalisiert für dich. Feed, Zusammenfassungen und
ausführliche Analysen - alles in einer eleganten App.
</p>
</div>
<!-- Product Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
<ul class="space-y-2">
{
footerLinks.product.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Legal Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
<ul class="space-y-2">
{
footerLinks.legal.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom -->
<div
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
>
<p class="text-text-muted text-sm">
&copy; {currentYear} News Hub. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
</div>
</div>
</footer>

View file

@ -1,101 +0,0 @@
---
const navLinks = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: "So funktioniert's" },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
];
---
<nav
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<svg
class="w-8 h-8 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"
></path>
</svg>
<span class="font-bold text-xl text-text-primary">News Hub</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))
}
</div>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<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>
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{
navLinks.map((link) => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))
}
</div>
</div>
</nav>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -1,48 +0,0 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const { title, description = 'News Hub - KI-kuratierte Nachrichten, personalisiert für dich' } =
Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- 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"
/>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -1,278 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
// Shared components
import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro';
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';
// Feature data
const features = [
{
icon: '📰',
title: 'Feed',
description:
'Schnelle News-Updates im Infinite-Scroll Format. Bleib auf dem Laufenden mit kurzen, prägnanten Nachrichten.',
},
{
icon: '📝',
title: 'Zusammenfassungen',
description:
'4 tägliche Zusammenfassungen (Morgen, Mittag, Abend, Nacht) - perfekt für einen schnellen Überblick.',
},
{
icon: '📖',
title: 'In-Depth Artikel',
description:
'Ausführliche Analysen (5-15 Min. Lesezeit) für tiefes Verständnis komplexer Themen.',
},
{
icon: '🔖',
title: 'Artikel speichern',
description:
'Speichere interessante Artikel mit der Browser-Extension und lese sie später in der App.',
},
{
icon: '🎯',
title: 'Personalisierte Kategorien',
description:
'Wähle deine Interessengebiete und erhalte maßgeschneiderte Nachrichten-Empfehlungen.',
},
{
icon: '🔄',
title: 'Cross-Platform Sync',
description:
'Deine Artikel, Lesefortschritt und Einstellungen werden auf allen Geräten synchronisiert.',
},
];
// Steps data
const steps = [
{
number: '1',
title: 'App herunterladen',
description: 'Lade News Hub kostenlos im App Store oder Google Play Store herunter.',
image: '/screenshots/download.png',
},
{
number: '2',
title: 'Kategorien wählen',
description: 'Wähle deine Interessengebiete für personalisierte Nachrichten.',
image: '/screenshots/categories.png',
},
{
number: '3',
title: 'Informiert bleiben',
description:
'Erhalte täglich kuratierte News im Feed, Zusammenfassungen oder In-Depth Artikeln.',
image: '/screenshots/feed.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: 'Feed mit allen News', included: true },
{ text: '2 Zusammenfassungen/Tag', included: true },
{ text: '5 Artikel speichern', included: true },
{ text: 'Basis-Kategorien', included: true },
{ text: 'In-Depth Artikel', included: false },
{ text: 'Browser Extension', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#download',
},
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Für Nachrichten-Enthusiasten',
features: [
{ text: 'Unbegrenzter Feed', included: true },
{ text: 'Alle 4 Zusammenfassungen', included: true },
{ text: 'In-Depth Artikel', included: true },
{ text: 'Unbegrenzt speichern', included: true },
{ text: 'Browser Extension', included: true },
{ text: 'Alle Kategorien', included: true },
],
cta: {
text: 'Pro werden',
href: '#download',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Team',
price: '12,99',
period: '/Monat',
description: 'Für Teams und Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'Geteilte Sammlungen', included: true },
{ text: 'Custom Kategorien', included: true },
{ text: 'API-Zugang', included: true },
{ text: 'Prioritäts-Support', included: true },
],
cta: {
text: 'Team starten',
href: '#download',
},
},
];
// FAQ data
const faqs = [
{
question: 'Was macht News Hub anders als andere News-Apps?',
answer:
'News Hub nutzt KI um Nachrichten zu kuratieren und in drei Formaten anzubieten: schnelle Feed-Updates, tägliche Zusammenfassungen und ausführliche Analysen. Du entscheidest, wie tief du in ein Thema eintauchen möchtest.',
},
{
question: 'Wie funktionieren die täglichen Zusammenfassungen?',
answer:
'Du erhältst 4 Zusammenfassungen pro Tag: Morgen (6 Uhr), Mittag (12 Uhr), Abend (18 Uhr) und Nacht (22 Uhr). Jede Zusammenfassung fasst die wichtigsten Ereignisse der letzten Stunden zusammen.',
},
{
question: 'Kann ich Artikel von anderen Webseiten speichern?',
answer:
'Ja! Mit der Browser Extension (Pro) kannst du jeden Artikel von jeder Webseite mit einem Klick speichern. Der Artikel wird automatisch für die App optimiert und ist offline verfügbar.',
},
{
question: 'Sind meine Daten sicher?',
answer:
'Absolut. Wir speichern nur das Nötigste und verkaufen keine Nutzerdaten. Die App ist vollständig DSGVO-konform und du kannst deine Daten jederzeit exportieren oder löschen.',
},
{
question: 'Funktioniert News Hub offline?',
answer:
'Ja! Bereits geladene Artikel und Zusammenfassungen sind offline verfügbar. Neue Inhalte werden synchronisiert, sobald du wieder online bist.',
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer:
'Ja, du kannst dein Pro- oder Team-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.',
},
];
---
<Layout title="News Hub - KI-kuratierte Nachrichten">
<Navigation />
<main class="pt-16">
<HeroSection
title="Nachrichten, die zu dir passen"
subtitle="News Hub kuratiert Nachrichten mit KI und liefert sie in drei Formaten: schnelle Updates, tägliche Zusammenfassungen und tiefgehende Analysen. Du entscheidest, wie informiert du sein willst."
variant="default"
primaryCta={{
text: 'Jetzt kostenlos starten',
href: '#download',
}}
secondaryCta={{
text: 'Features entdecken',
href: '#features',
variant: 'secondary',
}}
trustBadges={[
{ icon: '✓', text: 'Kostenlos testen' },
{ icon: '🔒', text: 'DSGVO-konform' },
{ icon: '📱', text: 'iOS, Android & Web' },
]}
/>
<FeatureSection
id="features"
title="Drei Wege, informiert zu bleiben"
subtitle="Wähle das Format, das zu deinem Alltag passt - von schnellen Updates bis zu ausführlichen Analysen."
features={features}
columns={3}
variant="cards"
class="bg-[var(--color-background-card)]"
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten loslegen"
subtitle="So einfach startest du mit News Hub"
steps={steps}
showImages={false}
alternateLayout={true}
/>
<PricingSection
id="pricing"
title="Wähle deinen Plan"
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
plans={pricingPlans}
class="bg-[var(--color-background-card)]"
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über News Hub wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Bereit für bessere Nachrichten?"
subtitle="Lade News Hub jetzt herunter und erlebe Nachrichten, die wirklich zu dir passen. Kostenlos und ohne Kreditkarte."
primaryCta={{ text: 'App herunterladen', href: '#' }}
variant="highlighted"
>
<!-- App Store Buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
</a>
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
</a>
</div>
<!-- Trust Indicators -->
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
</div>
</div>
</CTASection>
</main>
<Footer />
</Layout>

View file

@ -1,103 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* News Hub Theme CSS Variables - Purple/Indigo */
:root {
/* Primary colors - News Hub Purple */
--color-primary: #6366f1;
--color-primary-hover: #818cf8;
--color-primary-glow: rgba(99, 102, 241, 0.3);
/* Text colors */
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #6b7280;
/* Background colors */
--color-background-page: #0f0f1a;
--color-background-card: #1a1a2e;
--color-background-card-hover: #252542;
/* Border colors */
--color-border: #252542;
--color-border-hover: #3a3a5c;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-background-card);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-hover);
}
/* Selection */
::selection {
background-color: var(--color-primary);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
/* Button styles */
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
@apply hover:border-border-hover hover:bg-background-card;
}

View file

@ -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')
]
};

View file

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

View file

@ -1,2 +0,0 @@
# News Hub Web App Configuration
PUBLIC_NEWS_API_URL=http://localhost:3000

View file

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

View file

@ -1,8 +0,0 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";

View file

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

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

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

View file

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

View file

@ -1,136 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
const navItems = [
{ href: '/feed', label: 'Feed', icon: 'feed' },
{ href: '/summaries', label: 'Zusammenfassungen', icon: 'summaries' },
{ href: '/in-depth', label: 'In-Depth', icon: 'indepth' },
{ href: '/saved', label: 'Gespeichert', icon: 'saved' },
];
async function handleLogout() {
await authStore.logout();
goto('/auth/login');
}
</script>
<div class="min-h-screen flex">
<!-- Sidebar -->
<aside class="w-64 bg-background-card border-r border-border flex flex-col">
<!-- Logo -->
<div class="p-4 border-b border-border">
<a href="/feed" class="flex items-center gap-2">
<svg
class="w-8 h-8 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>
<span class="font-bold text-lg">News Hub</span>
</a>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors {$page.url.pathname.startsWith(
item.href
)
? 'bg-primary/10 text-primary'
: 'text-text-secondary hover:bg-background-card-hover hover:text-text-primary'}"
>
{#if item.icon === 'feed'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
{:else if item.icon === 'summaries'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
{:else if item.icon === 'indepth'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
{:else if item.icon === 'saved'}
<svg class="w-5 h-5" 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>
{/if}
<span>{item.label}</span>
</a>
{/each}
</nav>
<!-- User Menu -->
<div class="p-4 border-t border-border">
<a
href="/profile"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-text-secondary hover:bg-background-card-hover hover:text-text-primary transition-colors"
>
<div class="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center">
<span class="text-primary text-sm font-medium">
{authStore.user?.name?.[0]?.toUpperCase() ||
authStore.user?.email?.[0]?.toUpperCase() ||
'?'}
</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{authStore.user?.name || 'User'}</p>
<p class="text-xs text-text-muted truncate">{authStore.user?.email}</p>
</div>
</a>
<button
onclick={handleLogout}
class="w-full mt-2 flex items-center gap-3 px-3 py-2 rounded-lg text-text-secondary hover:bg-red-500/10 hover:text-red-400 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span>Abmelden</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +0,0 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -1,14 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -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',
],
},
});

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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