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:
Till JS 2026-03-29 19:28:11 +02:00
parent 4a48182677
commit 4d390be5af
574 changed files with 1385 additions and 100253 deletions

12
apps/news/.env.example Normal file
View 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
View 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
View 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

View 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()],
});

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

View 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">
&copy; {currentYear} News Hub. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
</div>
</div>
</footer>

View file

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

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

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

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

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

View file

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

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

View 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(','),
},
};
}

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

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

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

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

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

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

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

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

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

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

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

View file

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

View 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,
];

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

View 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
View file

@ -0,0 +1,11 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

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

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

View 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,
},
];

View 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');

View file

@ -0,0 +1,5 @@
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore({
devBackendPort: 3071,
});

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

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

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

View 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}
/>

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/feed'));
</script>

View 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"
/>

View 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"
/>

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

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

View 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
View file

@ -0,0 +1,8 @@
{
"name": "@manacore/news",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "turbo run dev"
}
}