mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
|
|
@ -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
52
apps/news/.gitignore
vendored
|
|
@ -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
|
|
@ -1,187 +0,0 @@
|
|||
# News Hub
|
||||
|
||||
A unified news reading platform combining AI-curated news with personal article saving capabilities.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
news/
|
||||
├── apps/
|
||||
│ ├── mobile/ # React Native/Expo App
|
||||
│ └── api/ # NestJS Backend
|
||||
├── packages/
|
||||
│ ├── database/ # Drizzle ORM Schema
|
||||
│ ├── shared/ # Shared utilities
|
||||
│ └── browser-extension/ # Chrome Extension
|
||||
└── docker/ # PostgreSQL Docker setup
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
| ------------ | --------------------------- |
|
||||
| **Database** | PostgreSQL 16 (Docker) |
|
||||
| **ORM** | Drizzle |
|
||||
| **Backend** | NestJS + Fastify |
|
||||
| **Auth** | Custom JWT Auth |
|
||||
| **Mobile** | React Native / Expo |
|
||||
| **State** | Zustand |
|
||||
| **Styling** | NativeWind (Tailwind) |
|
||||
| **Monorepo** | pnpm workspaces + Turborepo |
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Docker Desktop
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Start PostgreSQL
|
||||
pnpm docker:up
|
||||
|
||||
# 3. Push database schema
|
||||
pnpm db:push
|
||||
|
||||
# 4. Start API server
|
||||
pnpm dev:api
|
||||
|
||||
# 5. Start mobile app (in another terminal)
|
||||
pnpm dev:mobile
|
||||
```
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Start all services
|
||||
pnpm dev:api # Start API only
|
||||
pnpm dev:mobile # Start mobile app only
|
||||
|
||||
# Database
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:generate # Generate migrations
|
||||
pnpm db:migrate # Run migrations
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
|
||||
# Docker
|
||||
pnpm docker:up # Start PostgreSQL
|
||||
pnpm docker:down # Stop PostgreSQL
|
||||
pnpm docker:logs # View logs
|
||||
|
||||
# Build
|
||||
pnpm build # Build all packages
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql://news:news_dev_password@localhost:5432/news_hub
|
||||
|
||||
# API
|
||||
API_PORT=3000
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# Better Auth Secret
|
||||
BETTER_AUTH_SECRET=your-secret-key
|
||||
|
||||
# Mobile App
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### News Feed (AI-Generated)
|
||||
|
||||
- **Feed**: Quick news updates with infinite scroll
|
||||
- **Summaries**: 4 daily summaries (morning, noon, evening, night)
|
||||
- **In-Depth**: Detailed analysis articles
|
||||
|
||||
### Personal Library (Read Later)
|
||||
|
||||
- Save articles from any URL
|
||||
- Browser extension for one-click saving
|
||||
- Content extraction with Readability
|
||||
- Archive and organize articles
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
|
||||
- `POST /auth/signup` - Create account
|
||||
- `POST /auth/signin` - Sign in
|
||||
- `POST /auth/signout` - Sign out
|
||||
- `GET /auth/session` - Get current session
|
||||
|
||||
### Articles
|
||||
|
||||
- `GET /articles` - Get AI articles (public)
|
||||
- `GET /articles/:id` - Get single article
|
||||
- `GET /articles/saved/list` - Get saved articles (auth required)
|
||||
- `POST /articles/:id/archive` - Archive article
|
||||
- `DELETE /articles/:id` - Delete article
|
||||
|
||||
### Content Extraction
|
||||
|
||||
- `POST /extract/save` - Save article from URL (auth required)
|
||||
- `POST /extract/preview` - Preview URL extraction (public)
|
||||
|
||||
### Categories
|
||||
|
||||
- `GET /categories` - Get all categories
|
||||
|
||||
### Users
|
||||
|
||||
- `GET /users/me` - Get current user
|
||||
- `PATCH /users/me` - Update profile
|
||||
- `PATCH /users/me/onboarding` - Complete onboarding
|
||||
|
||||
## Browser Extension
|
||||
|
||||
The browser extension is located in `packages/browser-extension/`.
|
||||
|
||||
### Installation (Development)
|
||||
|
||||
1. Go to `chrome://extensions/`
|
||||
2. Enable "Developer mode"
|
||||
3. Click "Load unpacked"
|
||||
4. Select the `packages/browser-extension` folder
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
- `users` - User accounts and preferences
|
||||
- `articles` - All articles (AI-generated and user-saved)
|
||||
- `categories` - Article categories
|
||||
- `user_article_interactions` - Reading progress, ratings, bookmarks
|
||||
- `sessions` - Auth sessions
|
||||
- `accounts` - Auth providers
|
||||
- `verifications` - Email verification tokens
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a new API endpoint
|
||||
|
||||
1. Create service in `apps/api/src/{module}/{module}.service.ts`
|
||||
2. Create controller in `apps/api/src/{module}/{module}.controller.ts`
|
||||
3. Add module to `app.module.ts`
|
||||
|
||||
### Adding a new database table
|
||||
|
||||
1. Create schema in `packages/database/src/schema/{table}.ts`
|
||||
2. Export from `packages/database/src/schema/index.ts`
|
||||
3. Run `pnpm db:push` to update database
|
||||
|
||||
## License
|
||||
|
||||
Private
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{
|
||||
"name": "@news/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-fastify": "^10.4.0",
|
||||
"@manacore/news-database": "workspace:*",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"postgres": "^3.4.5",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.2.0",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ArticlesModule } from './articles/articles.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ContentExtractionModule } from './content-extraction/content-extraction.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '../../.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
AuthModule,
|
||||
ArticlesModule,
|
||||
CategoriesModule,
|
||||
UsersModule,
|
||||
ContentExtractionModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
|
||||
@Controller('articles')
|
||||
export class ArticlesController {
|
||||
constructor(private articlesService: ArticlesService) {}
|
||||
|
||||
// Public: Get AI-generated articles
|
||||
@Get()
|
||||
async getArticles(
|
||||
@Query('type') type?: 'feed' | 'summary' | 'in_depth',
|
||||
@Query('categoryId') categoryId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string
|
||||
) {
|
||||
return this.articlesService.getAIArticles({
|
||||
type,
|
||||
categoryId,
|
||||
limit: limit ? parseInt(limit, 10) : 20,
|
||||
offset: offset ? parseInt(offset, 10) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Public: Get single article
|
||||
@Get(':id')
|
||||
async getArticle(@Param('id') id: string) {
|
||||
const article = await this.articlesService.getArticleById(id);
|
||||
if (!article) {
|
||||
return { error: 'Article not found' };
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
// Protected: Get user's saved articles
|
||||
@Get('saved/list')
|
||||
@UseGuards(AuthGuard)
|
||||
async getSavedArticles(
|
||||
@CurrentUser() user: User,
|
||||
@Query('includeArchived') includeArchived?: string
|
||||
) {
|
||||
return this.articlesService.getSavedArticles(user.id, includeArchived === 'true');
|
||||
}
|
||||
|
||||
// Protected: Archive article
|
||||
@Post(':id/archive')
|
||||
@UseGuards(AuthGuard)
|
||||
async archiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.archiveArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Protected: Unarchive article
|
||||
@Post(':id/unarchive')
|
||||
@UseGuards(AuthGuard)
|
||||
async unarchiveArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.unarchiveArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Protected: Delete article
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard)
|
||||
async deleteArticle(@Param('id') id: string, @CurrentUser() user: User) {
|
||||
await this.articlesService.deleteArticle(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ArticlesController } from './articles.controller';
|
||||
import { ArticlesService } from './articles.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ArticlesController],
|
||||
providers: [ArticlesService],
|
||||
exports: [ArticlesService],
|
||||
})
|
||||
export class ArticlesModule {}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, articles, Article, eq, and, desc } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class ArticlesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
// Get AI-generated articles (feed, summary, in_depth)
|
||||
async getAIArticles(options: {
|
||||
type?: 'feed' | 'summary' | 'in_depth';
|
||||
categoryId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
const { type, categoryId, limit = 20, offset = 0 } = options;
|
||||
|
||||
const conditions = [eq(articles.sourceOrigin, 'ai')];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(articles.type, type));
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
conditions.push(eq(articles.categoryId, categoryId));
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(articles.publishedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
// Get user-saved articles
|
||||
async getSavedArticles(userId: string, includeArchived = false): Promise<Article[]> {
|
||||
const conditions = [eq(articles.sourceOrigin, 'user_saved'), eq(articles.userId, userId)];
|
||||
|
||||
if (!includeArchived) {
|
||||
conditions.push(eq(articles.isArchived, false));
|
||||
}
|
||||
|
||||
return this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(articles.createdAt));
|
||||
}
|
||||
|
||||
// Get single article by ID
|
||||
async getArticleById(articleId: string): Promise<Article | null> {
|
||||
const [article] = await this.database
|
||||
.select()
|
||||
.from(articles)
|
||||
.where(eq(articles.id, articleId))
|
||||
.limit(1);
|
||||
|
||||
return article || null;
|
||||
}
|
||||
|
||||
// Create a saved article
|
||||
async createSavedArticle(data: {
|
||||
userId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
parsedContent: string;
|
||||
originalUrl: string;
|
||||
author?: string;
|
||||
imageUrl?: string;
|
||||
}): Promise<Article> {
|
||||
const wordCount = data.content.split(/\s+/).length;
|
||||
const readingTimeMinutes = Math.ceil(wordCount / 200);
|
||||
|
||||
const [article] = await this.database
|
||||
.insert(articles)
|
||||
.values({
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
userId: data.userId,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
parsedContent: data.parsedContent,
|
||||
originalUrl: data.originalUrl,
|
||||
author: data.author,
|
||||
imageUrl: data.imageUrl,
|
||||
wordCount,
|
||||
readingTimeMinutes,
|
||||
isArchived: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
// Archive an article
|
||||
async archiveArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.update(articles)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Unarchive an article
|
||||
async unarchiveArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.update(articles)
|
||||
.set({ isArchived: false, updatedAt: new Date() })
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an article
|
||||
async deleteArticle(articleId: string, userId: string): Promise<void> {
|
||||
const result = await this.database
|
||||
.delete(articles)
|
||||
.where(and(eq(articles.id, articleId), eq(articles.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { Controller, Post, Get, Body, Headers, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
class SignUpDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
class SignInDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('signup')
|
||||
async signUp(@Body() body: SignUpDto) {
|
||||
const result = await this.authService.signUp(body.email, body.password, body.name);
|
||||
return {
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
},
|
||||
token: result.token,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signin')
|
||||
async signIn(@Body() body: SignInDto) {
|
||||
const result = await this.authService.signIn(body.email, body.password);
|
||||
return {
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
},
|
||||
token: result.token,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('signout')
|
||||
async signOut(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
await this.authService.signOut(token);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('session')
|
||||
async getSession(@Headers('authorization') authHeader: string) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const session = await this.authService.getSession(token);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { Injectable, Inject, UnauthorizedException, ConflictException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, users, sessions, accounts, eq, and, User } from '@manacore/news-database';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private database: Database,
|
||||
private configService: ConfigService
|
||||
) {}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, storedHash: string): boolean {
|
||||
const [salt, hash] = storedHash.split(':');
|
||||
const verifyHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||
return hash === verifyHash;
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
async signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
name?: string
|
||||
): Promise<{ user: User; token: string }> {
|
||||
// Check if user exists
|
||||
const existingUser = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
throw new ConflictException('User already exists');
|
||||
}
|
||||
|
||||
// Create user
|
||||
const [user] = await this.database
|
||||
.insert(users)
|
||||
.values({
|
||||
email: email.toLowerCase(),
|
||||
name: name || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create account with password
|
||||
await this.database.insert(accounts).values({
|
||||
userId: user.id,
|
||||
providerId: 'credential',
|
||||
accountId: email.toLowerCase(),
|
||||
password: this.hashPassword(password),
|
||||
});
|
||||
|
||||
// Create session
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await this.database.insert(sessions).values({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<{ user: User; token: string }> {
|
||||
// Find user
|
||||
const [user] = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Find account
|
||||
const [account] = await this.database
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, user.id), eq(accounts.providerId, 'credential')))
|
||||
.limit(1);
|
||||
|
||||
if (!account || !account.password) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!this.verifyPassword(password, account.password)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Create session
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await this.database.insert(sessions).values({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async signOut(token: string): Promise<void> {
|
||||
await this.database.delete(sessions).where(eq(sessions.token, token));
|
||||
}
|
||||
|
||||
async validateSession(token: string): Promise<{ user: User; session: any } | null> {
|
||||
const [session] = await this.database
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (!session || new Date(session.expiresAt) < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = await this.database
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
async getSession(token: string): Promise<{ user: User } | null> {
|
||||
const result = await this.validateSession(token);
|
||||
if (!result) return null;
|
||||
return { user: result.user };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
async getAllCategories() {
|
||||
return this.categoriesService.getAllCategories();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, categories, Category, asc } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
async getAllCategories(): Promise<Category[]> {
|
||||
return this.database.select().from(categories).orderBy(asc(categories.priority));
|
||||
}
|
||||
|
||||
async createCategory(data: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
priority?: number;
|
||||
}): Promise<Category> {
|
||||
const [category] = await this.database.insert(categories).values(data).returning();
|
||||
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const session = await this.authService.validateSession(token);
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
request.user = session.user;
|
||||
request.session = session;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ContentExtractionService } from './content-extraction.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
import { IsUrl } from 'class-validator';
|
||||
|
||||
class ExtractUrlDto {
|
||||
@IsUrl()
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Controller('extract')
|
||||
export class ContentExtractionController {
|
||||
constructor(private contentExtractionService: ContentExtractionService) {}
|
||||
|
||||
// Protected: Save article from URL
|
||||
@Post('save')
|
||||
@UseGuards(AuthGuard)
|
||||
async saveFromUrl(@Body() body: ExtractUrlDto, @CurrentUser() user: User) {
|
||||
const article = await this.contentExtractionService.saveArticleFromUrl(user.id, body.url);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
article: {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
createdAt: article.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Public: Preview URL extraction (without saving)
|
||||
@Post('preview')
|
||||
async previewUrl(@Body() body: ExtractUrlDto) {
|
||||
const extracted = await this.contentExtractionService.extractFromUrl(body.url);
|
||||
|
||||
return {
|
||||
title: extracted.title,
|
||||
excerpt: extracted.excerpt,
|
||||
byline: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
contentLength: extracted.content.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContentExtractionController } from './content-extraction.controller';
|
||||
import { ContentExtractionService } from './content-extraction.service';
|
||||
import { ArticlesModule } from '../articles/articles.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ArticlesModule, AuthModule],
|
||||
controllers: [ContentExtractionController],
|
||||
providers: [ContentExtractionService],
|
||||
exports: [ContentExtractionService],
|
||||
})
|
||||
export class ContentExtractionModule {}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { ArticlesService } from '../articles/articles.service';
|
||||
|
||||
export interface ExtractedContent {
|
||||
title: string;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
excerpt?: string;
|
||||
byline?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentExtractionService {
|
||||
constructor(private articlesService: ArticlesService) {}
|
||||
|
||||
async extractFromUrl(url: string): Promise<ExtractedContent> {
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid URL');
|
||||
}
|
||||
|
||||
// Fetch the page
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(
|
||||
`Failed to fetch URL: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Parse with JSDOM
|
||||
const dom = new JSDOM(html, { url });
|
||||
const reader = new Readability(dom.window.document);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new BadRequestException('Could not extract article content from this page');
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title || 'Untitled',
|
||||
content: article.textContent || '',
|
||||
htmlContent: article.content || '',
|
||||
excerpt: article.excerpt,
|
||||
byline: article.byline,
|
||||
siteName: article.siteName,
|
||||
};
|
||||
}
|
||||
|
||||
async saveArticleFromUrl(userId: string, url: string) {
|
||||
const extracted = await this.extractFromUrl(url);
|
||||
|
||||
return this.articlesService.createSavedArticle({
|
||||
userId,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
parsedContent: extracted.htmlContent,
|
||||
originalUrl: url,
|
||||
author: extracted.byline,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createDb } from '@manacore/news-database';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const databaseUrl =
|
||||
configService.get<string>('DATABASE_URL') ||
|
||||
'postgresql://news:news_dev_password@localhost:5434/news_hub';
|
||||
|
||||
console.log('Connecting to database:', databaseUrl.replace(/:[^:@]+@/, ':****@'));
|
||||
|
||||
return createDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({ logger: true })
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:8081', // Expo web
|
||||
'http://localhost:19006', // Expo web alt
|
||||
'http://localhost:3000', // API itself (for testing)
|
||||
/^exp:\/\/.*/, // Expo Go
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.API_PORT || 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthGuard } from '../common/guards/auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { User } from '@manacore/news-database';
|
||||
import { IsOptional, IsString, IsArray, IsEnum, IsBoolean } from 'class-validator';
|
||||
|
||||
class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
preferredCategories?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
blockedSources?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['slow', 'normal', 'fast'])
|
||||
readingSpeed?: 'slow' | 'normal' | 'fast';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notificationSettings?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async getCurrentUser(@CurrentUser() user: User) {
|
||||
return this.usersService.getUserById(user.id);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async updateCurrentUser(@CurrentUser() user: User, @Body() body: UpdateUserDto) {
|
||||
return this.usersService.updateUser(user.id, body);
|
||||
}
|
||||
|
||||
@Patch('me/onboarding')
|
||||
@UseGuards(AuthGuard)
|
||||
async completeOnboarding(@CurrentUser() user: User) {
|
||||
await this.usersService.completeOnboarding(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import { Database, users, User, eq } from '@manacore/news-database';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private database: Database) {}
|
||||
|
||||
async getUserById(userId: string): Promise<User | null> {
|
||||
const [user] = await this.database.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
userId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
preferredCategories?: string[];
|
||||
blockedSources?: string[];
|
||||
readingSpeed?: 'slow' | 'normal' | 'fast';
|
||||
notificationSettings?: string;
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
): Promise<User> {
|
||||
const [user] = await this.database
|
||||
.update(users)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async completeOnboarding(userId: string): Promise<void> {
|
||||
await this.database
|
||||
.update(users)
|
||||
.set({
|
||||
onboardingCompleted: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1,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()
|
||||
]
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
© {currentYear} News Hub. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// News Hub Purple/Indigo Theme
|
||||
primary: {
|
||||
DEFAULT: '#6366f1',
|
||||
hover: '#818cf8',
|
||||
glow: 'rgba(99, 102, 241, 0.3)'
|
||||
},
|
||||
background: {
|
||||
page: '#0f0f1a',
|
||||
card: '#1a1a2e',
|
||||
'card-hover': '#252542'
|
||||
},
|
||||
text: {
|
||||
primary: '#f9fafb',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#6b7280'
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#252542',
|
||||
hover: '#3a3a5c'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
]
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# News Hub Web App Configuration
|
||||
PUBLIC_NEWS_API_URL=http://localhost:3000
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
33
apps/news/apps/web/src/app.d.ts
vendored
33
apps/news/apps/web/src/app.d.ts
vendored
|
|
@ -1,33 +0,0 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
|
||||
interface NewsUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NewsSession {
|
||||
token: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
session: NewsSession | null;
|
||||
user: NewsUser | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: NewsSession | null;
|
||||
user: NewsUser | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -1,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>
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_URL = env.PUBLIC_NEWS_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
token?: string
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: errorText || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
signup: (email: string, password: string, name?: string) =>
|
||||
apiRequest<{ token: string; user: App.Locals['user'] }>('/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
}),
|
||||
|
||||
logout: (token: string) => apiRequest('/auth/logout', { method: 'POST' }, token),
|
||||
|
||||
me: (token: string) => apiRequest<App.Locals['user']>('/auth/me', {}, token),
|
||||
};
|
||||
|
||||
// Articles endpoints
|
||||
export const articlesApi = {
|
||||
getArticles: (
|
||||
params?: { type?: string; categoryId?: string; limit?: number; offset?: number },
|
||||
token?: string
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.categoryId) searchParams.set('categoryId', params.categoryId);
|
||||
if (params?.limit) searchParams.set('limit', params.limit.toString());
|
||||
if (params?.offset) searchParams.set('offset', params.offset.toString());
|
||||
const query = searchParams.toString();
|
||||
return apiRequest<any[]>(`/articles${query ? `?${query}` : ''}`, {}, token);
|
||||
},
|
||||
|
||||
getArticle: (id: string, token?: string) => apiRequest<any>(`/articles/${id}`, {}, token),
|
||||
|
||||
getSavedArticles: (token: string) => apiRequest<any[]>('/articles/saved/list', {}, token),
|
||||
|
||||
archiveArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}/archive`, { method: 'POST' }, token),
|
||||
|
||||
unarchiveArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}/unarchive`, { method: 'POST' }, token),
|
||||
|
||||
deleteArticle: (id: string, token: string) =>
|
||||
apiRequest(`/articles/${id}`, { method: 'DELETE' }, token),
|
||||
};
|
||||
|
||||
// Categories endpoints
|
||||
export const categoriesApi = {
|
||||
getCategories: (token?: string) => apiRequest<any[]>('/categories', {}, token),
|
||||
};
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { authApi } from '$lib/services/api';
|
||||
|
||||
class AuthStore {
|
||||
user = $state<App.Locals['user']>(null);
|
||||
session = $state<App.Locals['session']>(null);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
get isAuthenticated() {
|
||||
return !!this.session && !!this.user;
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { data, error } = await authApi.login(email, password);
|
||||
|
||||
if (error) {
|
||||
this.error = error;
|
||||
this.loading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
|
||||
this.user = data.user;
|
||||
// Store token in cookie/localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `news_session=${data.token}; path=/; max-age=604800`; // 7 days
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async signup(email: string, password: string, name?: string) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { data, error } = await authApi.signup(email, password, name);
|
||||
|
||||
if (error) {
|
||||
this.error = error;
|
||||
this.loading = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
this.session = { token: data.token, userId: data.user?.id ?? '', expiresAt: '' };
|
||||
this.user = data.user;
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = `news_session=${data.token}; path=/; max-age=604800`;
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (this.session?.token) {
|
||||
await authApi.logout(this.session.token);
|
||||
}
|
||||
|
||||
this.session = null;
|
||||
this.user = null;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.cookie = 'news_session=; path=/; max-age=0';
|
||||
}
|
||||
}
|
||||
|
||||
setSession(session: App.Locals['session'], user: App.Locals['user']) {
|
||||
this.session = session;
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
|
|
@ -1,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>
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'feed', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feed - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Feed</h1>
|
||||
<p class="text-text-secondary mt-1">Aktuelle Nachrichten im Überblick</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<svg
|
||||
class="w-16 h-16 text-text-muted mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-text-secondary">Noch keine Artikel vorhanden</p>
|
||||
<p class="text-text-muted text-sm mt-1">
|
||||
Artikel werden automatisch generiert und erscheinen hier
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-2">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-text-muted">
|
||||
{#if article.category}
|
||||
<span class="px-2 py-1 bg-primary/10 text-primary rounded">
|
||||
{article.category.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if article.createdAt}
|
||||
<span>{new Date(article.createdAt).toLocaleDateString('de-DE')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'in_depth', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>In-Depth - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">In-Depth Artikel</h1>
|
||||
<p class="text-text-secondary mt-1">Ausführliche Analysen zu wichtigen Themen</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-text-secondary">Noch keine In-Depth Artikel vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-6 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-bold text-xl hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-3">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-4 text-sm text-text-muted">
|
||||
<span>5-15 Min. Lesezeit</span>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.session?.token) {
|
||||
error = 'Nicht angemeldet';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: apiError } = await articlesApi.getSavedArticles(authStore.session.token);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gespeicherte Artikel - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Gespeicherte Artikel</h1>
|
||||
<p class="text-text-secondary mt-1">Deine gespeicherten Artikel zum späteren Lesen</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<svg
|
||||
class="w-16 h-16 text-text-muted mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-text-secondary">Noch keine Artikel gespeichert</p>
|
||||
<p class="text-text-muted text-sm mt-1">
|
||||
Speichere Artikel mit der Browser-Extension oder aus dem Feed
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-2">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { articlesApi } from '$lib/services/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let articles = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error: apiError } = await articlesApi.getArticles(
|
||||
{ type: 'summary', limit: 20 },
|
||||
authStore.session?.token
|
||||
);
|
||||
|
||||
if (apiError) {
|
||||
error = apiError;
|
||||
} else if (data) {
|
||||
articles = data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Zusammenfassungen - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Tägliche Zusammenfassungen</h1>
|
||||
<p class="text-text-secondary mt-1">
|
||||
Die wichtigsten Nachrichten des Tages kompakt zusammengefasst
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-text-secondary">Noch keine Zusammenfassungen vorhanden</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each articles as article}
|
||||
<article
|
||||
class="p-4 bg-background-card border border-border rounded-lg hover:border-border-hover transition-colors"
|
||||
>
|
||||
<a href="/article/{article.id}" class="block">
|
||||
<h2 class="font-semibold text-lg hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h2>
|
||||
{#if article.summary}
|
||||
<p class="text-text-secondary mt-2 line-clamp-3">
|
||||
{article.summary}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
// Initialize auth store with server data
|
||||
$effect(() => {
|
||||
if (data.session && data.user) {
|
||||
authStore.setSession(data.session, data.user);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background-page text-text-primary">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/feed');
|
||||
} else {
|
||||
goto('/auth/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="animate-pulse">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await authStore.login(email, password);
|
||||
if (success) {
|
||||
goto('/feed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary mx-auto mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold">News Hub</h1>
|
||||
<p class="text-text-secondary mt-2">Anmelden</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if authStore.error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{authStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authStore.loading}
|
||||
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{authStore.loading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-text-secondary mt-6">
|
||||
Noch kein Konto?
|
||||
<a href="/auth/register" class="text-primary hover:underline">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await authStore.signup(email, password, name || undefined);
|
||||
if (success) {
|
||||
goto('/feed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren - News Hub</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<svg
|
||||
class="w-12 h-12 text-primary mx-auto mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold">News Hub</h1>
|
||||
<p class="text-text-secondary mt-2">Konto erstellen</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if authStore.error}
|
||||
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
||||
{authStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium mb-2">Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-4 py-3 bg-background-card border border-border rounded-lg focus:outline-none focus:border-primary"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authStore.loading}
|
||||
class="w-full py-3 bg-primary hover:bg-primary-hover text-white font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{authStore.loading ? 'Wird registriert...' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-text-secondary mt-6">
|
||||
Bereits ein Konto?
|
||||
<a href="/auth/login" class="text-primary hover:underline">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'marked',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: news-hub-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: news
|
||||
POSTGRES_PASSWORD: news_dev_password
|
||||
POSTGRES_DB: news_hub
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U news -d news_hub"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: news-hub-pgadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@local.dev
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- Grants
|
||||
GRANT ALL PRIVILEGES ON DATABASE news_hub TO news;
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
# Kokon Browser Extension
|
||||
|
||||
Eine Chrome/Firefox Browser-Erweiterung für die Kokon Read-Later App.
|
||||
|
||||
## Features
|
||||
|
||||
- **Ein-Klick Speichern**: Speichere jeden Artikel mit einem Klick
|
||||
- **Automatische Content-Extraktion**: Nutzt die gleiche Mozilla Readability Engine wie die App
|
||||
- **Session-Synchronisation**: Automatische Anmeldeerkennung mit der Web-App
|
||||
- **Elegantes Design**: Moderne, responsive Benutzeroberfläche
|
||||
- **Fehlerbehandlung**: Intelligente Fehlerbehandlung und Benutzerführung
|
||||
|
||||
## Installation (Development)
|
||||
|
||||
### Chrome/Edge
|
||||
|
||||
1. Öffne `chrome://extensions/`
|
||||
2. Aktiviere "Entwicklermodus" (Developer mode)
|
||||
3. Klicke "Ungepackte Erweiterung laden" (Load unpacked)
|
||||
4. Wähle den `browser-extension` Ordner aus
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Öffne `about:debugging`
|
||||
2. Klicke "Dieses Firefox" (This Firefox)
|
||||
3. Klicke "Temporäres Add-on laden" (Load Temporary Add-on)
|
||||
4. Wähle die `manifest.json` Datei aus
|
||||
|
||||
## Verwendung
|
||||
|
||||
1. **Erste Einrichtung**:
|
||||
- Installiere die Erweiterung
|
||||
- Logge dich in der Kokon Web-App ein (wird automatisch geöffnet)
|
||||
|
||||
2. **Artikel speichern**:
|
||||
- Navigiere zu einem beliebigen Artikel im Web
|
||||
- Klicke auf das Kokon-Symbol in der Browser-Toolbar
|
||||
- Klicke "Save Article"
|
||||
- Der Artikel wird automatisch verarbeitet und in deiner Kokon-Liste gespeichert
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Architektur
|
||||
|
||||
- **Manifest V3**: Moderne Chrome Extension API
|
||||
- **Service Worker**: Background-Verarbeitung für Session-Management
|
||||
- **Popup Interface**: Elegant gestaltetes Popup mit Echtzeit-Feedback
|
||||
- **Chrome Storage API**: Synchronisation mit Web-App-Sessions
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- **Minimale Berechtigungen**: Nur `activeTab` und `storage`
|
||||
- **HTTPS Only**: Sichere Kommunikation mit Supabase
|
||||
- **Token-basierte Auth**: Nutzt bestehende Supabase-Session
|
||||
- **Domain-Validierung**: Verhindert Speichern von Browser-internen Seiten
|
||||
|
||||
### Integration
|
||||
|
||||
- Nutzt die gleiche `save-article` Edge Function wie die App
|
||||
- Teilt sich die Session mit der Web-App über Chrome Storage
|
||||
- Automatische Token-Erneuerung und Logout-Erkennung
|
||||
|
||||
## Datei-Struktur
|
||||
|
||||
```
|
||||
browser-extension/
|
||||
├── manifest.json # Extension-Konfiguration (Manifest V3)
|
||||
├── popup.html # Popup-Interface HTML
|
||||
├── popup.js # Popup-Logik und API-Calls
|
||||
├── background.js # Service Worker für Background-Tasks
|
||||
├── icons/ # Extension-Icons (TODO: Icons hinzufügen)
|
||||
│ ├── icon-16.png
|
||||
│ ├── icon-32.png
|
||||
│ ├── icon-48.png
|
||||
│ └── icon-128.png
|
||||
└── README.md # Diese Datei
|
||||
```
|
||||
|
||||
## TODO: Icons
|
||||
|
||||
Die Extension benötigt noch Icons in verschiedenen Größen:
|
||||
|
||||
- 16x16px (Toolbar)
|
||||
- 32x32px (Extension-Management)
|
||||
- 48x48px (Extension-Management)
|
||||
- 128x128px (Chrome Web Store)
|
||||
|
||||
Icons sollten das Kokon-Logo (🥥) oder ein ähnliches Design verwenden.
|
||||
|
||||
## Chrome Web Store Deployment
|
||||
|
||||
Für die Veröffentlichung im Chrome Web Store:
|
||||
|
||||
1. **Icons hinzufügen** (siehe TODO oben)
|
||||
2. **Version bumpen** in `manifest.json`
|
||||
3. **Extension packen**:
|
||||
```bash
|
||||
zip -r kokon-extension.zip browser-extension/
|
||||
```
|
||||
4. **Chrome Developer Dashboard**: Upload auf [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
|
||||
|
||||
## Firefox Add-ons Deployment
|
||||
|
||||
Für Mozilla Add-ons:
|
||||
|
||||
1. **Firefox-spezifische Anpassungen** (falls nötig)
|
||||
2. **Signierung** über [Mozilla Add-on Developer Hub](https://addons.mozilla.org/developers/)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Testing
|
||||
|
||||
1. Lade die Extension im Entwicklermodus
|
||||
2. Öffne eine beliebige Webseite
|
||||
3. Teste das Popup und die Save-Funktionalität
|
||||
4. Überprüfe die Browser-Konsole für Fehler
|
||||
|
||||
### Debugging
|
||||
|
||||
- **Popup debuggen**: Rechtsklick auf Extension-Icon → "Inspect popup"
|
||||
- **Background Script**: In `chrome://extensions/` → "Inspect views: background page"
|
||||
- **Storage prüfen**: Chrome DevTools → Application → Storage → Extension
|
||||
|
||||
## Kompatibilität
|
||||
|
||||
- **Chrome**: Version 88+ (Manifest V3 Support)
|
||||
- **Edge**: Version 88+ (Chromium-basiert)
|
||||
- **Firefox**: Version 109+ (Manifest V3 Support)
|
||||
- **Safari**: Benötigt Anpassungen für Safari Web Extensions
|
||||
|
||||
## Lizenz
|
||||
|
||||
Teil des Kokon-Projekts - siehe Haupt-Repository für Lizenzdetails.
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
// Background service worker for Kokon Browser Extension
|
||||
|
||||
// Installation handler
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
console.log('Kokon extension installed');
|
||||
|
||||
// Optionally open the web app on first install
|
||||
chrome.tabs.create({
|
||||
url: 'http://localhost:8081', // Local Expo web development server
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle extension icon click (this is mainly handled by the popup, but kept for completeness)
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
// This won't fire if popup.html is defined in manifest, but keeping for fallback
|
||||
console.log('Extension icon clicked for tab:', tab.url);
|
||||
});
|
||||
|
||||
// Listen for messages from content scripts (if needed in the future)
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
console.log('Background received message:', request);
|
||||
|
||||
// Handle any background tasks here
|
||||
if (request.action === 'saveArticle') {
|
||||
// This could be used for context menu integration in the future
|
||||
console.log('Save article request for:', request.url);
|
||||
}
|
||||
|
||||
return true; // Keep message channel open for async response
|
||||
});
|
||||
|
||||
// Sync storage with web app (for session management)
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === 'local') {
|
||||
console.log('Storage changed:', changes);
|
||||
|
||||
// Monitor auth state changes
|
||||
if (changes['supabase.auth.token']) {
|
||||
const newToken = changes['supabase.auth.token'].newValue;
|
||||
if (newToken) {
|
||||
console.log('User logged in');
|
||||
// Could update badge or perform other actions
|
||||
} else {
|
||||
console.log('User logged out');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle context menu (optional future feature)
|
||||
// chrome.contextMenus.create({
|
||||
// id: "saveToKokon",
|
||||
// title: "Save to Kokon",
|
||||
// contexts: ["page", "link"]
|
||||
// });
|
||||
|
||||
// chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
// if (info.menuItemId === "saveToKokon") {
|
||||
// const url = info.linkUrl || tab.url;
|
||||
// // Handle saving the article
|
||||
// }
|
||||
// });
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
// Content script to sync localStorage with Chrome storage
|
||||
console.log('🥥 Kokon content script loaded on:', window.location.href);
|
||||
|
||||
// Function to sync localStorage to Chrome storage
|
||||
function syncToChrome(key, value) {
|
||||
if (chrome && chrome.storage) {
|
||||
chrome.storage.local
|
||||
.set({ [key]: value })
|
||||
.then(() => {
|
||||
console.log('Content script: Successfully synced to Chrome storage:', key);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Content script: Failed to sync to Chrome storage:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Function to sync removal from localStorage to Chrome storage
|
||||
function removeFromChrome(key) {
|
||||
if (chrome && chrome.storage) {
|
||||
chrome.storage.local
|
||||
.remove([key])
|
||||
.then(() => {
|
||||
console.log('Content script: Successfully removed from Chrome storage:', key);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Content script: Failed to remove from Chrome storage:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for localStorage changes and sync to Chrome storage
|
||||
function setupStorageSync() {
|
||||
console.log('🥥 Setting up storage sync...');
|
||||
|
||||
// The actual Supabase auth token key
|
||||
const SUPABASE_AUTH_KEY = 'sb-hepsjdbvpkumaoabbycd-auth-token';
|
||||
|
||||
// Override localStorage.setItem to sync
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function (key, value) {
|
||||
console.log('🥥 localStorage.setItem called:', key);
|
||||
originalSetItem.call(this, key, value);
|
||||
if (key === SUPABASE_AUTH_KEY) {
|
||||
console.log('🥥 Detected supabase token change, syncing...');
|
||||
// Store with standardized key for extension
|
||||
syncToChrome('supabase.auth.token', value);
|
||||
}
|
||||
};
|
||||
|
||||
// Override localStorage.removeItem to sync
|
||||
const originalRemoveItem = localStorage.removeItem;
|
||||
localStorage.removeItem = function (key) {
|
||||
console.log('🥥 localStorage.removeItem called:', key);
|
||||
originalRemoveItem.call(this, key);
|
||||
if (key === SUPABASE_AUTH_KEY) {
|
||||
console.log('🥥 Detected supabase token removal, syncing...');
|
||||
removeFromChrome('supabase.auth.token');
|
||||
}
|
||||
};
|
||||
|
||||
// Check for existing token on page load
|
||||
const existingToken = localStorage.getItem(SUPABASE_AUTH_KEY);
|
||||
console.log('🥥 Checking for existing token:', existingToken ? 'Found' : 'Not found');
|
||||
if (existingToken) {
|
||||
console.log('🥥 Found existing token, syncing...');
|
||||
syncToChrome('supabase.auth.token', existingToken);
|
||||
}
|
||||
|
||||
// Also check all localStorage keys
|
||||
console.log('🥥 All localStorage keys:', Object.keys(localStorage));
|
||||
}
|
||||
|
||||
// Set up the sync when the page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupStorageSync);
|
||||
} else {
|
||||
setupStorageSync();
|
||||
}
|
||||
|
||||
// Also listen for storage events (in case other tabs make changes)
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'sb-hepsjdbvpkumaoabbycd-auth-token') {
|
||||
console.log('🥥 Storage event detected for supabase token');
|
||||
if (e.newValue) {
|
||||
syncToChrome('supabase.auth.token', e.newValue);
|
||||
} else {
|
||||
removeFromChrome('supabase.auth.token');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Debug Extension Storage</title>
|
||||
<style>
|
||||
body { font-family: Arial; padding: 20px; }
|
||||
.section { margin: 20px 0; }
|
||||
button { padding: 10px; margin: 5px; }
|
||||
pre { background: #f5f5f5; padding: 10px; border-radius: 5px; max-height: 400px; overflow-y: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Kokon Extension Debug</h1>
|
||||
|
||||
<div class="section">
|
||||
<button id="checkBtn">Check Chrome Storage</button>
|
||||
<button id="clearBtn">Clear Chrome Storage</button>
|
||||
<button id="testBtn">Set Test Data</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Chrome Storage Contents:</h3>
|
||||
<pre id="storageContent">Loading...</pre>
|
||||
</div>
|
||||
|
||||
<script src="debug.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// Debug script for Extension Storage
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get(null);
|
||||
document.getElementById('storageContent').textContent = JSON.stringify(result, null, 2);
|
||||
console.log('Chrome Storage contents:', result);
|
||||
} catch (error) {
|
||||
document.getElementById('storageContent').textContent = 'Error: ' + error.message;
|
||||
console.error('Error checking storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearStorage() {
|
||||
try {
|
||||
await chrome.storage.local.clear();
|
||||
document.getElementById('storageContent').textContent = 'Storage cleared';
|
||||
console.log('Chrome Storage cleared');
|
||||
} catch (error) {
|
||||
console.error('Error clearing storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setTestData() {
|
||||
try {
|
||||
const testSession = {
|
||||
access_token: 'test-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
refresh_token: 'test-refresh',
|
||||
};
|
||||
|
||||
await chrome.storage.local.set({
|
||||
'supabase.auth.token': JSON.stringify(testSession),
|
||||
});
|
||||
|
||||
document.getElementById('storageContent').textContent = 'Test data set';
|
||||
console.log('Test data set in Chrome Storage');
|
||||
} catch (error) {
|
||||
console.error('Error setting test data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('checkBtn').addEventListener('click', checkStorage);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearStorage);
|
||||
document.getElementById('testBtn').addEventListener('click', setTestData);
|
||||
|
||||
// Auto-check on load
|
||||
checkStorage();
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "News Hub - Save Article",
|
||||
"version": "1.0.0",
|
||||
"description": "Save articles from any website to your News Hub library",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"host_permissions": ["http://localhost:3000/*"],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Save to News Hub"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://localhost:*/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>News Hub - Save Article</title>
|
||||
<style>
|
||||
body {
|
||||
width: 350px;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.page-url {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: #3b82f6;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.save-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background: #475569;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.login-notice {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">NEWS HUB</div>
|
||||
<div class="subtitle">Save Article to Library</div>
|
||||
|
||||
<div id="loginNotice" class="login-notice" style="display: none;">
|
||||
<div>Please log in to News Hub first:</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<a href="#" id="loginLink" class="login-link">Open News Hub App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="currentPage" class="current-page">
|
||||
<div class="page-title" id="pageTitle">Loading page info...</div>
|
||||
<div class="page-url" id="pageUrl"></div>
|
||||
</div>
|
||||
|
||||
<button id="saveButton" class="save-button" disabled>
|
||||
<span id="buttonText">Ready to Save</span>
|
||||
</button>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
// Browser Extension Popup Script for News Hub
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
const pageUrl = document.getElementById('pageUrl');
|
||||
const saveButton = document.getElementById('saveButton');
|
||||
const buttonText = document.getElementById('buttonText');
|
||||
const status = document.getElementById('status');
|
||||
const loginNotice = document.getElementById('loginNotice');
|
||||
const loginLink = document.getElementById('loginLink');
|
||||
|
||||
// API Configuration
|
||||
const API_URL = 'http://localhost:3000';
|
||||
const APP_URL = 'http://localhost:8081';
|
||||
|
||||
let currentTab = null;
|
||||
let authToken = null;
|
||||
|
||||
// Get current tab info
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
currentTab = tab;
|
||||
|
||||
pageTitle.textContent = tab.title || 'Untitled Page';
|
||||
pageUrl.textContent = tab.url;
|
||||
} catch (error) {
|
||||
console.error('Error getting tab info:', error);
|
||||
pageTitle.textContent = 'Error loading page info';
|
||||
status.textContent = 'Failed to get page information';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in by looking for stored token in Chrome storage
|
||||
try {
|
||||
const result = await chrome.storage.local.get(['news_hub_auth_token']);
|
||||
authToken = result['news_hub_auth_token'];
|
||||
|
||||
console.log('Checking Chrome storage for token...', authToken ? 'Found' : 'Not found');
|
||||
|
||||
if (authToken) {
|
||||
// Verify token is still valid by calling session endpoint
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/session`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
saveButton.disabled = false;
|
||||
loginNotice.style.display = 'none';
|
||||
// Auto-save article immediately
|
||||
saveArticle();
|
||||
} else {
|
||||
// Token is invalid
|
||||
await chrome.storage.local.remove(['news_hub_auth_token']);
|
||||
authToken = null;
|
||||
showLoginNotice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
showLoginNotice();
|
||||
}
|
||||
} else {
|
||||
showLoginNotice();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking login status:', error);
|
||||
showLoginNotice();
|
||||
}
|
||||
|
||||
function showLoginNotice() {
|
||||
loginNotice.style.display = 'block';
|
||||
saveButton.disabled = true;
|
||||
status.textContent = 'Please log in to News Hub first';
|
||||
status.className = 'status error';
|
||||
}
|
||||
|
||||
// Handle login link click
|
||||
loginLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.tabs.create({ url: APP_URL });
|
||||
window.close();
|
||||
});
|
||||
|
||||
// Save article function
|
||||
async function saveArticle() {
|
||||
if (!currentTab || !authToken) {
|
||||
status.textContent = 'Please log in first';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
const url = currentTab.url;
|
||||
if (
|
||||
!url ||
|
||||
url.startsWith('chrome://') ||
|
||||
url.startsWith('chrome-extension://') ||
|
||||
url.startsWith('about:')
|
||||
) {
|
||||
status.textContent = 'Cannot save this type of page';
|
||||
status.className = 'status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
saveButton.disabled = true;
|
||||
buttonText.innerHTML = '<span class="loading"></span>Saving...';
|
||||
status.textContent = 'Saving article...';
|
||||
status.className = 'status';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/extract/save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ url: url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
status.textContent = 'Article saved!';
|
||||
status.className = 'status success';
|
||||
|
||||
// Show success for a moment, then close
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to save article');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving article:', error);
|
||||
|
||||
let errorMessage = 'Failed to save article';
|
||||
if (error.message.includes('fetch') || error.message.includes('NetworkError')) {
|
||||
errorMessage = 'Network error - is the API running?';
|
||||
} else if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
||||
errorMessage = 'Session expired - please log in again';
|
||||
showLoginNotice();
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
status.textContent = errorMessage;
|
||||
status.className = 'status error';
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveButton.disabled = authToken ? false : true;
|
||||
buttonText.textContent = 'Try Again';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle save button click (manual save if auto-save failed)
|
||||
saveButton.addEventListener('click', saveArticle);
|
||||
|
||||
// Listen for storage changes (if user logs in/out in another tab)
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === 'local' && changes['news_hub_auth_token']) {
|
||||
const newValue = changes['news_hub_auth_token'].newValue;
|
||||
|
||||
if (newValue) {
|
||||
authToken = newValue;
|
||||
saveButton.disabled = false;
|
||||
loginNotice.style.display = 'none';
|
||||
status.textContent = '';
|
||||
status.className = 'status';
|
||||
} else {
|
||||
authToken = null;
|
||||
showLoginNotice();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue