mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(news): migrate from archive to local-first + Hono architecture
- Move from apps-archived/ to apps/ - Delete NestJS API, Docker files, old docs, browser extension - Create Hono/Bun server with content extraction (Mozilla Readability) and AI feed API reading from mana-sync's sync_changes - Create local-first store (articles, categories) with guest seed data - Rewrite web app: Feed page, Saved articles with URL extraction, auth pages using shared-auth-ui, AuthGate with guest mode - Add news to shared-branding (app icon, mana-apps registry) - Add CLAUDE.md, dev scripts, root CLAUDE.md entry - 0 type errors on both server and web Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a48182677
commit
4d390be5af
574 changed files with 1385 additions and 100253 deletions
12
apps/news/.env.example
Normal file
12
apps/news/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# 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
Normal file
52
apps/news/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 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/
|
||||
67
apps/news/CLAUDE.md
Normal file
67
apps/news/CLAUDE.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# News Hub — AI News Reader & Personal Library
|
||||
|
||||
## Architecture
|
||||
|
||||
Local-first for saved articles, Hono/Bun server for content extraction and AI feed.
|
||||
|
||||
```
|
||||
Browser → IndexedDB (Saved Articles, Categories)
|
||||
↕ sync
|
||||
mana-sync → PostgreSQL
|
||||
|
||||
Browser → Hono Server → Content Extraction (Mozilla Readability)
|
||||
→ AI Feed (from sync_changes)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/news/
|
||||
├── apps/
|
||||
│ ├── web/ # SvelteKit web app (local-first)
|
||||
│ ├── server/ # Hono/Bun (extraction, feed API)
|
||||
│ └── landing/ # Astro marketing page
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Web** | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| **Server** | Hono + Bun, Mozilla Readability, JSDOM |
|
||||
| **Data** | Local-first (Dexie.js + mana-sync) |
|
||||
| **Auth** | mana-core-auth (Better Auth + EdDSA JWT) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:news:web # SvelteKit dev server
|
||||
pnpm dev:news:server # Hono/Bun server (port 3071)
|
||||
pnpm dev:news:local # Web + Sync + Server (no auth)
|
||||
pnpm dev:news:full # Everything incl. auth
|
||||
```
|
||||
|
||||
## Hono Server Routes
|
||||
|
||||
| Route | Auth | Description |
|
||||
|-------|------|-------------|
|
||||
| `GET /health` | No | Health check |
|
||||
| `GET /api/v1/feed` | No | AI article feed (type, categoryId, limit, offset) |
|
||||
| `GET /api/v1/feed/:id` | No | Single article |
|
||||
| `POST /api/v1/extract/preview` | No | Preview URL content extraction |
|
||||
| `POST /api/v1/extract/save` | JWT | Extract + return article data |
|
||||
|
||||
## Local-First Collections
|
||||
|
||||
| Collection | Purpose |
|
||||
|-----------|---------|
|
||||
| `articles` | Saved articles (user_saved) + AI feed cache |
|
||||
| `categories` | Article categories |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Content Extraction**: Mozilla Readability + JSDOM for robust HTML parsing
|
||||
- **Saved Articles**: Local-first via IndexedDB, sync to server
|
||||
- **AI Feed**: Loaded from Hono server, not local-first (server-generated)
|
||||
- **Auth**: Guest mode allowed, sync starts on login
|
||||
8
apps/news/apps/landing/astro.config.mjs
Normal file
8
apps/news/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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()],
|
||||
});
|
||||
26
apps/news/apps/landing/package.json
Normal file
26
apps/news/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
94
apps/news/apps/landing/src/components/Footer.astro
Normal file
94
apps/news/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
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>
|
||||
101
apps/news/apps/landing/src/components/Navigation.astro
Normal file
101
apps/news/apps/landing/src/components/Navigation.astro
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
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>
|
||||
48
apps/news/apps/landing/src/layouts/Layout.astro
Normal file
48
apps/news/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
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>
|
||||
278
apps/news/apps/landing/src/pages/index.astro
Normal file
278
apps/news/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
---
|
||||
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>
|
||||
103
apps/news/apps/landing/src/styles/global.css
Normal file
103
apps/news/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
@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;
|
||||
}
|
||||
37
apps/news/apps/landing/tailwind.config.mjs
Normal file
37
apps/news/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/** @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')],
|
||||
};
|
||||
9
apps/news/apps/landing/tsconfig.json
Normal file
9
apps/news/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/news/apps/server/package.json
Normal file
24
apps/news/apps/server/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@news/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
26
apps/news/apps/server/src/config.ts
Normal file
26
apps/news/apps/server/src/config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
cors: { origins: string[] };
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const requiredEnv = (key: string, fallback?: string): string => {
|
||||
const value = process.env[key] || fallback;
|
||||
if (!value) throw new Error(`Missing required env var: ${key}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3071', 10),
|
||||
databaseUrl: requiredEnv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://manacore:devpassword@localhost:5432/mana_sync'
|
||||
),
|
||||
manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
14
apps/news/apps/server/src/db/connection.ts
Normal file
14
apps/news/apps/server/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const client = postgres(databaseUrl, { max: 10 });
|
||||
db = drizzle(client);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
46
apps/news/apps/server/src/index.ts
Normal file
46
apps/news/apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { ExtractService } from './services/extract';
|
||||
import { FeedService } from './services/feed';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createExtractRoutes } from './routes/extract';
|
||||
import { createFeedRoutes } from './routes/feed';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const extractService = new ExtractService();
|
||||
const feedService = new FeedService(db);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||
|
||||
// Public routes (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
app.route('/api/v1/feed', createFeedRoutes(feedService));
|
||||
|
||||
// Preview extraction (public)
|
||||
app.post('/api/v1/extract/preview', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) return c.json({ error: 'URL is required' }, 400);
|
||||
const article = await extractService.extractFromUrl(url);
|
||||
return c.json(article);
|
||||
});
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.use('/api/v1/extract/save', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/extract', createExtractRoutes(extractService));
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`news-server starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
19
apps/news/apps/server/src/lib/errors.ts
Normal file
19
apps/news/apps/server/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message = 'Bad request') {
|
||||
super(400, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
11
apps/news/apps/server/src/middleware/error-handler.ts
Normal file
11
apps/news/apps/server/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
|
||||
};
|
||||
46
apps/news/apps/server/src/middleware/jwt-auth.ts
Normal file
46
apps/news/apps/server/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
38
apps/news/apps/server/src/routes/extract.ts
Normal file
38
apps/news/apps/server/src/routes/extract.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { ExtractService } from '../services/extract';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
|
||||
export function createExtractRoutes(extractService: ExtractService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.post('/preview', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) throw new BadRequestError('URL is required');
|
||||
|
||||
const article = await extractService.extractFromUrl(url);
|
||||
return c.json(article);
|
||||
})
|
||||
.post('/save', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) throw new BadRequestError('URL is required');
|
||||
|
||||
const extracted = await extractService.extractFromUrl(url);
|
||||
|
||||
// Return extracted data — client saves to local-first store
|
||||
return c.json({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
originalUrl: url,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
excerpt: extracted.excerpt,
|
||||
author: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
21
apps/news/apps/server/src/routes/feed.ts
Normal file
21
apps/news/apps/server/src/routes/feed.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { FeedService } from '../services/feed';
|
||||
|
||||
export function createFeedRoutes(feedService: FeedService) {
|
||||
return new Hono()
|
||||
.get('/', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
const categoryId = c.req.query('categoryId');
|
||||
const limit = parseInt(c.req.query('limit') || '20', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
const articles = await feedService.getArticles({ type, categoryId, limit, offset });
|
||||
return c.json(articles);
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const article = await feedService.getArticleById(id);
|
||||
if (!article) return c.json({ error: 'Article not found' }, 404);
|
||||
return c.json(article);
|
||||
});
|
||||
}
|
||||
10
apps/news/apps/server/src/routes/health.ts
Normal file
10
apps/news/apps/server/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({
|
||||
status: 'ok',
|
||||
service: 'news-server',
|
||||
runtime: 'bun',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
50
apps/news/apps/server/src/services/extract.ts
Normal file
50
apps/news/apps/server/src/services/extract.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Readability } from '@mozilla/readability';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
export interface ExtractedArticle {
|
||||
title: string;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
excerpt: string;
|
||||
byline: string | null;
|
||||
siteName: string | null;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
}
|
||||
|
||||
export class ExtractService {
|
||||
async extractFromUrl(url: string): Promise<ExtractedArticle> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ManaNews/1.0; +https://mana.how)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const dom = new JSDOM(html, { url });
|
||||
const reader = new Readability(dom.window.document);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new Error('Could not extract article content');
|
||||
}
|
||||
|
||||
const wordCount = article.textContent.split(/\s+/).filter(Boolean).length;
|
||||
const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
content: article.textContent,
|
||||
htmlContent: article.content,
|
||||
excerpt: article.excerpt || article.textContent.slice(0, 200),
|
||||
byline: article.byline || null,
|
||||
siteName: article.siteName || null,
|
||||
wordCount,
|
||||
readingTimeMinutes,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
apps/news/apps/server/src/services/feed.ts
Normal file
71
apps/news/apps/server/src/services/feed.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
|
||||
/**
|
||||
* Feed service reads AI-generated articles from sync_changes.
|
||||
* Articles with type='feed'|'summary'|'in_depth' and sourceOrigin='ai'.
|
||||
*/
|
||||
export class FeedService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getArticles(opts: { type?: string; categoryId?: string; limit?: number; offset?: number }) {
|
||||
const limit = opts.limit || 20;
|
||||
const offset = opts.offset || 0;
|
||||
|
||||
let whereClause = sql`app_id = 'news' AND table_name = 'articles' AND op != 'delete'`;
|
||||
|
||||
if (opts.type) {
|
||||
whereClause = sql`${whereClause} AND data->>'type' = ${opts.type}`;
|
||||
}
|
||||
if (opts.categoryId) {
|
||||
whereClause = sql`${whereClause} AND data->>'categoryId' = ${opts.categoryId}`;
|
||||
}
|
||||
|
||||
const result = await this.db.execute(sql`
|
||||
SELECT DISTINCT ON (record_id)
|
||||
record_id as id,
|
||||
data->>'title' as title,
|
||||
data->>'excerpt' as excerpt,
|
||||
data->>'author' as author,
|
||||
data->>'imageUrl' as "imageUrl",
|
||||
data->>'type' as type,
|
||||
data->>'categoryId' as "categoryId",
|
||||
(data->>'wordCount')::int as "wordCount",
|
||||
(data->>'readingTimeMinutes')::int as "readingTimeMinutes",
|
||||
data->>'publishedAt' as "publishedAt",
|
||||
created_at as "createdAt"
|
||||
FROM sync_changes
|
||||
WHERE ${whereClause}
|
||||
ORDER BY record_id, created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`);
|
||||
|
||||
return result as unknown as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async getArticleById(id: string) {
|
||||
const result = await this.db.execute(sql`
|
||||
SELECT DISTINCT ON (record_id)
|
||||
record_id as id,
|
||||
data->>'title' as title,
|
||||
data->>'content' as content,
|
||||
data->>'htmlContent' as "htmlContent",
|
||||
data->>'excerpt' as excerpt,
|
||||
data->>'author' as author,
|
||||
data->>'imageUrl' as "imageUrl",
|
||||
data->>'originalUrl' as "originalUrl",
|
||||
data->>'type' as type,
|
||||
(data->>'wordCount')::int as "wordCount",
|
||||
(data->>'readingTimeMinutes')::int as "readingTimeMinutes",
|
||||
data->>'publishedAt' as "publishedAt",
|
||||
created_at as "createdAt"
|
||||
FROM sync_changes
|
||||
WHERE app_id = 'news' AND table_name = 'articles' AND record_id = ${id} AND op != 'delete'
|
||||
ORDER BY record_id, created_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const rows = result as unknown as Record<string, unknown>[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
16
apps/news/apps/server/tsconfig.json
Normal file
16
apps/news/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
2
apps/news/apps/web/.env.example
Normal file
2
apps/news/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# News Hub Web App Configuration
|
||||
PUBLIC_NEWS_API_URL=http://localhost:3000
|
||||
17
apps/news/apps/web/eslint.config.js
Normal file
17
apps/news/apps/web/eslint.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
svelteConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', '.svelte-kit/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...svelteConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
33
apps/news/apps/web/package.json
Normal file
33
apps/news/apps/web/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"svelte-sonner": "^1.0.5"
|
||||
}
|
||||
}
|
||||
8
apps/news/apps/web/src/app.css
Normal file
8
apps/news/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
@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";
|
||||
11
apps/news/apps/web/src/app.d.ts
vendored
Normal file
11
apps/news/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
apps/news/apps/web/src/app.html
Normal file
12
apps/news/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!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>
|
||||
11
apps/news/apps/web/src/lib/components/NewsLogo.svelte
Normal file
11
apps/news/apps/web/src/lib/components/NewsLogo.svelte
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
let { size = 48, color = '#10b981' }: { size?: number; color?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="22" fill={color} />
|
||||
<rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none" />
|
||||
<line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round" />
|
||||
<line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round" />
|
||||
<line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
39
apps/news/apps/web/src/lib/data/guest-seed.ts
Normal file
39
apps/news/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { LocalArticle, LocalCategory } from './local-store';
|
||||
|
||||
export const guestCategories: LocalCategory[] = [
|
||||
{ id: 'cat-tech', name: 'Technologie', slug: 'technologie', color: '#3b82f6', order: 0 },
|
||||
{ id: 'cat-science', name: 'Wissenschaft', slug: 'wissenschaft', color: '#10b981', order: 1 },
|
||||
{ id: 'cat-world', name: 'Welt', slug: 'welt', color: '#f59e0b', order: 2 },
|
||||
{ id: 'cat-business', name: 'Wirtschaft', slug: 'wirtschaft', color: '#8b5cf6', order: 3 },
|
||||
];
|
||||
|
||||
export const guestArticles: LocalArticle[] = [
|
||||
{
|
||||
id: 'demo-1',
|
||||
type: 'feed',
|
||||
sourceOrigin: 'ai',
|
||||
title: 'Willkommen bei News Hub!',
|
||||
excerpt: 'Dein persönlicher Nachrichtenleser mit KI-Zusammenfassungen und Read-Later Funktion.',
|
||||
content:
|
||||
'News Hub kombiniert KI-kuratierte Nachrichten mit deiner persönlichen Leseliste. Speichere Artikel von jeder Website, lese sie offline und entdecke neue Perspektiven.',
|
||||
categoryId: 'cat-tech',
|
||||
isArchived: false,
|
||||
wordCount: 42,
|
||||
readingTimeMinutes: 1,
|
||||
publishedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'demo-2',
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
title: 'Beispiel: Gespeicherter Artikel',
|
||||
excerpt:
|
||||
'So sieht ein gespeicherter Artikel aus. Nutze die Browser-Extension um Artikel zu speichern.',
|
||||
originalUrl: 'https://example.com',
|
||||
content:
|
||||
'Dies ist ein Beispiel für einen Artikel, den du über die Browser-Extension oder die Web-App gespeichert hast.',
|
||||
isArchived: false,
|
||||
wordCount: 30,
|
||||
readingTimeMinutes: 1,
|
||||
},
|
||||
];
|
||||
50
apps/news/apps/web/src/lib/data/local-store.ts
Normal file
50
apps/news/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export interface LocalArticle extends BaseRecord {
|
||||
type: 'feed' | 'summary' | 'in_depth' | 'saved';
|
||||
sourceOrigin: 'ai' | 'user_saved';
|
||||
title: string;
|
||||
content?: string | null;
|
||||
htmlContent?: string | null;
|
||||
excerpt?: string | null;
|
||||
originalUrl?: string | null;
|
||||
author?: string | null;
|
||||
siteName?: string | null;
|
||||
imageUrl?: string | null;
|
||||
wordCount?: number | null;
|
||||
readingTimeMinutes?: number | null;
|
||||
categoryId?: string | null;
|
||||
isArchived: boolean;
|
||||
publishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalCategory extends BaseRecord {
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
import { guestArticles, guestCategories } from './guest-seed';
|
||||
|
||||
export const newsStore = createLocalStore({
|
||||
appId: 'news',
|
||||
collections: [
|
||||
{
|
||||
name: 'articles',
|
||||
indexes: ['type', 'sourceOrigin', 'isArchived', 'categoryId', '[type+isArchived]'],
|
||||
guestSeed: guestArticles,
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
indexes: ['slug', 'order'],
|
||||
guestSeed: guestCategories,
|
||||
},
|
||||
],
|
||||
sync: { serverUrl: SYNC_SERVER_URL },
|
||||
});
|
||||
|
||||
export const articleCollection = newsStore.collection<LocalArticle>('articles');
|
||||
export const categoryCollection = newsStore.collection<LocalCategory>('categories');
|
||||
5
apps/news/apps/web/src/lib/stores/auth.svelte.ts
Normal file
5
apps/news/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3071,
|
||||
});
|
||||
120
apps/news/apps/web/src/routes/(protected)/+layout.svelte
Normal file
120
apps/news/apps/web/src/routes/(protected)/+layout.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem } from '@manacore/shared-ui';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { AuthGate, GuestWelcomeModal, SessionExpiredBanner } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { newsStore } from '$lib/data/local-store';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const appItems = getPillAppItems();
|
||||
let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : '');
|
||||
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/feed', label: 'Feed', icon: 'rss' },
|
||||
{ href: '/saved', label: 'Gespeichert', icon: 'bookmark' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
];
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
let isDark = $state(true);
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
return;
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
const routes = ['/feed', '/saved', '/settings'];
|
||||
if (num >= 1 && num <= 3) {
|
||||
event.preventDefault();
|
||||
goto(routes[num - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
localStorage?.setItem('news-nav-collapsed', String(collapsed));
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
isDark = !isDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
localStorage?.setItem('news-dark-mode', String(isDark));
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
newsStore.stopSync();
|
||||
await authStore.signOut();
|
||||
goto('/auth/login');
|
||||
}
|
||||
|
||||
function handleAuthReady() {
|
||||
if (authStore.isAuthenticated) {
|
||||
newsStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('news')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
const savedCollapsed = localStorage?.getItem('news-nav-collapsed');
|
||||
if (savedCollapsed === 'true') isCollapsed = true;
|
||||
const savedDark = localStorage?.getItem('news-dark-mode');
|
||||
isDark = savedDark !== 'false'; // default dark
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="News"
|
||||
homeRoute="/feed"
|
||||
onLogout={handleLogout}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
primaryColor="#10b981"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
>
|
||||
{#snippet logo()}
|
||||
<span class="text-xl">📰</span>
|
||||
<span class="pill-label font-bold">News</span>
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<main class="main-content flex-1 transition-all duration-300 {isCollapsed ? '' : 'pt-20'}">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<GuestWelcomeModal
|
||||
appId="news"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/auth/login')}
|
||||
onRegister={() => goto('/auth/register')}
|
||||
locale="de"
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale="de" loginHref="/auth/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
103
apps/news/apps/web/src/routes/(protected)/feed/+page.svelte
Normal file
103
apps/news/apps/web/src/routes/(protected)/feed/+page.svelte
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const NEWS_SERVER = import.meta.env.PUBLIC_NEWS_SERVER_URL || 'http://localhost:3071';
|
||||
|
||||
let articles = $state<Record<string, unknown>[]>([]);
|
||||
let loading = $state(true);
|
||||
let selectedType = $state<string>('');
|
||||
|
||||
async function loadArticles() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedType) params.set('type', selectedType);
|
||||
params.set('limit', '30');
|
||||
const res = await fetch(`${NEWS_SERVER}/api/v1/feed?${params}`);
|
||||
if (res.ok) articles = await res.json();
|
||||
} catch {
|
||||
// Server offline
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function changeType(type: string) {
|
||||
selectedType = type;
|
||||
loadArticles();
|
||||
}
|
||||
|
||||
onMount(loadArticles);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold">Feed</h1>
|
||||
<div class="flex gap-1">
|
||||
{#each [{ value: '', label: 'Alle' }, { value: 'feed', label: 'News' }, { value: 'summary', label: 'Summaries' }, { value: 'in_depth', label: 'In-Depth' }] as tab}
|
||||
<button
|
||||
onclick={() => changeType(tab.value)}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {selectedType ===
|
||||
tab.value
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-24 animate-pulse rounded-xl bg-gray-800"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="rounded-xl border-2 border-dashed border-gray-700 p-12 text-center">
|
||||
<p class="text-lg font-medium text-gray-400">Noch keine Artikel im Feed</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
AI-kuratierte Nachrichten erscheinen hier automatisch.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each articles as article}
|
||||
<a
|
||||
href="/feed/{article.id}"
|
||||
class="block rounded-xl border border-gray-800 bg-gray-900 p-5 transition-all hover:border-gray-700 hover:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
{#if article.imageUrl}
|
||||
<img
|
||||
src={String(article.imageUrl)}
|
||||
alt=""
|
||||
class="h-20 w-28 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
{#if article.type === 'summary'}
|
||||
<span class="rounded bg-blue-900 px-1.5 py-0.5 text-xs text-blue-300"
|
||||
>Summary</span
|
||||
>
|
||||
{:else if article.type === 'in_depth'}
|
||||
<span class="rounded bg-purple-900 px-1.5 py-0.5 text-xs text-purple-300"
|
||||
>In-Depth</span
|
||||
>
|
||||
{/if}
|
||||
{#if article.readingTimeMinutes}
|
||||
<span class="text-xs text-gray-500">{article.readingTimeMinutes} Min.</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="truncate text-lg font-semibold text-gray-100">{article.title}</h2>
|
||||
{#if article.excerpt}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-gray-400">{article.excerpt}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
186
apps/news/apps/web/src/routes/(protected)/saved/+page.svelte
Normal file
186
apps/news/apps/web/src/routes/(protected)/saved/+page.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import { useLiveQuery } from '@manacore/local-store/svelte';
|
||||
import { articleCollection } from '$lib/data/local-store';
|
||||
import type { LocalArticle } from '$lib/data/local-store';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const NEWS_SERVER = import.meta.env.PUBLIC_NEWS_SERVER_URL || 'http://localhost:3071';
|
||||
|
||||
const savedArticles = useLiveQuery(() =>
|
||||
articleCollection.getAll({ sourceOrigin: 'user_saved' })
|
||||
);
|
||||
|
||||
let saveUrl = $state('');
|
||||
let saving = $state(false);
|
||||
let showArchived = $state(false);
|
||||
|
||||
let filteredArticles = $derived.by(() => {
|
||||
const all = savedArticles.value ?? [];
|
||||
return showArchived ? all : all.filter((a) => !a.isArchived);
|
||||
});
|
||||
|
||||
async function saveFromUrl() {
|
||||
if (!saveUrl) return;
|
||||
saving = true;
|
||||
try {
|
||||
const token = authStore.isAuthenticated ? await authStore.getValidToken() : null;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${NEWS_SERVER}/api/v1/extract/preview`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ url: saveUrl }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Extraction failed');
|
||||
const extracted = await res.json();
|
||||
|
||||
await articleCollection.insert({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
excerpt: extracted.excerpt,
|
||||
originalUrl: saveUrl,
|
||||
author: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
toast.success(`"${extracted.title}" gespeichert`);
|
||||
saveUrl = '';
|
||||
} catch {
|
||||
toast.error('Artikel konnte nicht extrahiert werden');
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function toggleArchive(article: LocalArticle) {
|
||||
await articleCollection.update(article.id, { isArchived: !article.isArchived });
|
||||
toast.success(article.isArchived ? 'Wiederhergestellt' : 'Archiviert');
|
||||
}
|
||||
|
||||
async function deleteArticle(article: LocalArticle) {
|
||||
if (!confirm(`"${article.title}" löschen?`)) return;
|
||||
await articleCollection.delete(article.id);
|
||||
toast.success('Gelöscht');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="mb-6 text-3xl font-bold">Gespeicherte Artikel</h1>
|
||||
|
||||
<!-- Save URL Form -->
|
||||
<div class="mb-6 rounded-xl border border-gray-800 bg-gray-900 p-5">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={saveUrl}
|
||||
placeholder="https://example.com/article — URL einfügen und speichern"
|
||||
class="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-gray-100 placeholder-gray-500 focus:border-emerald-500 focus:outline-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && saveFromUrl()}
|
||||
/>
|
||||
<button
|
||||
onclick={saveFromUrl}
|
||||
disabled={!saveUrl || saving}
|
||||
class="rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="mb-4">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-400">
|
||||
<input type="checkbox" bind:checked={showArchived} class="rounded" />
|
||||
Archivierte anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Articles List -->
|
||||
{#if savedArticles.loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="h-20 animate-pulse rounded-xl bg-gray-800"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filteredArticles.length === 0}
|
||||
<div class="rounded-xl border-2 border-dashed border-gray-700 p-12 text-center">
|
||||
<p class="text-lg font-medium text-gray-400">Noch keine gespeicherten Artikel</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Füge eine URL oben ein oder nutze die Browser-Extension.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredArticles as article (article.id)}
|
||||
<div
|
||||
class="group rounded-xl border border-gray-800 bg-gray-900 p-4 transition-all hover:border-gray-700 {article.isArchived
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-semibold text-gray-100">{article.title}</h3>
|
||||
{#if article.excerpt}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-gray-400">{article.excerpt}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||
{#if article.siteName}
|
||||
<span>{article.siteName}</span>
|
||||
{/if}
|
||||
{#if article.readingTimeMinutes}
|
||||
<span>{article.readingTimeMinutes} Min.</span>
|
||||
{/if}
|
||||
{#if article.originalUrl}
|
||||
<a
|
||||
href={article.originalUrl}
|
||||
target="_blank"
|
||||
class="text-emerald-500 hover:underline">Original</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => toggleArchive(article)}
|
||||
class="rounded-lg p-2 text-gray-500 opacity-0 transition-all hover:bg-gray-800 hover:text-gray-300 group-hover:opacity-100"
|
||||
title={article.isArchived ? 'Wiederherstellen' : 'Archivieren'}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteArticle(article)}
|
||||
class="rounded-lg p-2 text-gray-500 opacity-0 transition-all hover:bg-gray-800 hover:text-red-400 group-hover:opacity-100"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
37
apps/news/apps/web/src/routes/+layout.svelte
Normal file
37
apps/news/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { newsStore } from '$lib/data/local-store';
|
||||
|
||||
let { children } = $props();
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
await newsStore.initialize();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-950">
|
||||
<div
|
||||
class="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-emerald-500 border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
expand={false}
|
||||
richColors
|
||||
closeButton
|
||||
duration={4000}
|
||||
visibleToasts={3}
|
||||
/>
|
||||
6
apps/news/apps/web/src/routes/+page.svelte
Normal file
6
apps/news/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => goto('/feed'));
|
||||
</script>
|
||||
27
apps/news/apps/web/src/routes/auth/login/+page.svelte
Normal file
27
apps/news/apps/web/src/routes/auth/login/+page.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import NewsLogo from '$lib/components/NewsLogo.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="News Hub"
|
||||
logo={NewsLogo}
|
||||
primaryColor="#10b981"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={(email) => authStore.resendVerificationEmail(email)}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
|
||||
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
|
||||
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
|
||||
{goto}
|
||||
successRedirect="/feed"
|
||||
registerPath="/auth/register"
|
||||
forgotPasswordPath="/auth/forgot-password"
|
||||
/>
|
||||
20
apps/news/apps/web/src/routes/auth/register/+page.svelte
Normal file
20
apps/news/apps/web/src/routes/auth/register/+page.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import NewsLogo from '$lib/components/NewsLogo.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<RegisterPage
|
||||
appName="News Hub"
|
||||
logo={NewsLogo}
|
||||
primaryColor="#10b981"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/feed"
|
||||
loginPath="/auth/login"
|
||||
/>
|
||||
12
apps/news/apps/web/svelte.config.js
Normal file
12
apps/news/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: [vitePreprocess()],
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/news/apps/web/tsconfig.json
Normal file
14
apps/news/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
7
apps/news/apps/web/vite.config.ts
Normal file
7
apps/news/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
});
|
||||
8
apps/news/package.json
Normal file
8
apps/news/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@manacore/news",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue